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:
2026-09-10 10:11:50 +00:00
559 changed files with 36139 additions and 9087 deletions
+30 -1
View File
@@ -17,6 +17,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- `bridge-git-special-runtime.ts`
- Specialized Git flows (`pr-description`, `conflict-details`) and generation helpers.
- Generation model choice lives in `bridge-git-generation-model.ts`: request model first, then the user's small-model override (`smallModelUseDefault === false` plus `smallModelOverride` as `provider/model`) when the catalog has it, then the zen fallback. The old `gitProviderId`/`gitModelId` pair is no longer read.
- `bridge-git-process-runtime.ts`
- Git process execution and environment setup (`execGit`), including SSH agent socket resolution.
@@ -49,6 +50,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews
- `bridge-localfs-proxy-runtime.ts`
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
- `/api/fs/directory-stat` returns 501 locally. Directory-availability probes remain unknown in VS Code rather than falling through to OpenCode.
- Workspace-contained Markdown gallery images use these local filesystem
routes without calling the server grant route. Grant requests for OpenCode
temporary-directory images return an explicit unsupported response instead
@@ -64,8 +66,15 @@ The webview build emits each worker as one self-contained file. VS Code webviews
- Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`).
- OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty, while other content that yields no JSON value (YAML, plain text) fails closed. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file.
- `bridge-project-setup-runtime.ts`
- Extension-host side of `GET/PUT /api/projects/:projectId/config` (the webview handles the route locally and bridges `api:project-setup:get` / `api:project-setup:update`). Reads and writes the client-owned keys of `~/.config/openchamber/projects/<projectId>.json` (worktree setup commands, project actions, draft starters) with the rules in `project-setup.ts`, a mirror of the server's `packages/web/server/lib/projects/project-setup.js`; keep the two in sync. Writes to one file are chained; server-owned and unknown keys survive. The read also merges the team's optional `<workspace>/.openchamber/project.json` (checkout path decoded from the `path_<base64url>` id) by the same rules as the server, so the webview sees one view with `shared` / `personal` blocks. The shared UI (`openchamberConfig.ts`) no longer composes that path or reads it through the fs bridge.
- `bridge-settings-runtime.ts`
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
- Writes are gated by the generated registry snapshot (`settings-registry.json`, via `settings-registry-gate.ts`): keys the registry does not list, or marks `computed`, `local`, or `owner: desktop-shell`, never reach the shared settings files. Regenerate the snapshot with `bun run settings-registry:generate` when the UI registry changes.
- Shared settings live in two files under `~/.config/openchamber/`, split by `settings-files.ts` (a pure mirror of the server's `settings-files.js`; both write the same bytes): `settings.json` holds instance facts and legacy keys, `preferences.json` (`{ version: 1, fields: { key: { value, updatedAt } } }`) holds every registry `profile` key. `updatedAt` is stamped by the extension host only when a value actually changes. Reads return the merged view (preferences win). A missing `preferences.json` is seeded once from the profile keys still in `settings.json`; every write keeps a copy of the profile's base values in `settings.json` too, so a build from before the split (which reads only that file) still finds the user's preferences; it is ignored by current builds.
- An existing but unparseable `preferences.json` is a failure, not an empty profile: it is never seeded over or rewritten, one warning is logged per process, reads return `settings.json` only, and writes drop the profile part until a later read succeeds.
- Both files are written atomically (tmp file + rename). Write failures throw, so `persistSettings` rejects and the webview sees the save fail instead of a silent success.
- The extension host is always the `vscode` surface kind: per-surface profile keys it changes land under `surfaces.vscode` in `preferences.json` and reads resolve `vscode` first, base otherwise (mirrors the server's header-driven behaviour).
- `bridge-system-runtime.ts`
- System/editor/provider/quota/notification/update-check message handlers.
@@ -74,6 +83,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). Updates preserve existing provider, option, and retained-model fields that the form does not manage while honoring explicit model, header, and env removal. Legacy `providers` entries migrate to the canonical `provider` key when edited.
- Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider.
- `ollamaQuota.ts` owns the Ollama settings request and parser shared by credential validation and quota refresh. Both reject redirects, failed HTTP responses, and pages without parsed windows, with a 15-second request timeout. Validation finishes before the bridge writes a replacement cookie. Monthly dollar quotas and legacy session/weekly/premium quotas remain supported; zero extra-credit balances are omitted.
- `opencode-upgrade-runtime.ts`
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
@@ -93,6 +103,17 @@ The webview build emits each worker as one self-contained file. VS Code webviews
Message and part ordering is owned by [`packages/ui/src/sync/DOCUMENTATION.md`](../../ui/src/sync/DOCUMENTATION.md#session-message-loading). The VS Code webview consumes that shared sync implementation; bridge and proxy runtimes pass OpenCode records through without adding runtime-specific ordering.
The OpenChamber control stream (`/api/openchamber/events`) requires the
OpenChamber server, which the extension does not run. `subscribeOpenchamberEvents`
therefore returns a no-op subscription in VS Code before resolving URLs or
opening a connection. Session sync still uses the OpenCode SSE bridge and
global session polling. Sending the control stream to the webview origin caused
repeated `403` responses and URL-token requests to `/auth/url-token`.
Shared lazy imports retry a failed chunk load, but skip browser-navigation
recovery in VS Code. `window.location.reload()` is unsupported inside webviews;
the original import error must reach the UI error boundary instead.
## Extension guideline
When adding new bridge route families:
@@ -174,12 +195,20 @@ Handlers with no reachable caller in the VS Code webview.
| `api:fs:write`, `api:fs:rename`, `api:fs:delete`, `api:fs:reveal`, `api:fs:mkdir` | `FilesView`, `SidebarFilesTree`, `PlanView` only |
| `api:fs:exec` | Terminal API is a throwing stub; no other caller |
Reachable filesystem routes: `api:fs:read` (attachments, config), `api:fs:search`
Reachable filesystem routes: `api:fs:read` (attachments), `api:fs:search`
(`useFileSearchStore` behind composer file mentions), `api:fs:list`, `api:fs:stat`.
Maintenance: reviews, changelog entries, and parity claims consult this map;
whoever mounts or unmounts a surface updates it in the same change.
## Network connections
Extension activation applies `networkDefaults.ts` before registering handlers.
It gives Node connection attempts 5 seconds, matching the web runtime, so quota
requests to distant providers can connect. This is an extension-host process
default, including other Node connections in that host. Address-family selection
stays unchanged; runtimes without the setter retain their existing behavior.
## Global OpenCode paths
`opencodeConfigPaths.ts` owns the global config directory for config CRUD,
@@ -0,0 +1,86 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { BRIDGE_ZEN_DEFAULT_MODEL, chooseBridgeGitGenerationModel } from './bridge-git-generation-model';
const catalogOf = (...refs: string[]) => {
const set = new Set(refs);
return (providerID: string, modelID: string) => set.has(`${providerID}/${modelID}`);
};
describe('chooseBridgeGitGenerationModel', () => {
test('request payload model wins when it is in the catalog', () => {
const choice = chooseBridgeGitGenerationModel(
{ providerId: 'anthropic', modelId: 'claude-sonnet-4' },
{ smallModelUseDefault: false, smallModelOverride: 'openai/gpt-4.1-mini' },
catalogOf('anthropic/claude-sonnet-4', 'openai/gpt-4.1-mini'),
);
assert.deepEqual(choice, { providerID: 'anthropic', modelID: 'claude-sonnet-4' });
});
test('small-model override is honoured when present in the catalog', () => {
const choice = chooseBridgeGitGenerationModel(
{},
{ smallModelUseDefault: false, smallModelOverride: 'openai/gpt-4.1-mini' },
catalogOf('openai/gpt-4.1-mini'),
);
assert.deepEqual(choice, { providerID: 'openai', modelID: 'gpt-4.1-mini' });
});
test('override model ids may contain slashes; only the first splits provider from model', () => {
const choice = chooseBridgeGitGenerationModel(
{},
{ smallModelUseDefault: false, smallModelOverride: 'openrouter/meta/llama-3' },
catalogOf('openrouter/meta/llama-3'),
);
assert.deepEqual(choice, { providerID: 'openrouter', modelID: 'meta/llama-3' });
});
test('override is ignored when smallModelUseDefault is not false', () => {
const hasModel = catalogOf('openai/gpt-4.1-mini');
for (const useDefault of [true, undefined, 'false']) {
const choice = chooseBridgeGitGenerationModel(
{},
{ smallModelUseDefault: useDefault, smallModelOverride: 'openai/gpt-4.1-mini' },
hasModel,
);
assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL });
}
});
test('override is ignored when it is not in the catalog or malformed', () => {
const hasModel = catalogOf('openai/gpt-4.1-mini');
for (const override of ['openai/gpt-4o', 'openai', '/gpt-4.1-mini', 'openai/', ' ', 42]) {
const choice = chooseBridgeGitGenerationModel(
{},
{ smallModelUseDefault: false, smallModelOverride: override },
hasModel,
);
assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL });
}
});
test('the removed gitProviderId/gitModelId pair is no longer read', () => {
const choice = chooseBridgeGitGenerationModel(
{},
{ gitProviderId: 'openai', gitModelId: 'gpt-4.1-mini' },
catalogOf('openai/gpt-4.1-mini'),
);
assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL });
});
test('zen fallback prefers the request zen model, then settings, then the default', () => {
const none = () => false;
assert.deepEqual(
chooseBridgeGitGenerationModel({ zenModel: ' gpt-5-mini ' }, { zenModel: 'other' }, none),
{ providerID: 'zen', modelID: 'gpt-5-mini' },
);
assert.deepEqual(
chooseBridgeGitGenerationModel({}, { zenModel: 'other' }, none),
{ providerID: 'zen', modelID: 'other' },
);
assert.deepEqual(
chooseBridgeGitGenerationModel({}, {}, none),
{ providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL },
);
});
});
@@ -0,0 +1,64 @@
// Which model a bridge Git generation flow (PR description) talks to. Pure so
// the choice is unit-tested without `vscode`; the catalog lookup is injected.
//
// Order: the request's explicit model, then the user's small-model override
// from OpenChamber settings (the same setting every other utility generation in
// the product uses), then the zen fallback.
export const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano';
export type BridgeGitGenerationPayloadModel = {
providerId?: string;
modelId?: string;
zenModel?: string;
};
type BridgeGitGenerationModelChoice = { providerID: string; modelID: string };
// Bridge settings are the merged persisted dictionary; a value is a string
// only when the stored file says so, hence the narrowing here.
const readStringField = (settings: Record<string, unknown>, key: string): string => {
const candidate = settings[key];
return typeof candidate === 'string' ? candidate.trim() : '';
};
/**
* `smallModelOverride` is stored as `provider/model`; the model id may itself
* contain slashes, so only the first one separates the two.
*/
const readSmallModelOverride = (settings: Record<string, unknown>): BridgeGitGenerationModelChoice | null => {
if (settings.smallModelUseDefault !== false) return null;
const override = readStringField(settings, 'smallModelOverride');
const separator = override.indexOf('/');
if (separator <= 0) return null;
const providerID = override.slice(0, separator).trim();
const modelID = override.slice(separator + 1).trim();
if (!providerID || !modelID) return null;
return { providerID, modelID };
};
export const chooseBridgeGitGenerationModel = (
payloadModel: BridgeGitGenerationPayloadModel,
settings: Record<string, unknown>,
hasModel: (providerID: string, modelID: string) => boolean,
): BridgeGitGenerationModelChoice => {
// The payload reaches here from a webview message that is cast, not parsed,
// so a wrong-typed field must degrade to "absent" instead of throwing.
const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : '';
const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : '';
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
return { providerID: requestProviderId, modelID: requestModelId };
}
const override = readSmallModelOverride(settings);
if (override && hasModel(override.providerID, override.modelID)) {
return override;
}
const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : '';
const settingsZenModel = readStringField(settings, 'zenModel');
return {
providerID: 'zen',
modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL,
};
};
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import * as gitService from './gitService';
import { chooseBridgeGitGenerationModel, type BridgeGitGenerationPayloadModel } from './bridge-git-generation-model';
import type { BridgeContext, BridgeResponse } from './bridge';
type BridgeMessageInput = {
@@ -17,7 +18,6 @@ type SpecialGitDeps = {
execGit: (args: string[], cwd: string) => Promise<ExecGitResult>;
};
const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano';
const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000;
const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500;
const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000;
@@ -71,13 +71,6 @@ const createBridgeGitClient = (apiUrl: string, authHeaders?: Record<string, stri
headers: authHeaders || {},
});
const readStringField = (value: unknown, key: string): string => {
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
const candidate = record[key];
return typeof candidate === 'string' ? candidate.trim() : '';
};
const fetchBridgeGitModelCatalog = async (
apiUrl: string,
authHeaders?: Record<string, string>
@@ -115,7 +108,7 @@ const fetchBridgeGitModelCatalog = async (
};
const resolveBridgeGitGenerationModel = async (
payloadModel: { providerId?: string; modelId?: string; zenModel?: string },
payloadModel: BridgeGitGenerationPayloadModel,
settings: Record<string, unknown>,
apiUrl: string,
authHeaders?: Record<string, string>
@@ -134,24 +127,7 @@ const resolveBridgeGitGenerationModel = async (
return catalog.has(`${providerID}/${modelID}`);
};
const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : '';
const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : '';
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
return { providerID: requestProviderId, modelID: requestModelId };
}
const settingsProviderId = readStringField(settings, 'gitProviderId');
const settingsModelId = readStringField(settings, 'gitModelId');
if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) {
return { providerID: settingsProviderId, modelID: settingsModelId };
}
const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : '';
const settingsZenModel = readStringField(settings, 'zenModel');
return {
providerID: 'zen',
modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL,
};
return chooseBridgeGitGenerationModel(payloadModel, settings, hasModel);
};
const extractTextFromMessageParts = (parts: unknown): string => {
@@ -61,6 +61,11 @@ describe('bridge local fs proxy', () => {
expect(response?.status).toBe(404);
});
it('does not forward directory availability probes to OpenCode', async () => {
const response = await tryHandleLocalFsProxy('GET', '/api/fs/directory-stat?path=%2Fmissing-dir');
expect(response?.status).toBe(501);
});
it('reads from the active directory when it is the second workspace root', async () => {
existingFiles.add('/workspace-two/image.png');
const response = await tryHandleLocalFsProxy(
@@ -56,6 +56,9 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
}
const fsProxyPath = normalizeFsProxyPath(parsed.pathname);
if (parsed.pathname === '/api/fs/directory-stat') {
return buildProxyJsonError(501, 'Directory availability probes are not supported in the VS Code runtime');
}
if (/^\/api\/openchamber\/sessions\/[^/]+\/markdown-image-grants$/.test(parsed.pathname)) {
return buildProxyJsonError(501, 'Markdown image grants are not supported in the VS Code runtime');
}
@@ -0,0 +1,192 @@
// Extension-host side of the project setup routes
// (`GET/PUT /api/projects/:projectId/config`): the webview cannot reach the
// filesystem, so it bridges here and this module reads and writes
// `~/.config/openchamber/projects/<projectId>.json` with the same rules the
// OpenChamber server applies (`project-setup.ts`). Server-owned keys in the
// file (`version`, `scheduledTasks`) and keys from newer builds survive a
// write untouched.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
EMPTY_SHARED_PROJECT_CONFIG,
ProjectSetupValidationError,
SHARED_CONFIG_RELATIVE_PATH,
applySharedProjectSetupPatch,
isSharedProjectConfigEmpty,
mergeProjectSetup,
parseSharedProjectConfig,
personalProjectSetupOf,
projectSetupPatchToStored,
serializeSharedProjectConfig,
sharedTrustHashOf,
type ProjectSetupView,
type SharedProjectConfigRead,
} from './project-setup';
export type ProjectSetupBridgeMessage = { id: string; type: string; payload?: unknown };
export type ProjectSetupBridgeResponse = { id: string; type: string; success: boolean; data?: unknown; error?: string };
export type ProjectSetupStore = {
read: (projectId: string) => Promise<ProjectSetupView>;
update: (projectId: string, patch: unknown) => Promise<ProjectSetupView>;
updateShared: (projectId: string, patch: unknown) => Promise<ProjectSetupView>;
};
const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
/** The checkout a `path_<base64url>` id names, or `''` for ids of another form. */
export const projectPathFromId = (projectId: string): string => {
if (!projectId.startsWith('path_')) return '';
const encoded = projectId.slice('path_'.length);
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
return Buffer.from(encoded, 'base64url').toString('utf8');
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const sanitizeProjectId = (value: unknown): string => {
const projectId = typeof value === 'string' ? value.trim() : '';
if (!projectId) throw new ProjectSetupValidationError('projectId is required');
if (!PROJECT_ID_PATTERN.test(projectId)) throw new ProjectSetupValidationError('projectId contains unsupported characters');
return projectId;
};
const readJsonDocument = async (filePath: string): Promise<Record<string, unknown>> => {
let raw: string;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return {};
throw error;
}
const parsed: unknown = JSON.parse(raw);
return isObjectRecord(parsed) ? parsed : {};
};
const writeJsonAtomic = async (filePath: string, text: string): Promise<void> => {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
await fs.promises.writeFile(tmp, text, 'utf8');
await fs.promises.rename(tmp, filePath);
} catch (error) {
await fs.promises.rm(tmp, { force: true }).catch(() => {});
throw error;
}
};
/** A store over one projects directory; the default is the shared OpenChamber one. */
export const createProjectSetupStore = (
projectsDir: string = path.join(os.homedir(), '.config', 'openchamber', 'projects'),
): ProjectSetupStore => {
const filePathFor = (projectId: string): string => path.join(projectsDir, `${sanitizeProjectId(projectId)}.json`);
// Writes to one file are chained so two quick saves from the webview cannot
// interleave their read-modify-write.
const writeChains = new Map<string, Promise<unknown>>();
// The shared file lives in the checkout the id names (the personal file's
// `projectPath` is the fallback). A missing file is the normal case; an
// unreadable or unparsable one is reported, never treated as empty.
const projectPathOf = (projectId: string, personalRaw: Record<string, unknown>): string => {
const storedPath = personalRaw.projectPath;
return projectPathFromId(projectId) || (typeof storedPath === 'string' ? storedPath.trim() : '');
};
const sharedConfigPathOf = (projectPath: string): string => path.join(projectPath, ...SHARED_CONFIG_RELATIVE_PATH.split('/'));
const readShared = async (projectId: string, personalRaw: Record<string, unknown>): Promise<SharedProjectConfigRead> => {
const projectPath = projectPathOf(projectId, personalRaw);
if (!projectPath) return { status: 'missing' };
let raw: string;
try {
raw = await fs.promises.readFile(sharedConfigPathOf(projectPath), 'utf8');
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return { status: 'missing' };
return { status: 'invalid', reason: error instanceof Error ? error.message : String(error) };
}
return parseSharedProjectConfig(raw);
};
const mergedViewOf = async (projectId: string, personalRaw: Record<string, unknown>): Promise<ProjectSetupView> =>
mergeProjectSetup(personalProjectSetupOf(personalRaw), await readShared(projectId, personalRaw));
const read = async (projectId: string): Promise<ProjectSetupView> => mergedViewOf(projectId, await readJsonDocument(filePathFor(projectId)));
const update = async (projectId: string, patch: unknown): Promise<ProjectSetupView> => {
const filePath = filePathFor(projectId);
const stored = projectSetupPatchToStored(patch);
const previous = writeChains.get(filePath) ?? Promise.resolve();
const next = previous.then(async () => {
const existing = await readJsonDocument(filePath);
const merged: Record<string, unknown> = { ...existing, ...stored };
for (const [key, value] of Object.entries(stored)) {
if (value === undefined) delete merged[key];
}
await writeJsonAtomic(filePath, JSON.stringify(merged, null, 2));
return mergedViewOf(projectId, merged);
});
writeChains.set(filePath, next.catch(() => undefined));
return next;
};
// The team's shared file in the checkout; same rules as the server: a
// broken file counts as empty, an empty result removes the file, and the
// writer's own trust record is set to the new hash.
const updateShared = async (projectId: string, patch: unknown): Promise<ProjectSetupView> => {
const filePath = filePathFor(projectId);
const previous = writeChains.get(filePath) ?? Promise.resolve();
const next = previous.then(async () => {
const personalRaw = await readJsonDocument(filePath);
const projectPath = projectPathOf(projectId, personalRaw);
if (!projectPath) throw new ProjectSetupValidationError('project checkout not found');
const isDirectory = await fs.promises.stat(projectPath).then((stat) => stat.isDirectory()).catch(() => false);
if (!isDirectory) throw new ProjectSetupValidationError('project checkout not found');
const currentRead = await readShared(projectId, personalRaw);
const current = currentRead.status === 'ok' ? currentRead.config : EMPTY_SHARED_PROJECT_CONFIG;
const nextShared = applySharedProjectSetupPatch(current, patch);
const sharedPath = sharedConfigPathOf(projectPath);
if (isSharedProjectConfigEmpty(nextShared)) {
await fs.promises.rm(sharedPath, { force: true });
await fs.promises.rmdir(path.dirname(sharedPath)).catch(() => {});
} else {
await writeJsonAtomic(sharedPath, serializeSharedProjectConfig(nextShared));
}
const hash = sharedTrustHashOf(nextShared);
const personalNext: Record<string, unknown> = { ...personalRaw };
if (hash) personalNext.sharedTrust = { hash, trustedAt: Date.now() };
else delete personalNext.sharedTrust;
await writeJsonAtomic(filePath, JSON.stringify(personalNext, null, 2));
return mergedViewOf(projectId, personalNext);
});
writeChains.set(filePath, next.catch(() => undefined));
return next;
};
return { read, update, updateShared };
};
export async function handleProjectSetupBridgeMessage(
message: ProjectSetupBridgeMessage,
store: ProjectSetupStore,
): Promise<ProjectSetupBridgeResponse | null> {
const { id, type, payload } = message;
if (type !== 'api:project-setup:get' && type !== 'api:project-setup:update' && type !== 'api:project-setup:update-shared') return null;
try {
const request = isObjectRecord(payload) ? payload : {};
const projectId = sanitizeProjectId(request.projectId);
const data = type === 'api:project-setup:get'
? await store.read(projectId)
: type === 'api:project-setup:update'
? await store.update(projectId, request.patch)
: await store.updateShared(projectId, request.patch);
return { id, type, success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : 'Project config request failed';
return { id, type, success: false, error: message };
}
}
+154 -24
View File
@@ -5,9 +5,24 @@ import * as path from 'path';
import * as vscode from 'vscode';
import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig';
import type { BridgeContext } from './bridge';
import { filterPersistableSettingsChanges, withoutSecretSettings } from './settings-registry-gate';
import {
buildPreferencesFields,
flattenPreferences,
instancePartOf,
legacySettingsDocumentOf,
profilePartOf,
parsePreferencesDocument,
preferencesFilePathFor,
seedPreferencesFrom,
serializePreferencesDocument,
type PreferenceFields,
VSCODE_SETTINGS_SURFACE,
} from './settings-files';
const SETTINGS_KEY = 'openchamber.settings';
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
const OPENCHAMBER_PREFERENCES_PATH = preferencesFilePathFor(OPENCHAMBER_SHARED_SETTINGS_PATH);
const OPENCHAMBER_MAGIC_PROMPTS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'magic-prompts.json');
const MAGIC_PROMPTS_FILE_VERSION = 1;
const MAGIC_PROMPT_ID_PATTERN = /^[a-z0-9._-]{1,160}$/;
@@ -160,11 +175,21 @@ export const fetchOpenCodeSkillsFromApi = async (
}
};
const readSharedSettingsFromDisk = (): Record<string, unknown> => {
// Settings live in two files beside each other (see `settings-files.ts`):
// `settings.json` holds instance facts and legacy keys, `preferences.json`
// holds the profile keys with their `updatedAt` stamps. Reads return the
// merged view; writes split a merged document back into the two files.
//
// A settings.json parse failure (corrupt or non-object file) is still coerced
// to `{}`, which lets the next write replace it; tracked in the settings-scopes
// plan. preferences.json already fails closed below.
const readSettingsJsonFromDisk = (): Record<string, unknown> => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8');
// SAFETY: JSON.parse returns untyped data; the check below keeps only a plain object.
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
// SAFETY: a non-array object parsed from JSON is a string-keyed dictionary.
return parsed as Record<string, unknown>;
}
return {};
@@ -173,22 +198,122 @@ const readSharedSettingsFromDisk = (): Record<string, unknown> => {
}
};
const writeSharedSettingsToDisk = async (changes: Record<string, unknown>): Promise<void> => {
let tmp: string | null = null;
type PreferencesReadResult =
| { status: 'ok'; fields: PreferenceFields }
| { status: 'missing' }
| { status: 'unreadable'; reason: string };
// True after preferences.json was found but could not be read or parsed. While
// set, the file is left alone: reads return settings.json only and writes drop
// profile keys instead of replacing a file whose content we cannot see.
let preferencesUnavailable = false;
let preferencesUnavailableLogged = false;
const readPreferencesFromDisk = (): PreferencesReadResult => {
let result: PreferencesReadResult;
try {
await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true });
const current = readSharedSettingsFromDisk();
const next: Record<string, unknown> = { ...current, ...changes };
// Atomic write: tmp file + rename. Readers never see a partial/truncated
// JSON that would fail to parse and silently get coerced to {}.
tmp = `${OPENCHAMBER_SHARED_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fs.promises.writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
await fs.promises.rename(tmp, OPENCHAMBER_SHARED_SETTINGS_PATH);
} catch {
if (tmp) {
await fs.promises.rm(tmp, { force: true }).catch(() => {});
}
const parsed = parsePreferencesDocument(fs.readFileSync(OPENCHAMBER_PREFERENCES_PATH, 'utf8'));
result = parsed.ok ? { status: 'ok', fields: parsed.fields } : { status: 'unreadable', reason: parsed.reason };
} catch (error) {
// SAFETY: fs errors carry a `code` string; anything else is reported by message.
const code = (error as NodeJS.ErrnoException | null)?.code;
result = code === 'ENOENT'
? { status: 'missing' }
: { status: 'unreadable', reason: error instanceof Error ? error.message : String(error) };
}
if (result.status === 'unreadable') {
preferencesUnavailable = true;
if (!preferencesUnavailableLogged) {
preferencesUnavailableLogged = true;
console.warn(`[OpenChamber] ${OPENCHAMBER_PREFERENCES_PATH} could not be read (${result.reason}); profile settings are unavailable until the file is fixed or removed.`);
}
} else {
preferencesUnavailable = false;
}
return result;
};
// Atomic write: tmp file + rename, so readers never see a partial JSON. Throws
// on failure (after removing the tmp file) so a failed save is reported, not
// mistaken for success.
const writeJsonAtomic = async (filePath: string, text: string): Promise<void> => {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
await fs.promises.writeFile(tmp, text, 'utf8');
await fs.promises.rename(tmp, filePath);
} catch (error) {
await fs.promises.rm(tmp, { force: true }).catch(() => {});
throw error;
}
};
const writeJsonAtomicSync = (filePath: string, text: string): void => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
fs.writeFileSync(tmp, text, 'utf8');
fs.renameSync(tmp, filePath);
} catch (error) {
try {
fs.rmSync(tmp, { force: true });
} catch {
// Nothing more to clean up.
}
throw error;
}
};
// Merged view of both files. A missing preferences.json is seeded once from the
// profile keys settings.json still carries; every write keeps a copy of the
// profile's base values in settings.json, so an older build can still read it.
const readSharedSettingsFromDisk = (): Record<string, unknown> => {
const settings = readSettingsJsonFromDisk();
let preferences = readPreferencesFromDisk();
if (preferences.status === 'missing') {
const seeded = seedPreferencesFrom(stripDerived(settings), Date.now());
try {
writeJsonAtomicSync(OPENCHAMBER_PREFERENCES_PATH, serializePreferencesDocument(seeded));
} catch (error) {
console.warn('[OpenChamber] Failed to seed preferences.json:', error instanceof Error ? error.message : String(error));
}
preferences = { status: 'ok', fields: seeded };
}
if (preferences.status !== 'ok') {
return settings;
}
return { ...settings, ...flattenPreferences(preferences.fields, VSCODE_SETTINGS_SURFACE) };
};
// Write a complete merged document: profile keys go to preferences.json (keeping
// the stamps of unchanged values), everything else to settings.json. A key the
// document no longer carries leaves whichever file owned it.
const writeSharedSettingsToDisk = async (
document: Record<string, unknown>,
changedKeys: Iterable<string> | null = null,
): Promise<void> => {
const preferences = readPreferencesFromDisk();
if (preferencesUnavailable) {
console.warn('[OpenChamber] preferences.json is unreadable; profile settings were not saved.');
// settings.json keeps whatever legacy profile copy it already holds.
const onDisk = readSettingsJsonFromDisk();
await writeJsonAtomic(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify({
...instancePartOf(document),
...profilePartOf(onDisk),
}, null, 2));
return;
}
const previousFields = preferences.status === 'ok' ? preferences.fields : {};
// This host is always the VS Code surface kind: per-surface profile keys it
// changed land under `surfaces.vscode`; keys it did not change keep their entry.
const nextFields = buildPreferencesFields(previousFields, document, Date.now(), {
surface: VSCODE_SETTINGS_SURFACE,
changedKeys,
});
await writeJsonAtomic(OPENCHAMBER_PREFERENCES_PATH, serializePreferencesDocument(nextFields));
// The legacy copy of the profile's base values rides along for older builds.
await writeJsonAtomic(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(legacySettingsDocumentOf(document, nextFields), null, 2));
};
// Fields derived from runtime context — never persisted, always recomputed.
@@ -299,15 +424,19 @@ const readPersistedSettings = (ctx?: BridgeContext): Record<string, unknown> =>
}
if (Object.keys(missingFromDisk).length > 0) {
// Fire-and-forget; readers already have an in-memory merged view.
void writeSharedSettingsToDisk(missingFromDisk);
void writeSharedSettingsToDisk({ ...fromDisk, ...missingFromDisk }).catch((error: unknown) => {
console.warn('[OpenChamber] Failed to migrate settings from globalState:', error instanceof Error ? error.message : String(error));
});
}
}
return { ...fromGlobalState, ...fromDisk };
};
// Everything the webview may see: the persisted document minus the keys the
// registry marks `secret` (a UI password, tunnel tokens), which are write-only.
export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
const persisted = readPersistedSettings(ctx);
const persisted = withoutSecretSettings(readPersistedSettings(ctx));
const persistedOpencodeBinary =
typeof persisted.opencodeBinary === 'string' ? String(persisted.opencodeBinary).trim() : '';
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
@@ -327,7 +456,8 @@ export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
const current = readSettings(ctx);
const restChanges = stripDerived({ ...(changes || {}) });
// Only keys the settings registry knows as stored shared fields reach disk.
const restChanges = filterPersistableSettingsChanges(stripDerived({ ...(changes || {}) }));
const keysToClear = new Set<string>();
@@ -386,15 +516,15 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
delete persistable[key];
}
// Write to the shared file (canonical, cross-client). Also mirror into
// globalState so older builds can still read recent values if a user
// downgrades the extension.
await writeSharedSettingsToDisk(persistable);
// Write to the shared files (canonical, cross-client); a failed write rejects
// so the webview reports the save as failed. Also mirror into globalState so
// older builds can still read recent values if a user downgrades the extension.
await writeSharedSettingsToDisk(persistable, [...Object.keys(restChanges), ...keysToClear]);
await ctx?.context?.globalState.update(SETTINGS_KEY, persistable);
// Return the same shape as readSettings (with derived fields re-applied).
// Return the same shape as readSettings (derived fields re-applied, secrets withheld).
return {
...persistable,
...withoutSecretSettings(persistable),
themeVariant: current.themeVariant,
lastDirectory: current.lastDirectory,
opencodeBinary:
+6
View File
@@ -7,6 +7,7 @@ import { handleConfigBridgeMessage } from './bridge-config-runtime';
import { handleSystemBridgeMessage } from './bridge-system-runtime';
import { handleProxyBridgeMessage } from './bridge-proxy-runtime';
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
import { createProjectSetupStore, handleProjectSetupBridgeMessage } from './bridge-project-setup-runtime';
import {
fetchOpenCodeSkillsFromApi,
persistSettings,
@@ -55,6 +56,7 @@ export interface BridgeContext {
}
const CLIENT_RELOAD_DELAY_MS = 800;
const projectSetupStore = createProjectSetupStore();
const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check';
const GITHUB_BACKEND_DISABLED_ERROR = 'OpenChamber VS Code backend GitHub integration is disabled. Use native VS Code GitHub integrations.';
@@ -88,6 +90,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
if (specialGitResponse) {
return specialGitResponse;
}
const projectSetupResponse = await handleProjectSetupBridgeMessage({ id, type, payload }, projectSetupStore);
if (projectSetupResponse) {
return projectSetupResponse;
}
const fsResponse = await handleFsBridgeMessage(
{ id, type, payload },
{
+2
View File
@@ -7,6 +7,7 @@ import { startGlobalEventWatcher, stopGlobalEventWatcher, setChatViewProvider }
import { pathsEqualWithNormalizedDriveLetter } from './pathUtils';
import { resolveWorkspaceFolders } from './workspaceResolver';
import { InlineCommentThreads, SIDEBAR_SURFACE_ID } from './InlineCommentThreads';
import { applyConnectAttemptTimeout } from './networkDefaults';
let chatViewProvider: ChatViewProvider | undefined;
@@ -52,6 +53,7 @@ const formatDurationMs = (value: number | null | undefined) => {
};
export async function activate(context: vscode.ExtensionContext) {
applyConnectAttemptTimeout();
outputChannel = vscode.window.createOutputChannel('OpenChamber');
let moveToRightSidebarScheduled = false;
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as net from 'node:net';
import { applyConnectAttemptTimeout } from './networkDefaults';
test('allows slow connections without changing address-family selection', () => {
const previousTimeout = net.getDefaultAutoSelectFamilyAttemptTimeout();
const previousFamily = net.getDefaultAutoSelectFamily();
try {
net.setDefaultAutoSelectFamilyAttemptTimeout(250);
assert.equal(applyConnectAttemptTimeout(), true);
assert.equal(net.getDefaultAutoSelectFamilyAttemptTimeout(), 5_000);
assert.equal(net.getDefaultAutoSelectFamily(), previousFamily);
} finally {
net.setDefaultAutoSelectFamilyAttemptTimeout(previousTimeout);
}
});
test('unsupported runtimes retain their existing behavior', () => {
assert.equal(applyConnectAttemptTimeout({}), false);
assert.equal(applyConnectAttemptTimeout({
setDefaultAutoSelectFamilyAttemptTimeout() { throw new Error('unsupported'); },
}), false);
});
+15
View File
@@ -0,0 +1,15 @@
import * as net from 'node:net';
// Mirrors the web runtime policy for distant quota endpoints. The extension
// host has its own Node fetch stack and does not inherit server defaults.
export function applyConnectAttemptTimeout(
netModule: Partial<Pick<typeof net, 'setDefaultAutoSelectFamilyAttemptTimeout'>> = net,
): boolean {
try {
if (!netModule.setDefaultAutoSelectFamilyAttemptTimeout) return false;
netModule.setDefaultAutoSelectFamilyAttemptTimeout(5_000);
return true;
} catch {
return false;
}
}
+64
View File
@@ -0,0 +1,64 @@
type OllamaWindow = { usedPercent: number | null; valueLabel?: string };
type OllamaFetch = (url: string, init: RequestInit) => Promise<Response>;
export const fetchOllamaUsage = async (cookie: string, fetchImpl: OllamaFetch = fetch) => {
const response = await fetchImpl('https://ollama.com/settings', {
method: 'GET',
headers: {
Cookie: cookie,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
redirect: 'manual',
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error('Ollama Cloud authentication failed');
const html = await response.text();
const windows: Record<string, OllamaWindow> = {};
for (const [key, pattern] of [
['session', /Session\s+usage[^0-9]*([0-9.]+)%/i],
['weekly', /Weekly\s+usage[^0-9]*([0-9.]+)%/i],
] as const) {
const match = html.match(pattern);
if (!match) continue;
const usedPercent = Number(match[1]);
if (Number.isFinite(usedPercent)) {
windows[key] = { usedPercent };
}
}
const premium = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i);
if (premium) {
const used = Number(premium[1]);
const total = Number(premium[2]);
if (Number.isFinite(used) && Number.isFinite(total)) {
windows.premium = {
usedPercent: total > 0 ? Math.min(100, (used / total) * 100) : null,
valueLabel: `${used} / ${total}`,
};
}
}
const monthly = html.match(/Monthly\s+usage[\s\S]{0,200}?\$([0-9][0-9,.]*)\s+of\s+\$([0-9][0-9,.]*)/i);
if (monthly) {
const used = Number(monthly[1].replace(/,/g, ''));
const total = Number(monthly[2].replace(/,/g, ''));
if (Number.isFinite(used) && Number.isFinite(total)) {
windows.monthly = {
usedPercent: total > 0 ? Math.min(100, (used / total) * 100) : null,
valueLabel: `$${monthly[1]} / $${monthly[2]}`,
};
}
}
// Anchor on the balance label, not nearby purchase or auto-reload amounts.
const balanceMatch = html.match(/Balance\s+remaining[\s\S]{0,200}?\$([0-9][0-9,.]*)/i);
if (balanceMatch) {
const balance = Number(balanceMatch[1].replace(/,/g, ''));
if (Number.isFinite(balance) && balance > 0) {
windows.credits_balance = { usedPercent: null, valueLabel: `$${balanceMatch[1]}` };
}
}
if (Object.keys(windows).length === 0) throw new Error('Ollama Cloud usage data could not be parsed');
return windows;
};
+258
View File
@@ -0,0 +1,258 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
ProjectSetupValidationError,
mergeProjectSetup,
normalizePlansDir,
parseSharedProjectConfig,
personalProjectSetupOf,
projectSetupPatchToStored,
sanitizeDraftStarters,
sanitizeProjectActions,
sanitizeSetupCommands,
sharedTrustHashOf,
type PersonalProjectSetup,
} from './project-setup';
import { createProjectSetupStore, handleProjectSetupBridgeMessage, projectPathFromId } from './bridge-project-setup-runtime';
const emptyPersonal: PersonalProjectSetup = {
setupWorktree: [],
setupWorktreeWait: null,
setupWorktreeMode: 'append',
projectActions: [],
projectActionsPrimaryId: null,
draftStarters: [],
hiddenSharedActionIds: [],
sharedTrust: null,
};
const projectIdFor = (projectPath: string): string => `path_${Buffer.from(projectPath, 'utf8').toString('base64url')}`;
describe('project setup sanitizers', () => {
test('keeps only non-empty trimmed setup commands', () => {
assert.deepEqual(sanitizeSetupCommands([' bun install ', '', 42, '\n']), ['bun install']);
assert.deepEqual(sanitizeSetupCommands('bun install'), []);
});
test('drops incomplete actions and duplicate ids, keeps only set optional fields', () => {
assert.deepEqual(sanitizeProjectActions([
{ id: 'a', name: 'Dev', command: 'bun run dev', runIn: 'parent', platforms: ['macos', 'plan9'], icon: '' },
{ id: 'a', name: 'Again', command: 'x' },
{ id: '', name: 'No id', command: 'x' },
{ id: 'b', name: 'B', command: 'x', runIn: 'worktree' },
]), [
{ id: 'a', name: 'Dev', command: 'bun run dev', icon: null, platforms: ['macos'], runIn: 'parent' },
{ id: 'b', name: 'B', command: 'x', icon: null },
]);
});
test('dedupes draft starters by type and name', () => {
assert.deepEqual(sanitizeDraftStarters([
{ type: 'skill', name: 'triage-prs' },
{ type: 'skill', name: 'triage-prs' },
{ type: 'agent', name: 'nope' },
]), [{ type: 'skill', name: 'triage-prs' }]);
});
test('builds the personal view from on-disk keys and nulls a dangling primary action', () => {
assert.deepEqual(personalProjectSetupOf({
'setup-worktree': ['bun install'],
'setup-worktree-wait': true,
setupWorktreeMode: 'replace',
projectActions: [{ id: 'a', name: 'A', command: 'x' }],
projectActionsPrimaryId: 'missing',
hiddenSharedActionIds: ['dev', 'dev', 3],
}), {
setupWorktree: ['bun install'],
setupWorktreeWait: true,
setupWorktreeMode: 'replace',
projectActions: [{ id: 'a', name: 'A', command: 'x', icon: null }],
projectActionsPrimaryId: null,
draftStarters: [],
hiddenSharedActionIds: ['dev'],
sharedTrust: null,
});
assert.deepEqual(personalProjectSetupOf(null), emptyPersonal);
});
test('parses a shared file and refuses a broken one', () => {
const ok = parseSharedProjectConfig(JSON.stringify({ version: 1, setupWorktree: ['bun install'], plansDir: 'docs/plans' }));
assert.equal(ok.status, 'ok');
if (ok.status === 'ok') {
assert.deepEqual(ok.config, { setupWorktree: ['bun install'], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: 'docs/plans' });
}
assert.equal(parseSharedProjectConfig('{ nope').status, 'invalid');
assert.equal(parseSharedProjectConfig('{"version":2}').status, 'invalid');
assert.equal(parseSharedProjectConfig('{"version":1,"plansDir":"../x"}').status, 'invalid');
assert.equal(normalizePlansDir('./docs/plans/'), 'docs/plans');
assert.equal(normalizePlansDir('/abs'), null);
});
test('merges shared and personal by the agreed rules', () => {
const merged = mergeProjectSetup({
...emptyPersonal,
setupWorktree: ['mine'],
projectActions: [{ id: 'test', name: 'My test', command: 'x', icon: null }],
hiddenSharedActionIds: ['lint'],
draftStarters: [{ type: 'command', name: 'both' }, { type: 'command', name: 'mine' }],
}, {
status: 'ok',
config: {
setupWorktree: ['bun install'],
setupWorktreeWait: true,
projectActions: [
{ id: 'dev', name: 'Dev', command: 'd', icon: null },
{ id: 'test', name: 'Test', command: 't', icon: null },
{ id: 'lint', name: 'Lint', command: 'l', icon: null },
],
draftStarters: [{ type: 'command', name: 'both' }],
plansDir: null,
},
});
assert.deepEqual(merged.setupWorktree, ['bun install', 'mine']);
assert.equal(merged.setupWorktreeWait, true);
assert.deepEqual(merged.projectActions.map((action) => `${action.id}:${action.source}`), ['dev:shared', 'test:personal']);
assert.deepEqual(merged.draftStarters.map((starter) => `${starter.name}:${starter.source}`), ['both:shared', 'mine:personal']);
assert.equal(merged.trust.trusted, false);
assert.match(merged.trust.hash ?? '', /^sha256:/);
});
test('trusts only the recorded hash and nothing when nothing executes', () => {
const shared = { setupWorktree: ['bun install'], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null };
const hash = sharedTrustHashOf(shared);
assert.equal(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: hash ?? '', trustedAt: 1 } }, { status: 'ok', config: shared }).trust.trusted, true);
assert.equal(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: 'sha256:old', trustedAt: 1 } }, { status: 'ok', config: shared }).trust.trusted, false);
assert.deepEqual(mergeProjectSetup(emptyPersonal, { status: 'missing' }).trust, { hash: null, trusted: true });
assert.equal(sharedTrustHashOf({ ...shared, setupWorktree: [] }), null);
assert.deepEqual(projectSetupPatchToStored({ sharedTrustHash: null }), { sharedTrust: undefined });
assert.throws(() => projectSetupPatchToStored({ sharedTrustHash: '' }), ProjectSetupValidationError);
});
test('rejects wrongly shaped patch keys', () => {
assert.throws(() => projectSetupPatchToStored({ setupWorktree: 'x' }), ProjectSetupValidationError);
assert.throws(() => projectSetupPatchToStored(null), ProjectSetupValidationError);
assert.deepEqual(projectSetupPatchToStored({ projectActionsPrimaryId: null }), { projectActionsPrimaryId: undefined });
});
});
describe('project setup bridge', () => {
const withStore = async (run: (store: ReturnType<typeof createProjectSetupStore>, dir: string) => Promise<void>) => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'oc-vscode-project-setup-'));
try {
await run(createProjectSetupStore(dir), dir);
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
};
test('round-trips a patch through the bridge and preserves foreign keys', async () => {
await withStore(async (store, dir) => {
await fs.promises.writeFile(path.join(dir, 'project-a.json'), JSON.stringify({
version: 1,
scheduledTasks: [{ id: 'keep' }],
'setup-worktree': ['old'],
}));
const updated = await handleProjectSetupBridgeMessage(
{ id: '1', type: 'api:project-setup:update', payload: { projectId: 'project-a', patch: { setupWorktree: ['bun install'], projectPath: '/repo' } } },
store,
);
assert.equal(updated?.success, true);
const view = updated?.data as { setupWorktree: string[]; setupWorktreeWait: boolean; shared: { status: string } };
assert.deepEqual(view.setupWorktree, ['bun install']);
assert.equal(view.setupWorktreeWait, false);
assert.equal(view.shared.status, 'missing');
const raw = JSON.parse(await fs.promises.readFile(path.join(dir, 'project-a.json'), 'utf8'));
assert.deepEqual(raw.scheduledTasks, [{ id: 'keep' }]);
assert.equal(raw.projectPath, '/repo');
const read = await handleProjectSetupBridgeMessage({ id: '2', type: 'api:project-setup:get', payload: { projectId: 'project-a' } }, store);
assert.deepEqual(read?.data, updated?.data);
});
});
test('answers a bad patch or project id with a failure, and ignores other messages', async () => {
await withStore(async (store) => {
const bad = await handleProjectSetupBridgeMessage(
{ id: '1', type: 'api:project-setup:update', payload: { projectId: 'project-a', patch: { setupWorktree: 'x' } } },
store,
);
assert.equal(bad?.success, false);
assert.match(bad?.error ?? '', /setupWorktree must be/);
const badId = await handleProjectSetupBridgeMessage({ id: '2', type: 'api:project-setup:get', payload: { projectId: '../etc' } }, store);
assert.equal(badId?.success, false);
assert.equal(await handleProjectSetupBridgeMessage({ id: '3', type: 'api:fs:read', payload: {} }, store), null);
});
});
test('reads the shared file from the checkout the id names', async () => {
await withStore(async (store, dir) => {
const repo = path.join(dir, 'repo');
await fs.promises.mkdir(path.join(repo, '.openchamber'), { recursive: true });
await fs.promises.writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({
version: 1,
setupWorktree: ['bun install'],
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
}));
const projectId = projectIdFor(repo);
assert.equal(projectPathFromId(projectId), repo);
const view = await store.update(projectId, { setupWorktree: ['mine'], hiddenSharedActionIds: ['dev'] });
assert.equal(view.shared.status, 'ok');
assert.deepEqual(view.setupWorktree, ['bun install', 'mine']);
assert.deepEqual(view.projectActions, []);
await fs.promises.writeFile(path.join(repo, '.openchamber', 'project.json'), '{ broken');
const broken = await store.read(projectId);
assert.equal(broken.shared.status, 'invalid');
assert.deepEqual(broken.setupWorktree, ['mine']);
});
});
test('writes and removes the shared file through the bridge, trusting the writer', async () => {
await withStore(async (store, dir) => {
const repo = path.join(dir, 'repo');
await fs.promises.mkdir(repo, { recursive: true });
const projectId = projectIdFor(repo);
const shared = await handleProjectSetupBridgeMessage(
{ id: '1', type: 'api:project-setup:update-shared', payload: { projectId, patch: { setupWorktree: ['bun install'], plansDir: 'docs/plans' } } },
store,
);
assert.equal(shared?.success, true);
const view = shared?.data as { trust: { trusted: boolean }; shared: { status: string; plansDir: string | null } };
assert.equal(view.shared.status, 'ok');
assert.equal(view.shared.plansDir, 'docs/plans');
assert.equal(view.trust.trusted, true);
const raw = JSON.parse(await fs.promises.readFile(path.join(repo, '.openchamber', 'project.json'), 'utf8'));
assert.deepEqual(raw, { version: 1, setupWorktree: ['bun install'], plansDir: 'docs/plans' });
const emptied = await store.updateShared(projectId, { setupWorktree: [], plansDir: null });
assert.equal(emptied.shared.status, 'missing');
assert.equal(fs.existsSync(path.join(repo, '.openchamber')), false);
const missing = await handleProjectSetupBridgeMessage(
{ id: '2', type: 'api:project-setup:update-shared', payload: { projectId: projectIdFor(path.join(dir, 'nope')), patch: {} } },
store,
);
assert.equal(missing?.success, false);
assert.match(missing?.error ?? '', /checkout not found/);
});
});
test('serializes two quick updates to one file', async () => {
await withStore(async (store, dir) => {
await Promise.all([
store.update('project-a', { setupWorktree: ['a'] }),
store.update('project-a', { draftStarters: [{ type: 'skill', name: 's' }] }),
]);
const raw = JSON.parse(await fs.promises.readFile(path.join(dir, 'project-a.json'), 'utf8'));
assert.deepEqual(raw['setup-worktree'], ['a']);
assert.deepEqual(raw.draftStarters, [{ type: 'skill', name: 's' }]);
});
});
});
+445
View File
@@ -0,0 +1,445 @@
// The client-owned part of a project's config file
// (`~/.config/openchamber/projects/<projectId>.json`): worktree setup
// commands, project actions, and pinned draft starters. A mirror of the
// server's `packages/web/server/lib/projects/project-setup.js`; keep the
// sanitizing rules in sync so a value written from VS Code reads back the
// same on every other surface.
//
// Kept free of `vscode` imports so it is unit-tested directly.
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;
type ActionPlatform = 'macos' | 'linux' | 'windows';
const ACTION_PLATFORMS: ReadonlySet<string> = new Set<ActionPlatform>(['macos', 'linux', 'windows']);
export type ProjectAction = {
id: string;
name: string;
command: string;
icon: string | null;
autoOpenUrl?: true;
openUrl?: string;
desktopOpenSshForward?: string;
platforms?: ActionPlatform[];
runIn?: 'parent';
};
export type DraftStarter = { type: 'command' | 'skill'; name: string };
export type SetupWorktreeMode = 'append' | 'replace';
/** The personal file's part of the setup; the wait flag is `null` when the file does not set it. */
export type PersonalProjectSetup = {
setupWorktree: string[];
setupWorktreeWait: boolean | null;
setupWorktreeMode: SetupWorktreeMode;
projectActions: ProjectAction[];
projectActionsPrimaryId: string | null;
draftStarters: DraftStarter[];
hiddenSharedActionIds: string[];
/** The recorded answer to the trust prompt: which shared commands were trusted, and when. */
sharedTrust: { hash: string; trustedAt: number } | null;
};
export type SharedProjectConfig = {
setupWorktree: string[];
setupWorktreeWait: boolean | null;
projectActions: ProjectAction[];
draftStarters: DraftStarter[];
plansDir: string | null;
};
export type SharedProjectConfigRead =
| { status: 'missing' }
| { status: 'ok'; config: SharedProjectConfig }
| { status: 'invalid'; reason: string };
export type ProjectSetupSource = 'shared' | 'personal';
/** The merged view every client sees; see `mergeProjectSetup` for the rules. */
export type ProjectSetupView = {
/** Nothing to trust when `hash` is null; otherwise trusted only for the recorded hash. */
trust: { hash: string | null; trusted: boolean };
setupWorktree: string[];
setupWorktreeWait: boolean;
projectActions: Array<ProjectAction & { source: ProjectSetupSource }>;
projectActionsPrimaryId: string | null;
draftStarters: Array<DraftStarter & { source: ProjectSetupSource }>;
shared: SharedProjectConfig & { status: SharedProjectConfigRead['status']; reason?: string; path: string };
personal: PersonalProjectSetup;
};
export const SHARED_CONFIG_RELATIVE_PATH = '.openchamber/project.json';
const SHARED_CONFIG_VERSION = 1;
/**
* The on-disk keys this module owns inside the personal config document, as
* a patch: a key set to `undefined` is removed from the document.
*/
type StoredProjectSetupPatch = {
'setup-worktree'?: string[];
'setup-worktree-wait'?: boolean;
setupWorktreeMode?: SetupWorktreeMode;
projectActions?: ProjectAction[];
projectActionsPrimaryId?: string | undefined;
draftStarters?: DraftStarter[];
hiddenSharedActionIds?: string[];
sharedTrust?: { hash: string; trustedAt: number } | undefined;
projectPath?: string;
};
export class ProjectSetupValidationError extends Error {}
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const clamp = (value: string, maxLength: number): string => (value.length > maxLength ? value.slice(0, maxLength) : value);
const trimmedString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
export const sanitizeSetupCommands = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const commands: string[] = [];
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: unknown): ActionPlatform[] => {
if (!Array.isArray(value)) return [];
const platforms: ActionPlatform[] = [];
for (const entry of value) {
const platform = trimmedString(entry).toLowerCase();
if (!ACTION_PLATFORMS.has(platform)) continue;
// SAFETY: membership in ACTION_PLATFORMS was just checked.
const known = platform as ActionPlatform;
if (!platforms.includes(known)) platforms.push(known);
}
return platforms;
};
export const sanitizeProjectActions = (value: unknown): ProjectAction[] => {
if (!Array.isArray(value)) return [];
const actions: ProjectAction[] = [];
const seenIds = new Set<string>();
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: ProjectAction = { 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;
};
export const sanitizeDraftStarters = (value: unknown): DraftStarter[] => {
if (!Array.isArray(value)) return [];
const starters: DraftStarter[] = [];
const seen = new Set<string>();
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;
};
const sanitizeIdList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const ids: string[] = [];
for (const entry of value) {
const id = trimmedString(entry);
if (id && !ids.includes(id)) ids.push(id);
}
return ids;
};
const setupWorktreeModeOf = (value: unknown): SetupWorktreeMode => (value === 'replace' ? 'replace' : 'append');
/** The personal part of the view, straight from the personal file. */
export const personalProjectSetupOf = (raw: unknown): PersonalProjectSetup => {
const document = isObjectRecord(raw) ? raw : {};
const projectActions = sanitizeProjectActions(document.projectActions);
const primaryRaw = trimmedString(document.projectActionsPrimaryId);
const wait = document['setup-worktree-wait'];
return {
setupWorktree: sanitizeSetupCommands(document['setup-worktree']),
setupWorktreeWait: typeof wait === 'boolean' ? wait : null,
setupWorktreeMode: setupWorktreeModeOf(document.setupWorktreeMode),
projectActions,
projectActionsPrimaryId: primaryRaw && projectActions.some((action) => action.id === primaryRaw) ? primaryRaw : null,
draftStarters: sanitizeDraftStarters(document.draftStarters),
hiddenSharedActionIds: sanitizeIdList(document.hiddenSharedActionIds),
sharedTrust: sharedTrustOf(document.sharedTrust),
};
};
const sharedTrustOf = (value: unknown): PersonalProjectSetup['sharedTrust'] => {
if (!isObjectRecord(value)) return null;
const hash = trimmedString(value.hash);
if (!hash) return null;
const trustedAt = value.trustedAt;
return { hash, trustedAt: typeof trustedAt === 'number' && Number.isFinite(trustedAt) ? trustedAt : 0 };
};
/**
* What a trust answer covers: the shared setup commands and the shared
* actions' commands, canonical order, hashed; `null` when nothing executes.
*/
export const sharedTrustHashOf = (shared: SharedProjectConfig): string | null => {
const commands = shared.setupWorktree;
const actions = shared.projectActions
.map((action) => {
const executable: { id: string; command: string; runIn?: 'parent' } = { 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}`;
};
/**
* A `plansDir` is a relative path inside the repo: no absolute paths, no
* drive letters, no `..` segments, forward slashes.
*/
export const normalizePlansDir = (value: unknown): string | null => {
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: SharedProjectConfig = {
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.
*/
export const parseSharedProjectConfig = (raw: string): SharedProjectConfigRead => {
let parsed: unknown;
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: string | null = 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' };
}
const wait = parsed.setupWorktreeWait;
return {
status: 'ok',
config: {
setupWorktree: sanitizeSetupCommands(parsed.setupWorktree),
setupWorktreeWait: typeof wait === 'boolean' ? wait : null,
projectActions: sanitizeProjectActions(parsed.projectActions),
draftStarters: sanitizeDraftStarters(parsed.draftStarters),
plansDir,
},
};
};
const withSource = <T,>(entries: T[], source: ProjectSetupSource): Array<T & { source: ProjectSetupSource }> =>
entries.map((entry) => ({ ...entry, source }));
/**
* One merged view from the personal part and the shared read. Same rules as
* the server: shared setup commands first (unless personal replaces), the
* personal wait flag wins when set, actions union by id with personal
* replacing shared and hidden shared ids dropped, starters union by key.
*/
export const mergeProjectSetup = (personal: PersonalProjectSetup, sharedRead: SharedProjectConfigRead): ProjectSetupView => {
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 {
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: SharedProjectConfigRead, shared: SharedProjectConfig): ProjectSetupView['shared'] => {
const block: ProjectSetupView['shared'] = { 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: ProjectAction): Omit<ProjectAction, 'icon'> & { icon?: string } => {
const { icon, ...rest } = action;
return icon === null ? rest : { ...rest, icon };
};
/** True when the shared config carries nothing: the file should not exist. */
export const isSharedProjectConfigEmpty = (config: SharedProjectConfig): boolean => (
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, only the keys that carry something, pretty-printed. */
export const serializeSharedProjectConfig = (config: SharedProjectConfig): string => {
const document: Record<string, unknown> = { 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`;
};
export const EMPTY_SHARED_PROJECT_CONFIG: SharedProjectConfig = EMPTY_SHARED;
/** The next shared config after a client patch over the current one; wrong shapes are validation errors. */
export const applySharedProjectSetupPatch = (current: SharedProjectConfig, patch: unknown): SharedProjectConfig => {
if (!isObjectRecord(patch)) throw new ProjectSetupValidationError('patch must be an object');
const next: SharedProjectConfig = { ...current };
if ('setupWorktree' in patch) {
if (!Array.isArray(patch.setupWorktree)) throw new ProjectSetupValidationError('setupWorktree must be an array of commands');
next.setupWorktree = sanitizeSetupCommands(patch.setupWorktree);
}
if ('setupWorktreeWait' in patch) {
const wait = patch.setupWorktreeWait;
if (wait !== null && typeof wait !== 'boolean') throw new ProjectSetupValidationError('setupWorktreeWait must be a boolean or null');
next.setupWorktreeWait = wait;
}
if ('projectActions' in patch) {
if (!Array.isArray(patch.projectActions)) throw new ProjectSetupValidationError('projectActions must be an array');
next.projectActions = sanitizeProjectActions(patch.projectActions);
}
if ('draftStarters' in patch) {
if (!Array.isArray(patch.draftStarters)) throw new ProjectSetupValidationError('draftStarters must be an array');
next.draftStarters = sanitizeDraftStarters(patch.draftStarters);
}
if ('plansDir' in patch) {
const raw = patch.plansDir;
if (raw === null || (typeof raw === 'string' && !raw.trim())) {
next.plansDir = null;
} else {
const plansDir = normalizePlansDir(raw);
if (!plansDir) throw new ProjectSetupValidationError('plansDir must be a relative path inside the repository');
next.plansDir = plansDir;
}
}
return next;
};
/**
* The stored keys a client patch changes; `undefined` marks a key to remove.
* A key with the wrong shape is a validation error, never silently dropped.
*/
export const projectSetupPatchToStored = (patch: unknown): StoredProjectSetupPatch => {
if (!isObjectRecord(patch)) {
throw new ProjectSetupValidationError('patch must be an object');
}
const stored: StoredProjectSetupPatch = {};
if ('setupWorktree' in patch) {
if (!Array.isArray(patch.setupWorktree)) throw new ProjectSetupValidationError('setupWorktree must be an array of commands');
stored['setup-worktree'] = sanitizeSetupCommands(patch.setupWorktree);
}
if ('setupWorktreeWait' in patch) {
if (typeof patch.setupWorktreeWait !== 'boolean') throw new ProjectSetupValidationError('setupWorktreeWait must be a boolean');
stored['setup-worktree-wait'] = patch.setupWorktreeWait;
}
if ('projectActions' in patch) {
if (!Array.isArray(patch.projectActions)) throw new ProjectSetupValidationError('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 ProjectSetupValidationError('projectActionsPrimaryId must be a string or null');
}
stored.projectActionsPrimaryId = trimmedString(primary) || undefined;
}
if ('draftStarters' in patch) {
if (!Array.isArray(patch.draftStarters)) throw new ProjectSetupValidationError('draftStarters must be an array');
stored.draftStarters = sanitizeDraftStarters(patch.draftStarters);
}
if ('hiddenSharedActionIds' in patch) {
if (!Array.isArray(patch.hiddenSharedActionIds)) throw new ProjectSetupValidationError('hiddenSharedActionIds must be an array');
stored.hiddenSharedActionIds = sanitizeIdList(patch.hiddenSharedActionIds);
}
if ('setupWorktreeMode' in patch) {
if (patch.setupWorktreeMode !== 'append' && patch.setupWorktreeMode !== 'replace') {
throw new ProjectSetupValidationError('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 ProjectSetupValidationError('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 ProjectSetupValidationError('projectPath must be a string');
const projectPath = patch.projectPath.trim();
if (projectPath) stored.projectPath = projectPath;
}
return stored;
};
+3 -5
View File
@@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fetchExeDevUsage } from './exeDevQuota';
import { fetchOllamaUsage } from './ollamaQuota';
export type ManagedProvider = 'exe-dev' | 'ollama-cloud' | 'cursor';
export type ManagedCredential = Record<string, string>;
@@ -53,13 +54,10 @@ export const importCursorCredential = () => {
return credential;
};
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential, fetchImpl: (url: string, init: RequestInit) => Promise<Response> = fetch) => {
if (provider === 'exe-dev') await fetchExeDevUsage(credential.usageToken);
if (provider === 'ollama-cloud') {
const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed');
const html = await response.text();
if (!/Session\s+usage|Weekly\s+usage|Premium[^0-9]*[0-9]+\s*\/\s*[0-9]+/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed');
await fetchOllamaUsage(credential.cookie, fetchImpl);
}
if (provider === 'cursor') {
if (!credential.accessToken && credential.refreshToken) {
+533 -8
View File
@@ -15,17 +15,21 @@ const ORIGINAL_FS = { ...fs };
const AUTH = JSON.stringify({
openai: { access: 'test-token' },
crof: { key: 'test-token' },
'cline-pass': { key: 'test-token' },
neuralwatt: { key: 'test-token' },
'opencode-go': { key: 'test-token' },
openrouter: { key: 'test-token' },
'zai-coding-plan': { key: 'test-token' },
deepseek: { key: 'test-token' },
hyper: { key: 'test-token' },
'github-copilot': { access: 'test-token' },
anthropic: { access: 'test-token', refresh: 'test-refresh' },
});
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH;
import { fetchQuotaForProvider } from './quotaProviders';
import { fetchClinePassQuota, fetchHyperQuota, fetchOllamaCloudQuota, fetchQuotaForProvider } from './quotaProviders';
import { validateCredential } from './quotaCredentials';
type MockResponseInit = { ok?: boolean; status?: number };
@@ -84,6 +88,13 @@ const stubFetchFailing = (json: () => Promise<unknown>, init: MockResponseInit):
globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch;
};
test('dispatches Charm Hyper through the generic quota API', async () => {
stubFetchReturning(async () => Response.json({ balance: 100 }));
const result = await fetchQuotaForProvider('hyper');
assert.equal(result.ok, true);
assert.equal(result.usage?.windows.credits?.valueLabel, '100');
});
describe('OpenCode Go quota provider (VS Code parity)', () => {
test('uses the opencode-go key from auth.json', async () => {
let request: RequestInit | undefined;
@@ -105,6 +116,180 @@ describe('OpenCode Go quota provider (VS Code parity)', () => {
});
});
describe('OpenRouter quota provider (VS Code parity)', () => {
const documentedPayload = {
data: {
label: 'test-key',
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,
},
};
test('reads the documented key endpoint and emits the current reset window', async () => {
let requestedUrl = '';
let requestInit: RequestInit | undefined;
globalThis.fetch = (async (url: string, init?: RequestInit) => {
requestedUrl = url;
requestInit = init;
return mockResponse(documentedPayload);
}) as typeof fetch;
const result = await fetchQuotaForProvider('openrouter');
assert.equal(requestedUrl, 'https://openrouter.ai/api/v1/key');
assert.equal(requestedUrl.includes('/api/v1/credits'), false);
assert.equal(new Headers(requestInit?.headers).get('Authorization'), 'Bearer test-token');
assert.equal(new Headers(requestInit?.headers).get('Accept-Encoding'), 'identity');
assert.ok(requestInit?.signal instanceof AbortSignal);
assert.deepEqual(Object.keys(result.usage!.windows), ['daily']);
assert.equal(result.usage!.windows.daily!.windowSeconds, 86400);
assert.equal(result.usage!.windows.daily!.valueLabel, '$0.00 / $30.00');
assert.ok(typeof result.usage!.windows.daily!.resetAt === 'number');
});
test('maps an unlimited null-limit key to a monthly spent window', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
data: { limit: null, limit_remaining: null, limit_reset: null, usage_monthly: 12.5, is_management_key: false },
})));
const result = await fetchQuotaForProvider('openrouter');
assert.equal(result.ok, true);
assert.deepEqual(Object.keys(result.usage!.windows), ['monthly']);
assert.equal(result.usage!.windows.monthly!.usedPercent, null);
assert.equal(result.usage!.windows.monthly!.windowSeconds, 30 * 86400);
assert.equal(result.usage!.windows.monthly!.valueLabel, '$12.50 spent');
assert.ok(typeof result.usage!.windows.monthly!.resetAt === 'number');
});
test('maps a lifetime cap to a credits window without reset metadata', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
data: { limit: 30, limit_remaining: 25, limit_reset: null, usage_monthly: 5 },
})));
const result = await fetchQuotaForProvider('openrouter');
const window = result.usage!.windows.credits;
assert.ok(window);
assert.equal(window!.windowSeconds, null);
assert.equal(window!.resetAt, null);
});
test('maps an unrecognized reset period to a credits window', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
data: { limit: 30, limit_remaining: 25, limit_reset: 'yearly', usage_monthly: 5 },
})));
const result = await fetchQuotaForProvider('openrouter');
assert.ok(result.usage!.windows.credits);
assert.equal(result.usage!.windows.credits!.windowSeconds, null);
assert.equal(result.usage!.windows.credits!.resetAt, null);
});
test('clamps percent at 100 while leaving the money label unclamped', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
data: { limit: 30, limit_remaining: -1, limit_reset: 'monthly', usage_monthly: 31 },
})));
const result = await fetchQuotaForProvider('openrouter');
const window = result.usage!.windows.monthly;
assert.equal(window!.usedPercent, 100);
assert.equal(window!.valueLabel, '$31.00 / $30.00');
});
test('uses a weekly window and derives its reset on Monday UTC', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
data: { limit: 30, limit_remaining: 25, limit_reset: 'weekly', usage_monthly: 5 },
})));
const result = await fetchQuotaForProvider('openrouter');
const window = result.usage!.windows.weekly;
assert.ok(window);
assert.equal(window!.windowSeconds, 604800);
assert.equal(new Date(window!.resetAt!).getUTCDay(), 1);
});
test('rejects management keys with an inference-key error', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({ data: { is_management_key: true } })));
const result = await fetchQuotaForProvider('openrouter');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
assert.equal(result.error, 'Management key configured — quota needs an inference API key');
});
for (const status of [401, 403]) {
test(`maps HTTP ${status} to session expiry`, async () => {
stubFetchFailing(async () => ({}), { ok: false, status });
const result = await fetchQuotaForProvider('openrouter');
assert.equal(result.ok, false);
assert.equal(result.error, 'Session expired — please re-authenticate with OpenRouter');
});
}
test('reports invalid JSON as a parse failure', async () => {
globalThis.fetch = (async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('Unexpected token'); },
}) as unknown as Response) as typeof fetch;
const result = await fetchQuotaForProvider('openrouter');
assert.equal(result.ok, false);
assert.equal(result.error, 'Invalid response from provider');
});
test('normalizes timeout failures', async () => {
stubFetchReturning(() => Promise.reject(new DOMException('Timed out', 'TimeoutError')));
const result = await fetchQuotaForProvider('openrouter');
assert.equal(result.ok, false);
assert.equal(result.error, 'Request timed out');
});
test('rejects a response without usable quota data', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({ data: { limit: 30, limit_remaining: null } })));
const result = await fetchQuotaForProvider('openrouter');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
assert.equal(result.error, 'No quota data in response');
});
for (const payload of [{ data: {} }, { data: null }]) {
test(`rejects ${JSON.stringify(payload)} without quota data`, async () => {
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('openrouter');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
assert.equal(result.error, 'No quota data in response');
});
}
});
describe('Crof quota provider (VS Code parity)', () => {
test('reports credits balance as valueLabel with null percent', async () => {
@@ -152,6 +337,121 @@ describe('Crof quota provider (VS Code parity)', () => {
});
});
describe('ClinePass quota provider (VS Code parity)', () => {
// Live-verified response shape of
// GET https://api.cline.bot/api/v1/users/me/plan/usage-limits
const documentedPayload = {
data: {
limits: [
{ type: 'five_hour', percentUsed: 43, resetsAt: '2026-09-08T17:00:44.598174595Z' },
{ type: 'weekly', percentUsed: 17, resetsAt: '2026-09-13T17:00:44.598174595Z' },
{ type: 'monthly', percentUsed: 8, resetsAt: '2026-10-01T00:00:00Z' },
],
},
success: true,
};
test('maps documented limit kinds to 5h/weekly/monthly windows', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse(documentedPayload)));
const result = await fetchQuotaForProvider('cline-pass');
assert.equal(result.ok, true);
assert.equal(result.providerId, 'cline-pass');
assert.equal(result.usage?.windows['5h']?.usedPercent, 43);
assert.equal(result.usage?.windows['5h']?.windowSeconds, 18_000);
assert.equal(result.usage?.windows['5h']?.resetAt, Date.parse('2026-09-08T17:00:44.598174595Z'));
assert.equal(result.usage?.windows.weekly?.usedPercent, 17);
assert.equal(result.usage?.windows.weekly?.windowSeconds, 604_800);
assert.equal(result.usage?.windows.monthly?.usedPercent, 8);
assert.equal(result.usage?.windows.monthly?.windowSeconds, null);
});
test('ignores unknown limit types and rejects responses without quota data', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({ data: { limits: [{ type: 'quarterly', percentUsed: 5 }] } })));
const result = await fetchQuotaForProvider('cline-pass');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
assert.equal(result.error, 'No quota data in response');
});
test('maps 401 to session-expired with ClinePass branding', async () => {
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
const result = await fetchQuotaForProvider('cline-pass');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'Session expired — please re-authenticate with ClinePass');
});
test('reports invalid-response on JSON parse failure', async () => {
stubFetchFailing(async () => { throw new SyntaxError('Unexpected token'); }, { ok: true, status: 200 });
const result = await fetchQuotaForProvider('cline-pass');
assert.equal(result.ok, false);
assert.equal(result.error, 'Invalid response from provider');
});
const readAuth = () => ({ 'cline-pass': { key: 'test-token' } });
for (const limit of [
{ type: 'constructor', percentUsed: 5 }, { type: 'toString', percentUsed: 5 },
{ type: '__proto__', percentUsed: 5 }, { type: 'weekly', percentUsed: '' },
{ type: 'weekly', percentUsed: ' ' }, { type: 'weekly', percentUsed: true },
{ type: 'weekly', percentUsed: null }, null,
]) {
test(`skips malformed windows independently: ${JSON.stringify(limit)}`, async () => {
const invalid = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit] } }) });
assert.equal(invalid.ok, false);
assert.equal(invalid.usage, null);
const mixed = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit, { type: 'monthly', percentUsed: 8 }] } }) });
assert.equal(mixed.ok, true);
assert.ok(mixed.usage);
assert.deepEqual(Object.keys(mixed.usage.windows), ['monthly']);
});
}
for (const percentUsed of [0, '0', '51']) {
test(`accepts percentage ${JSON.stringify(percentUsed)}`, async () => {
const result = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [{ type: 'weekly', percentUsed }] } }) });
assert.equal(result.ok, true);
assert.equal(result.usage?.windows.weekly?.usedPercent, Number(percentUsed));
});
}
for (const entry of [{ key: '' }, { key: ' ' }, { key: 42 }, {}]) {
test(`falls back to a usable token: ${JSON.stringify(entry)}`, async () => {
const result = await fetchClinePassQuota({
readAuth: () => ({ 'cline-pass': { ...entry, token: 'test-token' } }),
fetchImpl: async (_url, options) => {
assert.equal(new Headers(options.headers).get('Authorization'), 'Bearer test-token');
return Response.json(documentedPayload);
},
});
assert.equal(result.ok, true);
});
}
test('does not request usage without usable credentials', async () => {
const result = await fetchClinePassQuota({ readAuth: () => ({ 'cline-pass': { key: 42 } }), fetchImpl: async () => { throw new Error('Unexpected fetch'); } });
assert.equal(result.configured, false);
assert.equal(result.error, 'Not configured');
});
test('recognizes the timeout exception from AbortSignal.timeout', async () => {
const result = await fetchClinePassQuota({ readAuth, fetchImpl: async () => { throw new DOMException('Timed out', 'TimeoutError'); } });
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
assert.equal(result.error, 'Request timed out');
});
});
describe('Codex quota provider (VS Code parity)', () => {
test('coalesces concurrent refreshes for the same provider', async () => {
let resolveResponse: ((response: Response) => void) | undefined;
@@ -421,7 +721,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => {
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
});
test('surfaces subscription and allowance windows (allowance keyed by period, key name in valueLabel)', async () => {
test('surfaces subscription and allowance windows (allowance keyed by period, percent value)', async () => {
const payload = {
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
balance: { credits_remaining_usd: 200 },
@@ -439,11 +739,11 @@ describe('NeuralWatt quota provider (VS Code parity)', () => {
assert.ok(Math.abs((subWindow!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
// 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;
assert.ok(allowWindow);
assert.equal(allowWindow!.usedPercent, 25);
assert.equal(allowWindow!.valueLabel, 'Prod');
assert.equal(allowWindow!.valueLabel, undefined);
assert.equal(allowWindow!.resetAt, Date.parse('2026-08-01T00:00:00Z'));
assert.equal(result.usage!.windows.credits_balance, undefined);
@@ -468,7 +768,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => {
assert.ok(Math.abs((window!.usedPercent as number) - (25 / 55) * 100) < 1e-2);
assert.equal(window!.windowSeconds, 30 * 86400);
assert.equal(window!.resetAt, Date.parse('2026-08-01T00:00:00Z'));
assert.equal(window!.valueLabel, 'prod-key');
assert.equal(window!.valueLabel, undefined);
assert.equal(result.usage!.windows.credits_balance, undefined);
});
@@ -507,7 +807,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => {
assert.ok(window);
assert.equal(window!.windowSeconds, 604800);
assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z'));
assert.equal(window!.valueLabel, 'Prod');
assert.equal(window!.valueLabel, undefined);
});
test('uses daily as the allowance key when period is daily', async () => {
@@ -547,7 +847,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => {
assert.equal(window!.usedPercent, 25);
});
test('marks blocked allowance as 100% with valueLabel set', async () => {
test('marks blocked allowance as 100% with percent value', async () => {
const payload = {
balance: { credits_remaining_usd: 30 },
subscription: null,
@@ -563,7 +863,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => {
const window = result.usage!.windows.monthly;
assert.ok(window);
assert.equal(window!.usedPercent, 100);
assert.equal(window!.valueLabel, 'sample');
assert.equal(window!.valueLabel, undefined);
});
test('falls back to credits_balance when neither subscription nor allowance exists', async () => {
@@ -714,3 +1014,228 @@ describe('DeepSeek quota provider (VS Code parity)', () => {
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
});
});
describe('Ollama Cloud quota validation and refresh', () => {
const credential = { cookie: 'test-ollama-cookie' };
const readCookie = () => credential.cookie;
for (const { html, expected } of [
{ html: '<h1>Monthly usage</h1><p>$25.00 of $100.00</p>', expected: { monthly: { usedPercent: 25, valueLabel: '$25.00 / $100.00' } } },
{ html: 'Monthly usage $1,250.00 of $2,500.00', expected: { monthly: { usedPercent: 50, valueLabel: '$1,250.00 / $2,500.00' } } },
{ html: 'Session usage 12% Weekly usage 34% Premium 2 / 10', expected: { session: { usedPercent: 12 }, weekly: { usedPercent: 34 }, premium: { usedPercent: 20, valueLabel: '2 / 10' } } },
{ html: 'Monthly usage $0 of $100 Balance remaining $5.25 Add $5', expected: { monthly: { usedPercent: 0, valueLabel: '$0 / $100' }, credits_balance: { usedPercent: null, valueLabel: '$5.25' } } },
{ html: 'Monthly usage $0 of $100 Balance remaining $0.00 Add $5', expected: { monthly: { usedPercent: 0, valueLabel: '$0 / $100' } } },
{ html: 'Monthly usage $125 of $100 Add $5', expected: { monthly: { usedPercent: 100, valueLabel: '$125 / $100' } } },
]) {
test(`accepts and displays ${html}`, async () => {
let requests = 0;
const fetchImpl = async (url: string, init: RequestInit) => {
requests += 1;
assert.equal(url, 'https://ollama.com/settings');
assert.equal(init.redirect, 'manual');
assert.equal(init.method, 'GET');
assert.equal(new Headers(init.headers).get('Cookie'), credential.cookie);
assert.ok(init.signal instanceof AbortSignal);
return new Response(html);
};
await validateCredential('ollama-cloud', credential, fetchImpl);
const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl });
assert.equal(requests, 2);
assert.equal(result.ok, true);
assert.ok(result.usage);
assert.deepEqual(Object.keys(result.usage.windows), Object.keys(expected));
for (const [key, expectedWindow] of Object.entries(expected)) {
const window: NonNullable<typeof result.usage>['windows'][string] = result.usage.windows[key];
assert.ok(window);
assert.equal(window.usedPercent, expectedWindow.usedPercent);
if ('valueLabel' in expectedWindow) assert.equal(window.valueLabel, expectedWindow.valueLabel);
assert.equal(window.resetAt, null);
}
assert.equal(JSON.stringify(result).includes(credential.cookie), false);
});
}
for (const html of ['', '<h1>Monthly usage</h1>', 'Session usage', 'Session usage 1.2.3%', 'Weekly usage 1.2.3%', 'Add $5', 'Monthly usage $1.2.3 of $100', 'Balance remaining $1.2.3']) {
test(`rejects unparseable HTML ${JSON.stringify(html)} in both consumers`, async () => {
const fetchImpl = async () => new Response(html);
await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), /usage data could not be parsed/);
const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl });
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
assert.equal(result.error, 'Ollama Cloud usage data could not be parsed');
});
}
for (const status of [302, 307, 401, 403, 429, 500]) {
test(`rejects HTTP ${status} in both consumers`, async () => {
const fetchImpl = async () => new Response('Monthly usage $25 of $100', { status });
await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), /authentication failed/);
const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl });
assert.equal(result.ok, false);
assert.equal(result.usage, null);
assert.equal(result.error, 'Ollama Cloud authentication failed');
});
}
for (const failure of [new DOMException('Request timed out', 'TimeoutError'), new Error('Network unavailable')]) {
test(`reports ${failure.message} in both consumers`, async () => {
const fetchImpl = async () => { throw failure; };
await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), failure);
const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl });
assert.equal(result.ok, false);
assert.equal(result.usage, null);
assert.equal(result.error, failure.message);
});
}
test('does not request usage without a cookie', async () => {
const result = await fetchOllamaCloudQuota({ readCookie: () => undefined, fetchImpl: async () => { assert.fail('Unexpected request'); } });
assert.equal(result.configured, false);
assert.equal(result.ok, false);
});
test('reports response body failures in both consumers', async () => {
const failure = new Error('Response body interrupted');
const fetchImpl = async () => new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.error(failure);
},
}));
await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), failure);
const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl });
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
assert.equal(result.error, failure.message);
assert.deepEqual(credential, { cookie: 'test-ollama-cookie' });
});
});
describe('Charm Hyper quota provider (VS Code parity)', () => {
const readAuth = () => ({ hyper: { key: 'test-token' } });
for (const { balance, credits, dollars } of [
{ balance: 100, credits: '100', dollars: '$5.00' },
{ balance: '50', credits: '50', dollars: '$2.50' },
{ balance: 25.5, credits: '25.50', dollars: '$1.28' },
{ balance: 0, credits: '0', dollars: '$0.00' },
{ balance: '0', credits: '0', dollars: '$0.00' },
]) {
test(`formats balance ${JSON.stringify(balance)} without an untranslated unit`, async () => {
const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => Response.json({ balance }) });
assert.equal(result.ok, true);
assert.equal(result.providerId, 'hyper');
assert.equal(result.configured, true);
assert.ok(result.usage);
assert.equal(result.usage.windows.credits?.valueLabel, credits);
assert.equal(result.usage.windows.credits_balance?.valueLabel, dollars);
for (const window of Object.values(result.usage.windows)) {
assert.equal(window.usedPercent, null);
assert.equal(window.remainingPercent, null);
assert.equal(window.windowSeconds, null);
assert.equal(window.resetAt, null);
assert.equal(window.resetAfterSeconds, null);
}
});
}
for (const payload of [
{}, null, [], { balance: '' }, { balance: ' \t ' }, { balance: 'NaN' },
{ balance: 'Infinity' }, { balance: null }, { balance: true }, { balance: [] },
{ balance: {} },
]) {
test(`rejects invalid payload ${JSON.stringify(payload)} instead of showing zero`, async () => {
const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => Response.json(payload) });
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'No quota data in response');
assert.equal(result.usage, null);
});
}
for (const [index, auth] of [
{ hyper: { key: 'test-token' } },
{ hyper: { token: 'test-token' } },
{ hyper: 'test-token' },
{ hyper: { key: ' ', token: 'test-token' } },
{ hyper: { key: 42, token: 'test-token' } },
].entries()) {
test(`uses validated credential variant ${index} for the documented request`, async () => {
let requests = 0;
const result = await fetchHyperQuota({
readAuth: () => auth,
fetchImpl: async (url, options) => {
requests += 1;
assert.equal(url, 'https://hyper.charm.land/v1/credits');
assert.equal(options.method, 'GET');
assert.equal(new Headers(options.headers).get('Authorization'), 'Bearer test-token');
assert.ok(options.signal instanceof AbortSignal);
return Response.json({ balance: 100 });
},
});
assert.equal(requests, 1);
assert.equal(result.ok, true);
assert.equal(JSON.stringify(result).includes('test-token'), false);
});
}
for (const [index, readInvalidAuth] of [
() => ({}),
() => ({ hyper: { key: '' } }),
() => ({ hyper: { key: ' ' } }),
() => ({ hyper: { key: 42 } }),
].entries()) {
test(`does not request usage with missing or invalid credential variant ${index}`, async () => {
let requests = 0;
const result = await fetchHyperQuota({
readAuth: readInvalidAuth,
fetchImpl: async () => {
requests += 1;
return Response.json({ balance: 100 });
},
});
assert.equal(requests, 0);
assert.equal(result.ok, false);
assert.equal(result.configured, false);
assert.equal(result.error, 'Not configured');
});
}
for (const { status, error } of [
{ status: 401, error: 'Session expired — please re-authenticate with Charm Hyper' },
{ status: 403, error: 'Session expired — please re-authenticate with Charm Hyper' },
{ status: 429, error: 'API error: 429' },
{ status: 500, error: 'API error: 500' },
]) {
test(`reports HTTP ${status} as a failure`, async () => {
const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) });
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, error);
assert.equal(result.usage, null);
});
}
test('reports invalid JSON as a parse failure', async () => {
const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response('{') });
assert.equal(result.error, 'Invalid response from provider');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
});
for (const { failure, message } of [
{ failure: new DOMException('Timed out', 'TimeoutError'), message: 'Request timed out' },
{ failure: new Error('Network unavailable'), message: 'Network unavailable' },
]) {
test(`reports ${message}`, async () => {
const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => { throw failure; } });
assert.equal(result.error, message);
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
});
}
});
+368 -79
View File
@@ -6,6 +6,7 @@ import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials';
import { getProviderAuth, updateProviderAuth } from './opencodeAuth';
import { fetchExeDevUsage } from './exeDevQuota';
import { fetchOllamaUsage } from './ollamaQuota';
type AuthEntry = Record<string, unknown> | string;
type AuthFile = Record<string, AuthEntry>;
@@ -145,6 +146,11 @@ type CrofPayload = {
credits?: number | string;
};
type ClineWindowKind = {
key: string;
windowSeconds: number | null;
};
type DeepseekPayload = {
is_available?: boolean;
balance_infos?: Array<{
@@ -843,6 +849,11 @@ export const listConfiguredQuotaProviders = () => {
configured.add('crof');
}
const clineAuth = normalizeAuthEntry(getAuthEntry(auth, ['cline-pass']));
if (clineAuth && (asNonEmptyString(clineAuth.key) || asNonEmptyString(clineAuth.token))) {
configured.add('cline-pass');
}
const neuralwattAuth = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt']));
if (neuralwattAuth && ((neuralwattAuth as Record<string, unknown>).key || (neuralwattAuth as Record<string, unknown>).token)) {
configured.add('neuralwatt');
@@ -853,6 +864,10 @@ export const listConfiguredQuotaProviders = () => {
configured.add('deepseek');
}
if (getHyperApiKey(auth)) {
configured.add('hyper');
}
let xaiAuth: XaiAuthEntry | null = null;
try {
xaiAuth = resolveXaiAuth();
@@ -1862,44 +1877,14 @@ const fetchMiniMaxCnCodingPlanQuota = () => fetchMiniMaxQuota({
usageFieldsAreRemaining: true,
});
const parseOllamaSettingsHtml = (html: string) => {
const windows: Record<string, UsageWindow> = {};
const sessionMatch = html.match(/Session\s+usage[^0-9]*([0-9.]+)%/i);
if (sessionMatch) {
windows.session = toUsageWindow({
usedPercent: toNumber(sessionMatch[1]),
windowSeconds: null,
resetAt: null,
});
}
const weeklyMatch = html.match(/Weekly\s+usage[^0-9]*([0-9.]+)%/i);
if (weeklyMatch) {
windows.weekly = toUsageWindow({
usedPercent: toNumber(weeklyMatch[1]),
windowSeconds: null,
resetAt: null,
});
}
const premiumMatch = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i);
if (premiumMatch) {
const used = toNumber(premiumMatch[1]);
const total = toNumber(premiumMatch[2]);
const usedPercent = total && used !== null ? Math.min(100, (used / total) * 100) : null;
windows.premium = toUsageWindow({
usedPercent,
windowSeconds: null,
resetAt: null,
valueLabel: `${used ?? 0} / ${total ?? 0}`,
});
}
return windows;
};
const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
const cookie = readCredential('ollama-cloud')?.cookie;
export const fetchOllamaCloudQuota = async ({
readCookie = () => readCredential('ollama-cloud')?.cookie,
fetchImpl = fetch,
}: {
readCookie?: () => string | undefined;
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
} = {}): Promise<ProviderResult> => {
const cookie = readCookie();
if (!cookie) {
return buildResult({
@@ -1912,30 +1897,17 @@ const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
}
try {
const response = await fetch('https://ollama.com/settings', {
method: 'GET',
headers: {
Cookie: cookie,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
});
if (!response.ok) {
return buildResult({
providerId: 'ollama-cloud',
providerName: 'Ollama Cloud',
ok: false,
configured: true,
error: `API error: ${response.status}`,
});
}
const parsed = await fetchOllamaUsage(cookie, fetchImpl);
const windows = Object.fromEntries(Object.entries(parsed).map(([key, value]) => [
key, toUsageWindow({ ...value, windowSeconds: null, resetAt: null }),
]));
return buildResult({
providerId: 'ollama-cloud',
providerName: 'Ollama Cloud',
ok: true,
configured: true,
usage: { windows: parseOllamaSettingsHtml(await response.text()) },
usage: { windows },
});
} catch (error) {
return buildResult({
@@ -1971,6 +1943,28 @@ const fetchCursorQuota = async (): Promise<ProviderResult> => {
} catch (error) { return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); }
};
const openRouterResetAt = (period: string | null, nowMs: number): number | null => {
const now = new Date(nowMs);
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
const day = now.getUTCDate();
if (period === 'daily') return Date.UTC(year, month, day + 1);
if (period === 'weekly') {
const daysUntilMonday = ((8 - now.getUTCDay()) % 7) || 7;
return Date.UTC(year, month, day + daysUntilMonday);
}
if (period === 'monthly') return Date.UTC(year, month + 1, 1);
return null;
};
const PERIOD_SECONDS = { daily: 86400, weekly: 604800, monthly: 30 * 86400 };
type OpenRouterPeriod = keyof typeof PERIOD_SECONDS;
const isOpenRouterPeriod = (value: unknown): value is OpenRouterPeriod => (
typeof value === 'string' && Object.prototype.hasOwnProperty.call(PERIOD_SECONDS, value)
);
const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record<string, unknown> | null;
@@ -1986,13 +1980,16 @@ const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetch('https://openrouter.ai/api/v1/credits', {
const response = await fetch('https://openrouter.ai/api/v1/key', {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
@@ -2001,20 +1998,86 @@ const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
providerName: 'OpenRouter',
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() as Record<string, unknown>;
const credits = payload.data as Record<string, unknown> | undefined;
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: string | null = null;
if (remaining !== null && totalUsage !== null) {
valueLabel = `$${formatMoney(remaining)} left · $${formatMoney(totalUsage)} spent`;
const payload = await response.json() as unknown;
const dataContainer = asObject(payload);
const data = asObject(dataContainer?.data);
if (data === null) {
return buildResult({
providerId: 'openrouter',
providerName: 'OpenRouter',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
if (data.is_management_key === true) {
return buildResult({
providerId: 'openrouter',
providerName: 'OpenRouter',
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: 'openrouter',
providerName: 'OpenRouter',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
const usageMonthly = toNumber(data.usage_monthly);
if (limit === null && usageMonthly === null) {
return buildResult({
providerId: 'openrouter',
providerName: 'OpenRouter',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
const nowMs = Date.now();
let windowKey: string;
let windowSeconds: number | null;
let resetAt: number | null;
let usedPercent: number | null;
let valueLabel: string;
if (limit === null) {
windowKey = 'monthly';
windowSeconds = PERIOD_SECONDS.monthly;
resetAt = openRouterResetAt('monthly', nowMs);
usedPercent = null;
valueLabel = `$${formatMoney(usageMonthly)} spent`;
} else {
const used = Math.max(0, limit - (limitRemaining ?? 0));
const percent = limit > 0 ? (used / limit) * 100 : null;
usedPercent = percent === null ? null : Math.min(100, percent);
valueLabel = `$${formatMoney(used)} / $${formatMoney(limit)}`;
if (isOpenRouterPeriod(data.limit_reset)) {
windowKey = data.limit_reset;
windowSeconds = PERIOD_SECONDS[data.limit_reset];
resetAt = openRouterResetAt(data.limit_reset, nowMs);
} else {
windowKey = 'credits';
windowSeconds = null;
resetAt = null;
}
}
return buildResult({
@@ -2024,22 +2087,28 @@ const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
configured: true,
usage: {
windows: {
credits: toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
[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: 'openrouter',
providerName: 'OpenRouter',
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',
});
}
};
@@ -2508,7 +2577,6 @@ const fetchNeuralwattQuota = async (): Promise<ProviderResult> => {
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: Record<string, UsageWindow> = {};
@@ -2554,19 +2622,17 @@ const fetchNeuralwattQuota = async (): Promise<ProviderResult> => {
: (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 = typeof keyName === 'string' && keyName.trim() ? keyName.trim() : null;
const resetAt = toTimestamp(allowance.reset_at);
const windowSeconds = period ? neuralwattWindowSeconds(period) : null;
windows[periodKey] = toUsageWindow({
usedPercent,
windowSeconds,
resetAt,
...(labelName ? { valueLabel: labelName } : {}),
});
} else if (creditsRemaining !== null) {
windows.credits_balance = toUsageWindow({
@@ -2689,6 +2755,118 @@ const fetchCrofQuota = async (): Promise<ProviderResult> => {
}
};
const CLINE_PASS_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 CLINE_WINDOW_KINDS = new Map<string, ClineWindowKind>([
['five_hour', { key: '5h', windowSeconds: 5 * 60 * 60 }],
['weekly', { key: 'weekly', windowSeconds: 7 * 24 * 60 * 60 }],
['monthly', { key: 'monthly', windowSeconds: null }],
]);
type ClineQuotaDependencies = {
readAuth?: () => AuthFile;
fetchImpl?: (url: string, options: RequestInit) => Promise<Response>;
};
export const fetchClinePassQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: ClineQuotaDependencies = {}): Promise<ProviderResult> => {
const auth = readAuth();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['cline-pass']));
const apiKey = asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
if (!apiKey) {
return buildResult({
providerId: 'cline-pass',
providerName: 'ClinePass',
ok: false,
configured: false,
error: 'Not configured',
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetchImpl(CLINE_PASS_USAGE_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
return buildResult({
providerId: 'cline-pass',
providerName: 'ClinePass',
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: Record<string, UsageWindow> = {};
for (const item of limits) {
const limit = asObject(item);
if (!limit) continue;
const limitType = asNonEmptyString(limit.type);
const kind = limitType === null ? undefined : CLINE_WINDOW_KINDS.get(limitType);
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: 'cline-pass',
providerName: 'ClinePass',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
return buildResult({
providerId: 'cline-pass',
providerName: 'ClinePass',
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: 'cline-pass',
providerName: 'ClinePass',
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed'),
});
}
};
const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance';
const fetchDeepseekQuota = async (): Promise<ProviderResult> => {
@@ -2786,6 +2964,113 @@ const fetchDeepseekQuota = async (): Promise<ProviderResult> => {
}
};
const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits';
const HYPER_CREDIT_TO_USD = 0.05;
const getHyperApiKey = (auth: AuthFile) => {
const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper']));
return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
};
type HyperQuotaDependencies = {
readAuth?: () => AuthFile;
fetchImpl?: (url: string, options: RequestInit) => Promise<Response>;
};
export const fetchHyperQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: HyperQuotaDependencies = {}): Promise<ProviderResult> => {
const apiKey = getHyperApiKey(readAuth());
if (!apiKey) {
return buildResult({
providerId: 'hyper',
providerName: 'Charm Hyper',
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: 'hyper',
providerName: 'Charm Hyper',
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: 'hyper',
providerName: 'Charm Hyper',
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 * HYPER_CREDIT_TO_USD)}`,
}),
credits: toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel: creditsLabel,
}),
};
return buildResult({
providerId: 'hyper',
providerName: 'Charm Hyper',
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: 'hyper',
providerName: 'Charm Hyper',
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed'),
});
}
};
const fetchXaiQuota = async (): Promise<ProviderResult> => {
try {
const entry = resolveXaiAuth();
@@ -2905,8 +3190,12 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<Pro
return fetchCursorQuota();
case 'crof':
return fetchCrofQuota();
case 'cline-pass':
return fetchClinePassQuota();
case 'deepseek':
return fetchDeepseekQuota();
case 'hyper':
return fetchHyperQuota();
case 'neuralwatt':
return fetchNeuralwattQuota();
case 'xai':
+172
View File
@@ -0,0 +1,172 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildPreferencesFields,
flattenPreferences,
isPerSurfaceSettingsKey,
instancePartOf,
isDeviceSettingsKey,
isProfileSettingsKey,
parsePreferencesDocument,
preferencesFilePathFor,
seedPreferencesFrom,
serializePreferencesDocument,
} from './settings-files';
import { SETTINGS_REGISTRY_FIELDS } from './settings-registry-gate';
const firstKeyWithScope = (scope: string): string => {
const key = Object.keys(SETTINGS_REGISTRY_FIELDS).find((candidate) => SETTINGS_REGISTRY_FIELDS[candidate].scope === scope);
assert.ok(key, `snapshot has a ${scope} key`);
return key;
};
const deviceKey = firstKeyWithScope('device');
describe('parsePreferencesDocument', () => {
test('rejects invalid JSON', () => {
const result = parsePreferencesDocument('{ not json');
assert.equal(result.ok, false);
assert.ok(!result.ok && result.reason.startsWith('invalid JSON'));
});
test('rejects a wrong version', () => {
const result = parsePreferencesDocument(JSON.stringify({ version: 2, fields: {} }));
assert.deepEqual(result, { ok: false, reason: 'not a version-1 preferences document' });
});
test('rejects non-object fields', () => {
assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1, fields: [] })).ok, false);
assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1, fields: 'x' })).ok, false);
assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1 })).ok, false);
});
test('rejects an entry without a value', () => {
const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { themeId: { updatedAt: 5 } } }));
assert.deepEqual(result, { ok: false, reason: 'field "themeId" is not a { value, updatedAt } entry' });
});
test('accepts an empty document and defaults a missing stamp to 0', () => {
assert.deepEqual(parsePreferencesDocument(JSON.stringify({ version: 1, fields: {} })), { ok: true, fields: {} });
const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { themeId: { value: 'nord' } } }));
assert.deepEqual(result, { ok: true, fields: { themeId: { value: 'nord', updatedAt: 0 } } });
});
});
describe('buildPreferencesFields', () => {
const previous = {
themeId: { value: 'nord', updatedAt: 100 },
defaultModel: { value: 'zen/gpt-5', updatedAt: 100 },
darkThemeId: { value: 'dracula', updatedAt: 100 },
};
test('keeps the stamp for unchanged values and restamps changed ones', () => {
const next = buildPreferencesFields(previous, { themeId: 'nord', defaultModel: 'zen/gpt-5-mini', darkThemeId: 'dracula' }, 200);
assert.deepEqual(next, {
themeId: { value: 'nord', updatedAt: 100 },
defaultModel: { value: 'zen/gpt-5-mini', updatedAt: 200 },
darkThemeId: { value: 'dracula', updatedAt: 100 },
});
});
test('compares structurally, so an equal object keeps its stamp', () => {
const before = { themeId: { value: { a: 1, b: [1, 2] }, updatedAt: 7 } };
const next = buildPreferencesFields(before, { themeId: { a: 1, b: [1, 2] } }, 9);
assert.deepEqual(next, before);
});
test('drops profile keys the document no longer carries and ignores non-profile keys', () => {
const next = buildPreferencesFields(previous, { themeId: 'nord', opencodeBinary: '/usr/bin/opencode', [deviceKey]: '#fff', unknownKey: 1 }, 200);
assert.deepEqual(next, { themeId: { value: 'nord', updatedAt: 100 } });
});
test('skips undefined values', () => {
assert.deepEqual(buildPreferencesFields({}, { themeId: undefined }, 1), {});
});
});
describe('instancePartOf', () => {
test('excludes profile keys and keeps instance and unknown legacy keys', () => {
const document = { themeId: 'nord', defaultModel: 'x', opencodeBinary: '/bin/oc', legacyKey: true, dropped: undefined };
assert.deepEqual(instancePartOf(document), { opencodeBinary: '/bin/oc', legacyKey: true });
});
});
describe('scope helpers', () => {
test('classify keys by the checked-in registry snapshot', () => {
assert.equal(isProfileSettingsKey('themeId'), true);
assert.equal(isProfileSettingsKey('opencodeBinary'), false);
assert.equal(isDeviceSettingsKey(deviceKey), true);
assert.equal(isDeviceSettingsKey('themeId'), false);
assert.equal(isProfileSettingsKey('constructor'), false);
assert.equal(isProfileSettingsKey('nope'), false);
});
test('preferences.json sits beside settings.json', () => {
assert.equal(preferencesFilePathFor('/home/u/.config/openchamber/settings.json'), '/home/u/.config/openchamber/preferences.json');
});
});
describe('round trip', () => {
test('serialize then parse yields the same fields, and flatten yields the values', () => {
const fields = seedPreferencesFrom({ themeId: 'nord', defaultModel: 'zen/gpt-5', opencodeBinary: '/bin/oc' }, 42);
assert.deepEqual(fields, {
themeId: { value: 'nord', updatedAt: 42 },
defaultModel: { value: 'zen/gpt-5', updatedAt: 42 },
});
const text = serializePreferencesDocument(fields);
assert.ok(text.startsWith('{\n "version": 1,\n "fields": {'));
const parsed = parsePreferencesDocument(text);
assert.deepEqual(parsed, { ok: true, fields });
assert.deepEqual(flattenPreferences(fields), { themeId: 'nord', defaultModel: 'zen/gpt-5' });
});
});
describe('per-surface keys', () => {
const perSurfaceKey = Object.keys(SETTINGS_REGISTRY_FIELDS).find((key) => SETTINGS_REGISTRY_FIELDS[key].perSurface === true);
const plainProfileKey = Object.keys(SETTINGS_REGISTRY_FIELDS).find(
(key) => SETTINGS_REGISTRY_FIELDS[key].scope === 'profile' && SETTINGS_REGISTRY_FIELDS[key].perSurface !== true,
);
test('the snapshot names at least one per-surface profile key', () => {
assert.ok(perSurfaceKey && isPerSurfaceSettingsKey(perSurfaceKey));
assert.ok(plainProfileKey && !isPerSurfaceSettingsKey(plainProfileKey));
});
test('a surface write lands under the surface and leaves the base as it was', () => {
assert.ok(perSurfaceKey && plainProfileKey);
const previous = { [perSurfaceKey]: { value: 'base', updatedAt: 1 } };
const next = buildPreferencesFields(previous, { [perSurfaceKey]: 'mine', [plainProfileKey]: 'shared' }, 5, {
surface: 'vscode',
changedKeys: [perSurfaceKey, plainProfileKey],
});
assert.deepEqual(next[perSurfaceKey], { value: 'base', updatedAt: 1, surfaces: { vscode: { value: 'mine', updatedAt: 5 } } });
assert.deepEqual(next[plainProfileKey], { value: 'shared', updatedAt: 5 });
assert.equal(flattenPreferences(next, 'vscode')[perSurfaceKey], 'mine');
assert.equal(flattenPreferences(next, 'mobile')[perSurfaceKey], 'base');
assert.equal(flattenPreferences(next)[perSurfaceKey], 'base');
});
test('a per-surface key the write did not change keeps its whole entry', () => {
assert.ok(perSurfaceKey && plainProfileKey);
const previous = { [perSurfaceKey]: { value: 'base', updatedAt: 1, surfaces: { mobile: { value: 'phone', updatedAt: 2 } } } };
const next = buildPreferencesFields(previous, { [perSurfaceKey]: 'base', [plainProfileKey]: 'x' }, 9, {
surface: 'vscode',
changedKeys: [plainProfileKey],
});
assert.deepEqual(next[perSurfaceKey], previous[perSurfaceKey]);
});
test('a per-surface key first set from one surface has no base', () => {
assert.ok(perSurfaceKey);
const next = buildPreferencesFields({}, { [perSurfaceKey]: 'mine' }, 3, { surface: 'vscode', changedKeys: [perSurfaceKey] });
assert.equal('value' in next[perSurfaceKey], false);
assert.deepEqual(next[perSurfaceKey].surfaces, { vscode: { value: 'mine', updatedAt: 3 } });
const parsed = parsePreferencesDocument(serializePreferencesDocument(next));
assert.ok(parsed.ok);
assert.equal(flattenPreferences(parsed.fields, 'mobile')[perSurfaceKey], undefined);
});
test('rejects an unknown surface in the file', () => {
const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { x: { surfaces: { toaster: { value: 1 } } } } }));
assert.equal(result.ok, false);
});
});
+201
View File
@@ -0,0 +1,201 @@
// 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.
//
// Mirrors the server implementation in
// `packages/web/server/lib/opencode/settings-files.js`; both sides must write
// byte-compatible files, so keep format changes in sync.
//
// Kept free of `vscode` imports so it is unit-tested directly.
import * as path from 'path';
import { SETTINGS_REGISTRY_FIELDS } from './settings-registry-gate';
const PREFERENCES_FILE_NAME = 'preferences.json';
const PREFERENCES_DOCUMENT_VERSION = 1;
type SettingsSurface = 'web' | 'desktop' | 'vscode' | 'mobile';
const SETTINGS_SURFACES: readonly SettingsSurface[] = ['web', 'desktop', 'vscode', 'mobile'];
// SAFETY: widening the tuple to `readonly string[]` only for the membership test; the guard's result is what narrows.
const isSettingsSurface = (value: string): value is SettingsSurface => (SETTINGS_SURFACES as readonly string[]).includes(value);
/** The extension host is always the VS Code surface kind. */
export const VSCODE_SETTINGS_SURFACE: SettingsSurface = 'vscode';
// Boundary parser: values are whatever JSON the file (or the webview) carries.
type SurfaceValue = { value: unknown; updatedAt: number };
// The base value is optional: a per-surface key first set from one surface kind has none.
type PreferenceField = { value?: unknown; updatedAt: number; surfaces?: Partial<Record<SettingsSurface, SurfaceValue>> };
export type PreferenceFields = Record<string, PreferenceField>;
type ParsedPreferencesDocument =
| { ok: true; fields: PreferenceFields }
| { ok: false; reason: string };
/** The registry scope for a key, or `null` when the registry does not know it. */
const getSettingsScope = (key: string): string | null =>
Object.prototype.hasOwnProperty.call(SETTINGS_REGISTRY_FIELDS, key) ? SETTINGS_REGISTRY_FIELDS[key].scope : null;
export const isProfileSettingsKey = (key: string): boolean => getSettingsScope(key) === 'profile';
export const isDeviceSettingsKey = (key: string): boolean => getSettingsScope(key) === 'device';
/** Profile keys the owner chose to store per surface kind. */
export const isPerSurfaceSettingsKey = (key: string): boolean =>
Object.prototype.hasOwnProperty.call(SETTINGS_REGISTRY_FIELDS, key) && SETTINGS_REGISTRY_FIELDS[key].perSurface === true;
export const preferencesFilePathFor = (settingsFilePath: string): string =>
path.join(path.dirname(settingsFilePath), PREFERENCES_FILE_NAME);
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const parseStamp = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
const sameValue = (left: unknown, right: unknown): boolean => {
if (left === right) return true;
if (left === undefined || right === undefined) return false;
return JSON.stringify(left) === JSON.stringify(right);
};
/**
* 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.
*/
export const parsePreferencesDocument = (raw: string): ParsedPreferencesDocument => {
let parsed: unknown;
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: PreferenceFields = {};
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: PreferenceField = { updatedAt: parseStamp(entry.updatedAt) };
if ('value' in entry) next.value = entry.value;
if (isPlainObject(entry.surfaces)) {
const surfaces: Partial<Record<SettingsSurface, SurfaceValue>> = {};
for (const [surface, surfaceEntry] of Object.entries(entry.surfaces)) {
if (!isSettingsSurface(surface) || !isPlainObject(surfaceEntry) || !('value' in surfaceEntry)) {
return { ok: false, reason: `field "${key}" has an invalid surface entry "${surface}"` };
}
surfaces[surface] = { value: surfaceEntry.value, updatedAt: parseStamp(surfaceEntry.updatedAt) };
}
next.surfaces = surfaces;
}
fields[key] = next;
}
return { ok: true, fields };
};
export const serializePreferencesDocument = (fields: PreferenceFields): string =>
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 webview keeps what it holds, or its default).
*/
export const flattenPreferences = (fields: PreferenceFields, surface: SettingsSurface | null = null): Record<string, unknown> => {
const values: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(fields)) {
const own = surface ? 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).
*/
export const buildPreferencesFields = (
previousFields: PreferenceFields,
document: Record<string, unknown>,
now: number,
options: { surface?: SettingsSurface | null; changedKeys?: Iterable<string> | null } = {},
): PreferenceFields => {
const surface = options.surface ?? null;
const changed = options.changedKeys ? new Set(options.changedKeys) : null;
const fields: PreferenceFields = {};
for (const [key, value] of Object.entries(document)) {
if (value === undefined || !isProfileSettingsKey(key)) continue;
const previous = previousFields[key];
// Per-surface keys: a surface's write lands under its own entry and leaves
// the base as it was; a key the write did not change keeps its whole entry
// (the document only carries this surface's resolved view of it).
if (surface && isPerSurfaceSettingsKey(key)) {
if (changed && !changed.has(key)) {
if (previous) fields[key] = previous;
continue;
}
const previousOwn = previous?.surfaces?.[surface];
const own: SurfaceValue = 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 are already filtered by the registry
* gate on the write path; ones older builds persisted stay in place.
*/
export const instancePartOf = (document: Record<string, unknown>): Record<string, unknown> => {
const instance: Record<string, unknown> = {};
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, as they would seed a fresh preferences file. */
/** The profile keys of a document (the part `instancePartOf` leaves out). */
export const profilePartOf = (document: Record<string, unknown>): Record<string, unknown> => {
const profile: Record<string, unknown> = {};
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, so a build from before the split (which reads
* only this file) still finds the user's preferences. Current builds ignore
* the copy: `preferences.json` wins in the merged read.
*/
export const legacySettingsDocumentOf = (
document: Record<string, unknown>,
preferenceFields: PreferenceFields,
): Record<string, unknown> => ({
...instancePartOf(document),
...flattenPreferences(preferenceFields),
});
export const seedPreferencesFrom = (document: Record<string, unknown>, now: number): PreferenceFields =>
buildPreferencesFields({}, document, now);
@@ -0,0 +1,72 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { SETTINGS_REGISTRY_FIELDS, filterPersistableSettingsChanges, withoutSecretSettings, type SettingsRegistryGateFields } from './settings-registry-gate';
const fields: SettingsRegistryGateFields = {
themeId: { scope: 'profile' },
smallModelOverride: { scope: 'profile' },
hasDesktopSettings: { scope: 'instance', computed: true },
sidebarWidth: { scope: 'device', local: true },
windowBounds: { scope: 'instance', owner: 'desktop-shell' },
desktopUiPassword: { scope: 'instance', secret: true },
};
describe('withoutSecretSettings', () => {
test('withholds secret keys and keeps everything else', () => {
assert.deepEqual(withoutSecretSettings({ desktopUiPassword: 'pw', themeId: 'a' }, fields), { themeId: 'a' });
});
test('the real registry marks the UI password and tunnel tokens secret', () => {
const stripped = withoutSecretSettings({
desktopUiPassword: 'pw',
managedRemoteTunnelToken: 't',
managedRemoteTunnelPresetTokens: { a: 't' },
themeId: 'a',
}, SETTINGS_REGISTRY_FIELDS);
assert.deepEqual(stripped, { themeId: 'a' });
});
});
describe('filterPersistableSettingsChanges', () => {
test('keeps stored shared fields and preserves their values as sent', () => {
const result = filterPersistableSettingsChanges(
{ themeId: 'nord', smallModelOverride: '', unrelated: 1 },
fields,
);
assert.deepEqual(result, { themeId: 'nord', smallModelOverride: '' });
});
test('drops keys the registry does not know', () => {
assert.deepEqual(filterPersistableSettingsChanges({ gitProviderId: 'zen', gitModelId: 'x' }, fields), {});
});
test('drops computed, local, and desktop-shell owned keys', () => {
const result = filterPersistableSettingsChanges(
{ hasDesktopSettings: true, sidebarWidth: 320, windowBounds: { x: 0 }, themeId: 'a' },
fields,
);
assert.deepEqual(result, { themeId: 'a' });
});
test('ignores prototype keys that are not registry fields', () => {
assert.deepEqual(filterPersistableSettingsChanges({ constructor: 'x', toString: 'y' }, fields), {});
});
test('the checked-in snapshot drops derived-at-read and desktop-shell keys but keeps profile settings', () => {
const result = filterPersistableSettingsChanges({
themeId: 'nord',
smallModelUseDefault: false,
smallModelOverride: 'zen/gpt-5-nano',
gitProviderId: 'zen',
gitModelId: 'gpt-5-nano',
});
assert.deepEqual(result, { themeId: 'nord', smallModelUseDefault: false, smallModelOverride: 'zen/gpt-5-nano' });
const computedKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.computed).map(([key]) => key);
const localKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.local).map(([key]) => key);
const shellKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.owner === 'desktop-shell').map(([key]) => key);
assert.ok(computedKeys.length > 0 && localKeys.length > 0 && shellKeys.length > 0, 'snapshot exercises every gate branch');
const blocked = Object.fromEntries([...computedKeys, ...localKeys, ...shellKeys].map((key) => [key, 'value']));
assert.deepEqual(filterPersistableSettingsChanges(blocked), {});
});
});
@@ -0,0 +1,60 @@
// Gate for the bridge's settings write path. The generated registry snapshot
// (`settings-registry.json`, produced from the UI package's settings registry)
// names every key OpenChamber persists; anything else the webview sends is
// dropped here so the shared settings file never grows keys the rest of the
// product does not know about.
//
// Kept free of `vscode` imports so it is unit-tested directly.
import registrySnapshot from './settings-registry.json';
type SettingsRegistryGateField = {
scope: string;
perSurface?: boolean;
computed?: boolean;
secret?: boolean;
local?: boolean;
owner?: string;
};
export type SettingsRegistryGateFields = Record<string, SettingsRegistryGateField>;
export const SETTINGS_REGISTRY_FIELDS: SettingsRegistryGateFields = registrySnapshot.fields;
/**
* A key is persistable through the bridge only when the registry lists it as a
* stored, shared field: not computed at read time, not local to one webview's
* store, and not owned by the desktop shell (which keeps its own values).
*/
const isPersistableField = (field: SettingsRegistryGateField | undefined): boolean => {
if (!field) return false;
if (field.computed === true) return false;
if (field.local === true) return false;
if (field.owner === 'desktop-shell') return false;
return true;
};
export const filterPersistableSettingsChanges = (
changes: Record<string, unknown>,
fields: SettingsRegistryGateFields = SETTINGS_REGISTRY_FIELDS,
): Record<string, unknown> => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(changes)) {
if (!Object.prototype.hasOwnProperty.call(fields, key)) continue;
if (!isPersistableField(fields[key])) continue;
next[key] = value;
}
return next;
};
/** Drop the keys the registry marks `secret`: accepted on write, never handed back to a webview. */
export const withoutSecretSettings = (
settings: Record<string, unknown>,
fields: SettingsRegistryGateFields = SETTINGS_REGISTRY_FIELDS,
): Record<string, unknown> => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(settings)) {
if (fields[key]?.secret === true) continue;
next[key] = value;
}
return next;
};
+747
View File
@@ -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"
]
}
}
}