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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user