Merge branch 'main' into reproduce/issue-1720

Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
Mayuresh K
2026-08-05 10:27:32 +01:00
committed by GitHub
1402 changed files with 157643 additions and 52975 deletions
@@ -140,6 +140,14 @@ export class AgentManagerPanelProvider {
});
}
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
this._panel?.webview.postMessage({
type: 'command',
command: 'permissionAutoAcceptSynced',
payload: snapshot,
});
}
public notifyWindowFocusChanged(focused: boolean): void {
if (!this._panel) {
return;
@@ -177,7 +185,7 @@ export class AgentManagerPanelProvider {
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
const { id, type, payload } = message;
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
if (!this._openCodeManager) {
@@ -189,8 +197,11 @@ export class AgentManagerPanelProvider {
};
}
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
? requestedStreamId
: `sse_${++this._sseCounter}_${Date.now()}`;
const controller = new AbortController();
this._sseStreams.set(streamId, controller);
try {
const start = await openSseProxy({
@@ -203,8 +214,6 @@ export class AgentManagerPanelProvider {
},
});
this._sseStreams.set(streamId, controller);
start.run
.then(() => {
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId });
@@ -230,6 +239,7 @@ export class AgentManagerPanelProvider {
},
};
} catch (error) {
this._sseStreams.delete(streamId);
const message = error instanceof Error ? error.message : String(error);
return {
id,
+14 -4
View File
@@ -318,6 +318,14 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
});
}
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
this._view?.webview.postMessage({
type: 'command',
command: 'permissionAutoAcceptSynced',
payload: snapshot,
});
}
/**
* Ask the webview to run the full OpenCode reload flow (overlay + managed
* restart via the bridge + config/data refresh) — the same flow used after an
@@ -528,7 +536,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
const { id, type, payload } = message;
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
if (!this._openCodeManager) {
@@ -540,8 +548,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
};
}
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
? requestedStreamId
: `sse_${++this._sseCounter}_${Date.now()}`;
const controller = new AbortController();
this._sseStreams.set(streamId, { controller, view: this._view });
try {
const start = await openSseProxy({
@@ -554,8 +565,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
},
});
this._sseStreams.set(streamId, { controller, view: this._view });
start.run
.then(() => {
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
@@ -581,6 +590,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
},
};
} catch (error) {
this._sseStreams.delete(streamId);
const message = error instanceof Error ? error.message : String(error);
return {
id,
+18
View File
@@ -21,6 +21,11 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- `bridge-git-process-runtime.ts`
- Git process execution and environment setup (`execGit`), including SSH agent socket resolution.
- `gitService.ts`
- Owns VS Code Git and worktree operations.
- Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`.
- Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state.
- `bridge-fs-runtime.ts`
- Bridge handlers for filesystem-related message routes.
- Uses shared FS helpers via injected dependencies.
@@ -34,11 +39,15 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- dropped-file parsing and attachment reading
- models metadata fetch helper
The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`.
- `bridge-localfs-proxy-runtime.ts`
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
- `bridge-proxy-runtime.ts`
- Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies.
- SSE routes are intentionally excluded from the generic proxy and use `sseProxy.ts`, whose upstream-only stall watchdog closes a quiet OpenCode stream so the webview can reconnect instead of trusting an open but silent response.
- The webview allocates each SSE stream ID and installs its listener before requesting the upstream stream, so immediate OpenCode replay events cannot race the bridge start response.
- `bridge-config-runtime.ts`
- Config and skills message handlers (`api:config/*`).
@@ -51,6 +60,15 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- System/editor/provider/quota/notification/update-check message handlers.
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
- 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-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
- `opencode-upgrade-runtime.ts`
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
- `bridge-permission-auto-accept-runtime.ts`
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
- Serializes reads and read-modify-write updates, persists a monotonic policy revision, and broadcasts the exact committed snapshot to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
## Extension guideline
@@ -199,6 +199,16 @@ export class SessionEditorPanelProvider {
}
}
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
for (const entry of this._panels.values()) {
entry.panel.webview.postMessage({
type: 'command',
command: 'permissionAutoAcceptSynced',
payload: snapshot,
});
}
}
public notifyWindowFocusChanged(focused: boolean): void {
for (const entry of this._panels.values()) {
entry.panel.webview.postMessage({
@@ -405,7 +415,7 @@ export class SessionEditorPanelProvider {
private async _startSseProxy(message: BridgeRequest, entry: SessionPanelState): Promise<BridgeResponse> {
const { id, type, payload } = message;
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
if (!this._openCodeManager) {
@@ -417,8 +427,11 @@ export class SessionEditorPanelProvider {
};
}
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
? requestedStreamId
: `sse_${++this._sseCounter}_${Date.now()}`;
const controller = new AbortController();
entry.sseStreams.set(streamId, controller);
try {
const start = await openSseProxy({
@@ -427,20 +440,19 @@ export class SessionEditorPanelProvider {
headers: this._buildSseHeaders(headers),
signal: controller.signal,
onChunk: (chunk) => {
entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk });
// Panel may be disposed before SSE callbacks fire.
entry.panel?.webview?.postMessage({ type: 'api:sse:chunk', streamId, chunk });
},
});
entry.sseStreams.set(streamId, controller);
start.run
.then(() => {
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId });
entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId });
})
.catch((error) => {
if (!controller.signal.aborted) {
const messageText = error instanceof Error ? error.message : String(error);
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText });
entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId, error: messageText });
}
})
.finally(() => {
@@ -458,6 +470,7 @@ export class SessionEditorPanelProvider {
},
};
} catch (error) {
entry.sseStreams.delete(streamId);
const messageText = error instanceof Error ? error.message : String(error);
return {
id,
+35 -1
View File
@@ -25,6 +25,8 @@ import {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -652,7 +654,21 @@ export async function handleConfigBridgeMessage(
if (!name && normalizedMethod === 'GET') {
const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory);
return { id, type, success: true, data: { skills } };
return {
id,
type,
success: true,
data: {
skills: skills.map((skill) => ({
...skill,
renamable: Boolean(
skill.path
&& skill.path !== '<built-in>'
&& isManagedSkillPath(skill.path, workingDirectory)
),
})),
},
};
}
const skillName = typeof name === 'string' ? name.trim() : '';
@@ -693,6 +709,24 @@ export async function handleConfigBridgeMessage(
}
if (normalizedMethod === 'PATCH') {
if (typeof body?.renameTo === 'string') {
const newName = body.renameTo.trim();
renameSkill(skillName, newName, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
name: newName,
requiresReload: true,
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
await ctx?.manager?.restart();
return {
@@ -4,7 +4,7 @@ import * as path from 'path';
import * as vscode from 'vscode';
import { execGit } from './bridge-git-process-runtime';
const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024;
const MAX_FILE_ATTACH_SIZE_BYTES = 20 * 1024 * 1024;
const createGitCheckIgnoreTimeoutMs = () => {
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
@@ -99,7 +99,7 @@ export const readUriAsAttachment = async (
const size = stat.size ?? 0;
if (size > MAX_FILE_ATTACH_SIZE_BYTES) {
return { skipped: { name, reason: 'File exceeds 10MB limit' } };
return { skipped: { name, reason: 'File exceeds 20MB limit' } };
}
const bytes = await vscode.workspace.fs.readFile(uri);
+6 -1
View File
@@ -481,7 +481,11 @@ export async function handleFsBridgeMessage(
}
case 'api:files/pick': {
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
const options = payload as { allowMany?: boolean; extensions?: unknown };
const allowMany = options?.allowMany !== false;
const extensions = Array.isArray(options?.extensions)
? options.extensions.filter((extension): extension is string => typeof extension === 'string' && extension.length > 0)
: [];
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
const picks = await vscode.window.showOpenDialog({
@@ -490,6 +494,7 @@ export async function handleFsBridgeMessage(
canSelectMany: allowMany,
defaultUri,
openLabel: 'Attach',
filters: extensions.length > 0 ? { Files: extensions } : undefined,
});
if (!picks || picks.length === 0) {
@@ -7,6 +7,8 @@ const gitService = {
cherryPick: mock(),
revertCommit: mock(),
resetToCommit: mock(),
createWorktree: mock(),
getWorktreeBootstrapStatus: mock(),
};
mock.module('./gitService', () => gitService);
@@ -21,6 +23,8 @@ describe('bridge git runtime index mutations', () => {
gitService.cherryPick.mockReset();
gitService.revertCommit.mockReset();
gitService.resetToCommit.mockReset();
gitService.createWorktree.mockReset();
gitService.getWorktreeBootstrapStatus.mockReset();
});
it('accepts legacy stage path payloads', async () => {
@@ -109,4 +113,66 @@ describe('bridge git runtime index mutations', () => {
expect(gitService.revertCommit).not.toHaveBeenCalled();
expect(gitService.resetToCommit).not.toHaveBeenCalled();
});
it('preserves bootstrap phases in status responses', async () => {
const bootstrapStatus = {
status: 'pending',
phase: 'git-ready',
error: null,
updatedAt: 123,
};
gitService.getWorktreeBootstrapStatus.mockResolvedValue(bootstrapStatus);
const response = await handleStandardGitBridgeMessage({
id: 'bootstrap-status',
type: 'api:git/worktrees/bootstrap-status',
payload: { directory: '/repo-worktree' },
});
expect(response).toEqual({
id: 'bootstrap-status',
type: 'api:git/worktrees/bootstrap-status',
success: true,
data: bootstrapStatus,
});
expect(gitService.getWorktreeBootstrapStatus).toHaveBeenCalledWith('/repo-worktree');
});
it('preserves the directory-created phase in fast create responses', async () => {
const created = {
head: '',
name: 'feature',
branch: 'openchamber/feature',
path: '/repo-worktree',
directoryCreated: true,
bootstrapStatus: {
status: 'pending',
phase: 'directory-created',
error: null,
updatedAt: 123,
},
};
gitService.createWorktree.mockResolvedValue(created);
const response = await handleStandardGitBridgeMessage({
id: 'create-worktree',
type: 'api:git/worktrees',
payload: {
directory: '/repo',
method: 'POST',
worktreeName: 'feature',
returnAfterDirectoryCreated: true,
},
});
expect(response).toEqual({
id: 'create-worktree',
type: 'api:git/worktrees',
success: true,
data: created,
});
expect(gitService.createWorktree).toHaveBeenCalledWith('/repo', expect.objectContaining({
returnAfterDirectoryCreated: true,
}));
});
});
@@ -0,0 +1,70 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
const createContext = () => {
const values = new Map<string, unknown>();
return {
globalState: {
get: (key: string) => values.get(key),
update: async (key: string, value: unknown) => { values.set(key, value); },
},
};
};
describe('VS Code permission auto-accept policy bridge', () => {
test('persists policy and broadcasts the authoritative snapshot', async () => {
const context = createContext();
const broadcasts: unknown[] = [];
const dependencies = { broadcast: async (snapshot: unknown) => { broadcasts.push(snapshot); } };
const response = await handlePermissionAutoAcceptBridgeMessage({
id: '1',
type: 'api:permission-auto-accept:set',
payload: { sessionId: 'root', enabled: true },
}, context, dependencies);
assert.equal(response?.success, true);
assert.deepEqual(response?.data, { sessions: { root: true }, revision: 1 });
assert.deepEqual(broadcasts, [{ sessions: { root: true }, revision: 1 }]);
const reloaded = await handlePermissionAutoAcceptBridgeMessage({
id: '2',
type: 'api:permission-auto-accept:get',
}, context, dependencies);
assert.deepEqual(reloaded?.data, { sessions: { root: true }, revision: 1 });
});
test('serializes concurrent writes without losing policy entries', async () => {
const context = createContext();
const dependencies = { broadcast: async () => undefined };
const first = handlePermissionAutoAcceptBridgeMessage({
id: '1',
type: 'api:permission-auto-accept:set',
payload: { sessionId: 'root', enabled: true },
}, context, dependencies);
const second = handlePermissionAutoAcceptBridgeMessage({
id: '2',
type: 'api:permission-auto-accept:set',
payload: { sessionId: 'child', enabled: false },
}, context, dependencies);
await Promise.all([first, second]);
const reloaded = await handlePermissionAutoAcceptBridgeMessage({
id: '3',
type: 'api:permission-auto-accept:get',
}, context, dependencies);
assert.deepEqual(reloaded?.data, { sessions: { root: true, child: false }, revision: 2 });
});
test('rejects malformed policy writes', async () => {
const broadcasts: unknown[] = [];
const response = await handlePermissionAutoAcceptBridgeMessage({
id: '1',
type: 'api:permission-auto-accept:set',
payload: { sessionId: 'root', enabled: 'yes' },
}, createContext(), { broadcast: async (snapshot) => { broadcasts.push(snapshot); } });
assert.equal(response?.success, false);
assert.deepEqual(broadcasts, []);
});
});
@@ -0,0 +1,103 @@
const STORAGE_KEY = 'permissionAutoAccept';
type PolicyContext = {
globalState: {
get: (key: string) => unknown;
update: (key: string, value: unknown) => PromiseLike<void>;
};
};
export type PermissionAutoAcceptSnapshot = {
sessions: Record<string, boolean>;
revision: number;
};
const normalizeSnapshot = (value: unknown): PermissionAutoAcceptSnapshot => {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as { sessions?: unknown }
: {};
const entries = source.sessions && typeof source.sessions === 'object' && !Array.isArray(source.sessions)
? Object.entries(source.sessions)
: [];
const sessions: Record<string, boolean> = {};
for (const [sessionId, enabled] of entries) {
if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled;
}
const revision = Number.isSafeInteger((source as { revision?: unknown }).revision)
&& Number((source as { revision?: unknown }).revision) >= 0
? Number((source as { revision?: unknown }).revision)
: 0;
return { sessions, revision };
};
const readPermissionAutoAcceptPolicy = (context: PolicyContext) =>
normalizeSnapshot(context.globalState.get(STORAGE_KEY));
const operationQueues = new WeakMap<object, Promise<void>>();
const serialize = async <T>(context: PolicyContext, operation: () => Promise<T>): Promise<T> => {
const owner = context.globalState as object;
const previous = operationQueues.get(owner) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => { release = resolve; });
const queued = previous.catch(() => undefined).then(() => current);
operationQueues.set(owner, queued);
await previous.catch(() => undefined);
try {
return await operation();
} finally {
release();
if (operationQueues.get(owner) === queued) operationQueues.delete(owner);
}
};
async function setPermissionAutoAcceptPolicy(
context: PolicyContext,
sessionId: string,
enabled: boolean,
broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike<unknown>,
) {
return serialize(context, async () => {
const current = readPermissionAutoAcceptPolicy(context);
const snapshot = {
sessions: { ...current.sessions, [sessionId]: enabled },
revision: current.revision + 1,
};
await context.globalState.update(STORAGE_KEY, snapshot);
await broadcast(snapshot);
return snapshot;
});
}
export async function handlePermissionAutoAcceptBridgeMessage(
message: { id: string; type: string; payload?: unknown },
context?: PolicyContext,
dependencies?: { broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike<unknown> },
) {
if (message.type !== 'api:permission-auto-accept:get' && message.type !== 'api:permission-auto-accept:set') {
return null;
}
if (!context) return { id: message.id, type: message.type, success: false, error: 'Extension context is unavailable' };
if (message.type === 'api:permission-auto-accept:get') {
const snapshot = await serialize(context, async () => readPermissionAutoAcceptPolicy(context));
return { id: message.id, type: message.type, success: true, data: snapshot };
}
const payload = message.payload && typeof message.payload === 'object'
? message.payload as { sessionId?: unknown; enabled?: unknown }
: {};
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
if (!sessionId) return { id: message.id, type: message.type, success: false, error: 'sessionId is required' };
if (typeof payload.enabled !== 'boolean') {
return { id: message.id, type: message.type, success: false, error: 'enabled must be a boolean' };
}
const snapshot = await setPermissionAutoAcceptPolicy(
context,
sessionId,
payload.enabled,
dependencies?.broadcast ?? (() => Promise.resolve()),
);
return { id: message.id, type: message.type, success: true, data: snapshot };
}
+28 -1
View File
@@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
const keysToClear = new Set<string>();
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) {
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride', 'walkthroughModelOverride']) {
const value = restChanges[key];
if (typeof value === 'string' && value.trim().length === 0) {
keysToClear.add(key);
@@ -299,6 +299,33 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
}
}
if ('smallModelUseDefault' in restChanges && typeof restChanges.smallModelUseDefault !== 'boolean') {
delete restChanges.smallModelUseDefault;
}
if ('sessionRecapEnabled' in restChanges && typeof restChanges.sessionRecapEnabled !== 'boolean') {
delete restChanges.sessionRecapEnabled;
}
if ('sessionSuggestionEnabled' in restChanges && typeof restChanges.sessionSuggestionEnabled !== 'boolean') {
delete restChanges.sessionSuggestionEnabled;
}
if ('sessionGoalEnabled' in restChanges && typeof restChanges.sessionGoalEnabled !== 'boolean') {
delete restChanges.sessionGoalEnabled;
}
if ('sessionGoalDefaultBudgetEnabled' in restChanges && typeof restChanges.sessionGoalDefaultBudgetEnabled !== 'boolean') {
delete restChanges.sessionGoalDefaultBudgetEnabled;
}
if ('sessionGoalDefaultBudget' in restChanges) {
const budget = restChanges.sessionGoalDefaultBudget;
if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
delete restChanges.sessionGoalDefaultBudget;
}
}
if (typeof restChanges.usageAutoRefresh !== 'boolean') {
delete restChanges.usageAutoRefresh;
}
+109 -14
View File
@@ -3,10 +3,13 @@ import * as os from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import { randomUUID } from 'crypto';
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
import { removeProviderConfig, getProviderSources, upsertProviderConfig } from './opencodeConfig';
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
import type { BridgeContext, BridgeResponse } from './bridge';
type BridgeMessageInput = {
@@ -74,10 +77,13 @@ const getOrCreateInstallId = (scope: string): string => {
return installId;
};
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => {
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'android' | 'ios' | 'web' => {
// The webview already sends API-shaped values; Node's os.platform() is the fallback source.
if (value === 'macos' || value === 'windows' || value === 'linux' || value === 'android' || value === 'ios' || value === 'web') {
return value;
}
if (value === 'darwin') return 'macos';
if (value === 'win32') return 'windows';
if (value === 'linux') return 'linux';
return 'web';
};
@@ -267,6 +273,15 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:opencode/upgrade-status': {
return { id, type, success: true, data: await getOpenCodeUpgradeStatus(ctx?.manager) };
}
case 'api:opencode/upgrade': {
const target = (payload as { target?: unknown } | undefined)?.target;
return { id, type, success: true, data: await upgradeManagedOpenCode(ctx?.manager, target) };
}
case 'api:session-activity:get': {
return { id, type, success: true, data: getSessionActivitySnapshot() };
}
@@ -303,7 +318,6 @@ export async function handleSystemBridgeMessage(
: os.arch();
const reportUsage = body.reportUsage !== false;
const installId = getOrCreateInstallId('vscode');
const requestBody = {
appType: 'vscode',
deviceClass,
@@ -311,7 +325,7 @@ export async function handleSystemBridgeMessage(
arch: mapNodeArchToApiArch(archRaw),
channel: 'stable',
currentVersion,
installId,
...(reportUsage ? { installId: getOrCreateInstallId('vscode') } : {}),
instanceMode,
reportUsage,
};
@@ -471,6 +485,64 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:provider:upsert': {
const {
providerID,
providerId: providerIdAlias,
config,
scope,
directory,
} = (payload || {}) as {
providerID?: string;
providerId?: string;
config?: unknown;
scope?: string;
directory?: string;
};
const providerId = (typeof providerID === 'string' && providerID.trim())
|| (typeof providerIdAlias === 'string' && providerIdAlias.trim())
|| '';
if (!providerId) {
return { id, type, success: false, error: 'Provider ID is required' };
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return { id, type, success: false, error: 'Provider config is required' };
}
const normalizedScope = typeof scope === 'string' ? scope : 'user';
if (normalizedScope !== 'user' && normalizedScope !== 'project' && normalizedScope !== 'custom') {
return { id, type, success: false, error: 'Invalid scope' };
}
try {
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
? directory.trim()
: ctx?.manager?.getWorkingDirectory();
const result = upsertProviderConfig(
providerId,
config,
workingDirectory,
normalizedScope,
{ hasStoredAuth: Boolean(getProviderAuth(providerId)) },
);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
providerId: result.providerId,
path: result.path,
config: result.config,
requiresReload: true,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { id, type, success: false, error: errorMessage };
}
}
case 'api:quota:providers': {
try {
const providers = listConfiguredQuotaProviders();
@@ -481,6 +553,38 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:quota:credentials': {
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
try {
if (!providerId || !['opencode-go', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
if (method === 'IMPORT') {
if (providerId !== 'cursor') return { id, type, success: false, error: 'Import unavailable' };
const credential = importCursorCredential();
await validateCredential(providerId, credential);
return { id, type, success: true, data: writeCredential(providerId, credential) };
}
if (method === 'PUT') {
const credential = normalizeCredential(providerId, input);
if (!credential) return { id, type, success: false, error: 'Invalid credential' };
if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string });
else await validateCredential(providerId, credential);
return { id, type, success: true, data: writeCredential(providerId, credential) };
}
if (method === 'VALIDATE') {
const credential = readCredential(providerId);
if (!credential) return { id, type, success: false, error: 'Not configured' };
if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string });
else await validateCredential(providerId, credential);
return { id, type, success: true, data: { valid: true } };
}
return { id, type, success: false, error: 'Unsupported method' };
} catch (error) {
return { id, type, success: false, error: error instanceof Error ? error.message : String(error) };
}
}
case 'api:quota:get': {
const { providerId } = (payload || {}) as { providerId?: string };
if (!providerId) {
@@ -524,15 +628,6 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:notifications/auto-accept': {
const request = (payload || {}) as { sessionId?: unknown; enabled?: unknown };
const sessionId = typeof request.sessionId === 'string' ? request.sessionId.trim() : '';
if (!sessionId) {
return { id, type, success: false, error: 'sessionId is required' };
}
return { id, type, success: true, data: { success: true } };
}
default:
return null;
}
+13
View File
@@ -6,6 +6,7 @@ import { handleFsBridgeMessage } from './bridge-fs-runtime';
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 {
fetchOpenCodeSkillsFromApi,
persistSettings,
@@ -63,6 +64,18 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
const { id, type, payload } = message;
try {
const permissionAutoAcceptResponse = await handlePermissionAutoAcceptBridgeMessage(
{ id, type, payload },
ctx?.context,
{
broadcast: (snapshot) => vscode.commands.executeCommand(
'openchamber.internal.permissionAutoAcceptSynced',
snapshot,
),
},
);
if (permissionAutoAcceptResponse) return permissionAutoAcceptResponse;
const standardGitResponse = await handleStandardGitBridgeMessage({ id, type, payload });
if (standardGitResponse) {
return standardGitResponse;
+11 -2
View File
@@ -200,6 +200,14 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.internal.permissionAutoAcceptSynced', (snapshot: unknown) => {
chatViewProvider?.notifyPermissionAutoAcceptSynced(snapshot);
sessionEditorProvider?.notifyPermissionAutoAcceptSynced(snapshot);
agentManagerProvider?.notifyPermissionAutoAcceptSynced(snapshot);
})
);
context.subscriptions.push(
vscode.window.onDidChangeWindowState((state) => {
chatViewProvider?.notifyWindowFocusChanged(state.focused);
@@ -297,13 +305,14 @@ export async function activate(context: vscode.ExtensionContext) {
}
// Get file info for context
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
// false matches the relativePath broadcast for the active editor, so this attachment dedupes against the pin-selection suggestion.
const filePath = vscode.workspace.asRelativePath(editor.document.uri, false);
// Get line numbers (1-based for display)
const startLine = selection.start.line + 1;
const endLine = selection.end.line + 1;
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
const filename = `${editor.document.fileName.split(/[\\/]/).pop() || filePath}:${lineRange}`;
const filename = `${filePath}:${lineRange}`;
const contextSelection = {
filePath: editor.document.uri.fsPath,
filename,
+170 -131
View File
@@ -14,11 +14,25 @@ import type { API as GitAPI, Repository, GitExtension, Status } from './git.d';
let gitApi: GitAPI | null = null;
let gitExtensionEnabled = false;
const worktreeBootstrapState = new Map<string, { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>();
type WorktreeBootstrapStatus = {
status: 'pending' | 'ready' | 'failed';
phase: 'directory-created' | 'git-ready' | 'setup-ready';
error: string | null;
updatedAt: number;
};
const worktreeBootstrapState = new Map<string, WorktreeBootstrapStatus>();
const activeWorktreeBootstrapTasks = new Map<string, Promise<unknown>>();
const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const;
const WORKTREE_BOOTSTRAP_READY = 'ready' as const;
const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const;
const WORKTREE_PHASE_DIRECTORY_CREATED = 'directory-created' as const;
const WORKTREE_PHASE_GIT_READY = 'git-ready' as const;
const WORKTREE_PHASE_SETUP_READY = 'setup-ready' as const;
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
const toBootstrapStateKey = (directory: string): string => {
const normalized = normalizeDirectoryPath(directory);
@@ -28,16 +42,35 @@ const toBootstrapStateKey = (directory: string): string => {
return path.resolve(normalized);
};
const setWorktreeBootstrapState = (directory: string, status: 'pending' | 'ready' | 'failed', error: string | null = null): void => {
const setWorktreeBootstrapState = (
directory: string,
status: WorktreeBootstrapStatus['status'],
phase: WorktreeBootstrapStatus['phase'],
error: string | null = null,
): WorktreeBootstrapStatus | null => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
return null;
}
worktreeBootstrapState.set(key, {
const state: WorktreeBootstrapStatus = {
status,
phase,
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
updatedAt: Date.now(),
});
};
worktreeBootstrapState.set(key, state);
return state;
};
const setWorktreeBootstrapFailure = (directory: string, error: unknown): void => {
const current = worktreeBootstrapState.get(toBootstrapStateKey(directory));
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
current?.phase ?? WORKTREE_PHASE_DIRECTORY_CREATED,
error instanceof Error ? error.message : String(error),
);
};
const clearWorktreeBootstrapState = (directory: string): void => {
@@ -48,6 +81,36 @@ const clearWorktreeBootstrapState = (directory: string): void => {
worktreeBootstrapState.delete(key);
};
const trackWorktreeBootstrapTask = (directory: string, task: Promise<unknown>): void => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
activeWorktreeBootstrapTasks.set(key, task);
const clearTask = () => {
if (activeWorktreeBootstrapTasks.get(key) === task) {
activeWorktreeBootstrapTasks.delete(key);
}
};
void task.then(clearTask, clearTask);
};
const waitForActiveWorktreeBootstrap = async (directory: string): Promise<void> => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
while (true) {
const task = activeWorktreeBootstrapTasks.get(key);
if (!task) {
return;
}
await task.catch(() => undefined);
}
};
const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
@@ -778,7 +841,7 @@ export interface GitWorktreeInfo {
branch: string;
path: string;
directoryCreated?: true;
bootstrapStatus?: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number };
bootstrapStatus?: WorktreeBootstrapStatus;
}
type WorktreeListEntry = {
@@ -1025,6 +1088,72 @@ const runGitCommandOrThrow = async (cwd: string, args: string[], fallbackMessage
return result;
};
const wait = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
const isIndexLockError = (result: GitCommandResult): boolean => {
const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n');
return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message);
};
const getWorktreeIndexLockPath = async (directory: string): Promise<string | null> => {
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']);
if (!result.success) {
return null;
}
const value = String(result.stdout || '').trim();
return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null;
};
const getFileIdentity = async (filePath: string): Promise<string | null> => {
try {
const stat = await fs.promises.stat(filePath);
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return null;
}
throw error;
}
};
const populateWorktreeWithLockRecovery = async (directory: string): Promise<void> => {
let result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
}
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
}
const lockPath = await getWorktreeIndexLockPath(directory);
const identity = lockPath ? await getFileIdentity(lockPath) : null;
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
throw new Error(result.message || 'Failed to populate worktree');
}
await fs.promises.unlink(lockPath).catch((error) => {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error;
}
});
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
};
const ensureOpenCodeProjectId = async (primaryWorktree: string): Promise<string> => {
const gitDir = path.join(primaryWorktree, '.git');
const idFile = path.join(gitDir, 'opencode');
@@ -1256,74 +1385,11 @@ const loadProjectStartCommand = async (projectID: string): Promise<string> => {
}
};
const getProjectStoragePath = (projectID: string) => {
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
};
const updateProjectSandboxes = async (
projectID: string,
primaryWorktree: string,
updater: (project: {
id: string;
worktree: string;
vcs: string;
sandboxes: string[];
time: { created: number; updated: number };
}) => void
) => {
const storagePath = getProjectStoragePath(projectID);
await fs.promises.mkdir(path.dirname(storagePath), { recursive: true });
const now = Date.now();
const base = {
id: projectID,
worktree: primaryWorktree,
vcs: 'git',
sandboxes: [] as string[],
time: { created: now, updated: now },
};
const parsed = await fs.promises.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw) as typeof base).catch(() => null);
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
current.id = String(current.id || projectID);
current.worktree = String(current.worktree || primaryWorktree);
current.vcs = current.vcs || 'git';
current.sandboxes = Array.isArray(current.sandboxes)
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
const createdAt = Number(current?.time?.created);
current.time = {
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
updated: now,
};
updater(current);
current.sandboxes = [...new Set(current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean))];
await fs.promises.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
};
const syncProjectSandboxAdd = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
if (!project.sandboxes.includes(sandbox)) {
project.sandboxes.push(sandbox);
}
});
};
const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
});
};
// OpenCode owns its own project/sandbox registry and records a worktree as a
// sandbox itself when an instance boots for that directory. OpenChamber used to
// write that state into OpenCode's storage JSON directly, behind the back of the
// running process — and since OpenCode v2 reads sandboxes from its database, the
// JSON write did not even reach it. Registration is not ours to perform.
const isInsideOrSameDirectory = (root: string, target: string): boolean => {
const relative = path.relative(root, target);
@@ -1348,14 +1414,6 @@ const cleanupFailedFastWorktreeCreate = async (
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
if (!isAttached) {
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
} catch (error) {
console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
}
}
if (!isInsideWorktreeRoot || isAttached) {
return;
}
@@ -1417,9 +1475,9 @@ const queueWorktreeBootstrap = (args: {
ensureRemoteUrl,
startCommand,
} = args;
setTimeout(() => {
const run = async () => {
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
.then(async () => {
await populateWorktreeWithLockRecovery(directory);
if (setUpstream) {
await applyUpstreamConfiguration({
primaryWorktree,
@@ -1434,21 +1492,18 @@ const queueWorktreeBootstrap = (args: {
console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
});
}
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_GIT_READY);
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error));
});
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
};
void run().catch((error) => {
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_PHASE_SETUP_READY);
})
.catch((error) => {
setWorktreeBootstrapFailure(directory, error);
console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
});
}, 0);
trackWorktreeBootstrapTask(directory, task);
};
const ensureRemoteWithUrl = async (primaryWorktree: string, remoteName: string, remoteUrl: string) => {
@@ -1837,19 +1892,17 @@ async function attachGitWorktreeToCandidate(
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
const shouldSetUpstream = Boolean(input?.setUpstream);
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_PHASE_DIRECTORY_CREATED,
) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
error: null,
updatedAt: Date.now(),
};
@@ -1903,15 +1956,13 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
if (input?.returnAfterDirectoryCreated === true) {
await fs.promises.mkdir(candidate.directory, { recursive: false });
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_PHASE_DIRECTORY_CREATED,
) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
error: null,
updatedAt: Date.now(),
};
@@ -1919,15 +1970,12 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
: candidate.branch;
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => {
setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
void cleanupFailedFastWorktreeCreate(context, candidate);
const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
setWorktreeBootstrapFailure(candidate.directory, error);
await cleanupFailedFastWorktreeCreate(context, candidate);
console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error));
});
trackWorktreeBootstrapTask(candidate.directory, task);
return {
head: '',
@@ -1942,7 +1990,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
return attachGitWorktreeToCandidate(context, candidate, input);
}
export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> {
export async function getWorktreeBootstrapStatus(directory: string): Promise<WorktreeBootstrapStatus> {
const key = toBootstrapStateKey(directory);
if (!key) {
throw new Error('Worktree directory is required');
@@ -1955,6 +2003,7 @@ export async function getWorktreeBootstrapStatus(directory: string): Promise<{ s
return {
status: WORKTREE_BOOTSTRAP_READY,
phase: WORKTREE_PHASE_SETUP_READY,
error: null,
updatedAt: Date.now(),
};
@@ -1966,6 +2015,8 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
throw new Error('Worktree directory is required');
}
await waitForActiveWorktreeBootstrap(targetDirectory);
const context = await resolveWorktreeProjectContext(directory);
const deleteLocalBranch = input?.deleteLocalBranch === true;
@@ -1995,12 +2046,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(targetDirectory);
return true;
@@ -2023,12 +2068,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
}
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;
@@ -0,0 +1,18 @@
import { describe, expect, it, mock } from 'bun:test';
mock.module('vscode', () => ({
extensions: { getExtension: () => undefined },
Uri: { file: (fsPath) => ({ fsPath }) },
}));
const { getWorktreeBootstrapStatus } = await import('./gitService.ts?worktree-bootstrap-test');
describe('VS Code worktree bootstrap phases', () => {
it('treats missing bootstrap state as fully ready', async () => {
await expect(getWorktreeBootstrapStatus('/untracked-worktree')).resolves.toMatchObject({
status: 'ready',
phase: 'setup-ready',
error: null,
});
});
});
@@ -0,0 +1,91 @@
import { afterEach, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode, type OpenCodeUpgradeManager } from './opencode-upgrade-runtime';
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const createManager = (mode: 'managed' | 'external' = 'managed') => {
let restartCount = 0;
const manager: OpenCodeUpgradeManager = {
getApiUrl: () => 'http://127.0.0.1:4096',
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
getDebugInfo: () => ({ mode }),
restart: async () => { restartCount += 1; },
};
return { manager, getRestartCount: () => restartCount };
};
describe('VS Code OpenCode upgrades', () => {
test('reports an available update for a managed OpenCode process', async () => {
const { manager } = createManager();
globalThis.fetch = (async (input: Parameters<typeof fetch>[0]) => {
const url = String(input);
if (url.endsWith('/global/health')) return new Response(JSON.stringify({ version: '1.18.8' }));
if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.9' }));
return new Response(JSON.stringify({ tag_name: 'v1.18.9' }));
}) as typeof fetch;
assert.deepEqual(await getOpenCodeUpgradeStatus(manager), {
available: true,
currentVersion: '1.18.8',
latestVersion: '1.18.9',
upgrade: { supported: true, manager: 'opencode', reason: null },
});
});
test('fails closed for externally managed OpenCode without contacting the updater', async () => {
const { manager } = createManager('external');
let fetchCount = 0;
globalThis.fetch = (async () => {
fetchCount += 1;
return new Response('{}');
}) as typeof fetch;
assert.deepEqual(await upgradeManagedOpenCode(manager), {
status: 409,
body: {
success: false,
code: 'OPENCODE_UPGRADE_UNSUPPORTED',
error: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
},
});
assert.equal(fetchCount, 0);
});
test('upgrades then restarts the extension-owned OpenCode process', async () => {
const { manager, getRestartCount } = createManager();
let request: RequestInit | undefined;
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
assert.equal(String(input), 'http://127.0.0.1:4096/global/upgrade');
request = init;
return new Response(JSON.stringify({ success: true, version: '1.18.9' }));
}) as typeof fetch;
assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), {
status: 200,
body: { success: true, version: '1.18.9', restarted: true },
});
assert.equal(getRestartCount(), 1);
assert.equal(request?.method, 'POST');
assert.deepEqual(JSON.parse(String(request?.body)), { target: '1.18.9' });
assert.equal((request?.headers as Record<string, string>).Authorization, 'Basic test');
});
test('serializes concurrent managed upgrades', async () => {
const { manager } = createManager();
let release: (response: Response) => void = () => {};
globalThis.fetch = (() => new Promise<Response>((resolve) => { release = resolve; })) as typeof fetch;
const first = upgradeManagedOpenCode(manager);
const second = await upgradeManagedOpenCode(manager);
assert.equal(second.status, 409);
assert.equal(second.body.code, 'OPENCODE_UPGRADE_IN_PROGRESS');
release(new Response(JSON.stringify({ success: true })));
assert.equal((await first).status, 200);
});
});
@@ -0,0 +1,136 @@
type UpgradeCapability = {
supported: boolean;
manager: 'opencode' | 'external' | 'openchamber' | null;
reason: 'external' | 'unavailable' | 'windows-arm64-workaround' | null;
};
export type OpenCodeUpgradeManager = {
getApiUrl(): string | null;
getOpenCodeAuthHeaders(): Record<string, string>;
getDebugInfo(): { mode: 'managed' | 'external' };
restart(): Promise<void>;
};
type UpgradeResult = { status: number; body: Record<string, unknown> };
let openCodeUpgradePromise: Promise<UpgradeResult> | null = null;
// TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
// FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
// Disable OpenCode self-upgrade on ARM64 so it can't overwrite the working x64
// binary with the broken ARM64 build. Remove when the upstream issue is resolved.
const isWindowsArm64 = (): boolean => process.platform === 'win32' && process.arch === 'arm64';
const parseVersion = (value: unknown): { parts: number[]; prerelease: boolean } => {
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
const prereleaseIndex = normalized.indexOf('-');
const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized;
return {
parts: core.split('.').map((part) => {
const parsed = Number.parseInt(part || '0', 10);
return Number.isFinite(parsed) ? parsed : 0;
}),
prerelease: prereleaseIndex >= 0,
};
};
const compareVersions = (left: unknown, right: unknown): number => {
const a = parseVersion(left);
const b = parseVersion(right);
for (let index = 0; index < Math.max(a.parts.length, b.parts.length); index += 1) {
const difference = (a.parts[index] || 0) - (b.parts[index] || 0);
if (difference !== 0) return difference;
}
return a.prerelease === b.prerelease ? 0 : (a.prerelease ? -1 : 1);
};
const getCapability = (manager?: OpenCodeUpgradeManager): UpgradeCapability => {
if (isWindowsArm64()) return { supported: false, manager: 'openchamber', reason: 'windows-arm64-workaround' };
if (!manager) return { supported: false, manager: null, reason: 'unavailable' };
if (manager.getDebugInfo().mode !== 'managed') return { supported: false, manager: 'external', reason: 'external' };
if (!manager.getApiUrl()) return { supported: false, manager: null, reason: 'unavailable' };
return { supported: true, manager: 'opencode', reason: null };
};
const getApiUrl = (manager?: OpenCodeUpgradeManager): string | null => {
const apiUrl = manager?.getApiUrl();
return apiUrl ? `${apiUrl.replace(/\/+$/, '')}/` : null;
};
const fetchLatestVersion = async (): Promise<string> => {
const results = await Promise.allSettled([
fetch('https://registry.npmjs.org/opencode-ai/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
.then(async (response) => {
if (!response.ok) throw new Error(`OpenCode npm registry responded with ${response.status}`);
const payload = await response.json() as { version?: unknown };
return typeof payload.version === 'string' ? payload.version.trim().replace(/^v/, '') : '';
}),
fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
.then(async (response) => {
if (!response.ok) throw new Error(`OpenCode releases responded with ${response.status}`);
const payload = await response.json() as { tag_name?: unknown };
return typeof payload.tag_name === 'string' ? payload.tag_name.trim().replace(/^v/, '') : '';
}),
]);
const versions = results.flatMap((result) => result.status === 'fulfilled' && result.value ? [result.value] : []);
if (versions.length === 0) throw new Error('Failed to resolve latest OpenCode version');
return versions.sort((left, right) => compareVersions(right, left))[0];
};
export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise<Record<string, unknown>> => {
const upgrade = getCapability(manager);
const apiUrl = getApiUrl(manager);
if (!upgrade.supported || !apiUrl || !manager) return { available: false, currentVersion: null, latestVersion: null, upgrade };
try {
const [healthResponse, latestVersion] = await Promise.all([
fetch(new URL('global/health', apiUrl).toString(), { method: 'GET', headers: { Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() } }),
fetchLatestVersion(),
]);
const health = await healthResponse.json().catch(() => null) as { version?: unknown; error?: unknown } | null;
if (!healthResponse.ok) {
const error = typeof health?.error === 'string' ? health.error : healthResponse.statusText || 'Failed to read OpenCode version';
return { available: null, error, upgrade };
}
const currentVersion = typeof health?.version === 'string' && health.version.trim() ? health.version.trim().replace(/^v/, '') : null;
return { available: currentVersion ? compareVersions(latestVersion, currentVersion) > 0 : null, currentVersion, latestVersion, upgrade };
} catch (error) {
return { available: null, error: error instanceof Error ? error.message : String(error), upgrade };
}
};
export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | undefined, target?: unknown): Promise<UpgradeResult> => {
const upgrade = getCapability(manager);
const apiUrl = getApiUrl(manager);
if (!upgrade.supported || !apiUrl || !manager) {
return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_UNSUPPORTED', error: 'This OpenCode runtime cannot be upgraded by OpenChamber.' } };
}
if (openCodeUpgradePromise) {
return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_IN_PROGRESS', error: 'An OpenCode upgrade is already in progress.' } };
}
const targetVersion = typeof target === 'string' ? target.trim() : '';
const operation = (async (): Promise<UpgradeResult> => {
try {
const response = await fetch(new URL('global/upgrade', apiUrl).toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() },
body: JSON.stringify(targetVersion ? { target: targetVersion } : {}),
});
const payload = await response.json().catch(() => null) as { error?: unknown } | null;
if (!response.ok) return { status: response.status, body: { success: false, error: typeof payload?.error === 'string' ? payload.error : response.statusText || 'Failed to upgrade OpenCode' } };
try {
await manager.restart();
} catch (error) {
return { status: 500, body: { success: false, upgraded: true, error: error instanceof Error ? `OpenCode upgraded, but restart failed: ${error.message}` : 'OpenCode upgraded, but restart failed' } };
}
return { status: 200, body: { ...(payload && typeof payload === 'object' ? payload : { success: true }), restarted: true } };
} catch (error) {
return { status: 500, body: { success: false, error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode' } };
}
})();
openCodeUpgradePromise = operation;
try {
return await operation;
} finally {
if (openCodeUpgradePromise === operation) openCodeUpgradePromise = null;
}
};
+74 -27
View File
@@ -123,14 +123,40 @@ function isExecutable(filePath: string): boolean {
}
}
function shouldUseWindowsShell(binary: string): boolean {
if (process.platform !== 'win32') return false;
// Windows launch spec: .cmd/.bat shims (and bare names, which resolve to .cmd
// shims via PATHEXT) must run under cmd.exe. Spawn cmd.exe DIRECTLY with the
// shim path as its own argv element (shell:false) — `shell: true` builds an
// unquoted command line, so a space-containing path like
// "C:\Program Files\nodejs\opencode.cmd" broke with
// "'C:\Program' is not recognized as an internal or external command".
function resolveWindowsLaunchSpec(binary: string, args: string[]): { binary: string; args: string[] } {
if (process.platform !== 'win32') {
return { binary, args };
}
const trimmed = (binary || '').trim();
if (!trimmed) return true;
const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat') return true;
// Bare command names often resolve to .cmd shims via PATHEXT.
return !ext && !trimmed.includes('\\') && !trimmed.includes('/');
const isBatchShim = ext === '.cmd' || ext === '.bat';
const isBareName = !ext && !trimmed.includes('\\') && !trimmed.includes('/');
if (!isBatchShim && !isBareName) {
return { binary: trimmed, args };
}
return {
binary: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', 'call', trimmed, ...args],
};
}
// Strip a single wrapping quote pair (Windows "Copy as path" and quoted shell
// snippets) — literal quotes are never part of a real path and break every
// executable check.
function stripWrappingQuotes(value: string): string {
const trimmed = (value || '').trim();
if (trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function appendToPath(dir: string) {
@@ -177,7 +203,7 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
if (typeof raw !== 'string') {
return null;
}
const trimmed = raw.trim();
const trimmed = stripWrappingQuotes(raw);
if (!trimmed) {
return null;
}
@@ -193,13 +219,33 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
}
function isMacOpenCodeAppBundlePath(candidate: string): boolean {
return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
return process.platform === 'darwin' && /\/OpenCode(?: Dev| Beta)?\.app\/Contents\/MacOS\/(?:OpenCode(?: Dev| Beta)?|opencode-cli)$/i.test(candidate);
}
function isWindowsOpenCodeDesktopAppPath(candidate: string): boolean {
if (process.platform !== 'win32' || typeof candidate !== 'string') {
return false;
}
const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim()
? path.resolve(process.env.LOCALAPPDATA).toLowerCase()
: '';
if (!localAppData) {
return false;
}
const normalized = path.resolve(candidate).toLowerCase();
return normalized.startsWith(`${localAppData}${path.sep}`)
&& normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`);
}
function isKnownOpenCodeDesktopAppPath(candidate: string): boolean {
return isMacOpenCodeAppBundlePath(candidate) || isWindowsOpenCodeDesktopAppPath(candidate);
}
function createConfiguredOpencodeBinaryError(raw: string, normalized: string): Error {
const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set openchamber.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.';
if (isMacOpenCodeAppBundlePath(raw) || isMacOpenCodeAppBundlePath(normalized)) {
return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`);
if (isKnownOpenCodeDesktopAppPath(raw) || isKnownOpenCodeDesktopAppPath(normalized)) {
const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle';
return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${normalized}. ${messageSuffix}`);
}
try {
@@ -254,7 +300,7 @@ function validateConfiguredOpencodeBinaryForManagedStart(): string | null {
return null;
}
if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) {
if (isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) {
return normalized;
}
@@ -271,7 +317,7 @@ function resolveOpencodeCliPath(): string | null {
}
})();
if (configured && isExecutable(configured) && !isMacOpenCodeAppBundlePath(configured)) {
if (configured && isExecutable(configured) && !isKnownOpenCodeDesktopAppPath(configured)) {
return configured;
}
@@ -288,7 +334,7 @@ function resolveOpencodeCliPath(): string | null {
}
})();
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(sharedFromOpenChamber)) {
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isKnownOpenCodeDesktopAppPath(sharedFromOpenChamber)) {
return sharedFromOpenChamber;
}
@@ -298,17 +344,17 @@ function resolveOpencodeCliPath(): string | null {
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.map((v) => (typeof v === 'string' ? stripWrappingQuotes(v) : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) {
return candidate;
}
}
if (cachedDetectedOpencodeCliPath) {
if (isExecutable(cachedDetectedOpencodeCliPath)) {
if (isExecutable(cachedDetectedOpencodeCliPath) && !isKnownOpenCodeDesktopAppPath(cachedDetectedOpencodeCliPath)) {
return cachedDetectedOpencodeCliPath;
}
cachedDetectedOpencodeCliPath = undefined;
@@ -327,7 +373,6 @@ function resolveOpencodeCliPath(): string | null {
const winFallbacks = (() => {
const userProfile = process.env.USERPROFILE || home;
const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || '';
const programData = process.env.ProgramData || 'C:\\ProgramData';
const npmDir = path.join(appData, 'npm');
@@ -338,20 +383,22 @@ function resolveOpencodeCliPath(): string | null {
path.join(npmDir, 'opencode.exe'),
path.join(npmDir, 'opencode.cmd'),
path.join(npmDir, 'opencode.bat'),
// System-wide Node installer keeps the global npm prefix here
// (npm i -g opencode-ai → opencode.cmd shim).
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'opencode.cmd'),
path.join(userProfile, 'scoop', 'shims', 'opencode.exe'),
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
// Bun global install
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
// Some installers use LocalAppData
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
].filter(Boolean);
})();
if (process.platform !== 'win32') {
const fromPath = findExecutableInPath('opencode');
if (fromPath) {
if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) {
cachedDetectedOpencodeCliPath = fromPath;
return fromPath;
}
@@ -359,7 +406,7 @@ function resolveOpencodeCliPath(): string | null {
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
for (const candidate of fallbacks) {
if (isExecutable(candidate)) {
if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) {
cachedDetectedOpencodeCliPath = candidate;
return candidate;
}
@@ -367,7 +414,7 @@ function resolveOpencodeCliPath(): string | null {
if (process.platform === 'win32') {
const fromPath = findExecutableInPath('opencode');
if (fromPath) {
if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) {
cachedDetectedOpencodeCliPath = fromPath;
return fromPath;
}
@@ -376,13 +423,14 @@ function resolveOpencodeCliPath(): string | null {
const result = spawnSync('where', ['opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
const found = lines.find((line) => isExecutable(line) && !isKnownOpenCodeDesktopAppPath(line));
if (found) {
cachedDetectedOpencodeCliPath = found;
return found;
@@ -615,14 +663,13 @@ async function spawnManagedOpenCodeServer(
port: number,
timeoutMs: number
): Promise<{ url: string; close: () => void }> {
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)];
const child = spawn(binary, args, {
const binary = stripWrappingQuotes(process.env.OPENCODE_BINARY || 'opencode') || 'opencode';
const launch = resolveWindowsLaunchSpec(binary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)]);
const child = spawn(launch.binary, launch.args, {
cwd: workingDirectory,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
shell: shouldUseWindowsShell(binary),
});
const url = await new Promise<string>((resolve, reject) => {
+5 -2
View File
@@ -28,15 +28,18 @@ const readAuthFile = (): AuthFile => {
const writeAuthFile = (auth: AuthFile): void => {
try {
if (!fs.existsSync(OPENCODE_DATA_DIR)) {
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true });
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true, mode: 0o700 });
}
if (process.platform !== 'win32') fs.chmodSync(OPENCODE_DATA_DIR, 0o700);
if (fs.existsSync(AUTH_FILE)) {
const backupFile = `${AUTH_FILE}.openchamber.backup`;
fs.copyFileSync(AUTH_FILE, backupFile);
if (process.platform !== 'win32') fs.chmodSync(backupFile, 0o600);
}
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { encoding: 'utf8', mode: 0o600 });
if (process.platform !== 'win32') fs.chmodSync(AUTH_FILE, 0o600);
} catch (error) {
console.error('Failed to write auth file:', error);
throw new Error('Failed to write OpenCode auth configuration');
@@ -0,0 +1,265 @@
import { afterEach, beforeEach, 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 {
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
validateCustomProviderConfig,
} from './opencodeConfig';
let projectDir: string;
const writeJson = (filePath: string, value: unknown) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
};
const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
describe('custom provider config persistence (VS Code parity)', () => {
beforeEach(() => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-provider-'));
});
afterEach(() => {
fs.rmSync(projectDir, { recursive: true, force: true });
});
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
assert.equal(validateCustomProviderConfig('Bad Id', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok, false);
const ftp = validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'ftp://api.example.com' },
models: { m: { name: 'M' } },
});
assert.equal(ftp.ok, false);
assert.match(ftp.error ?? '', /http:\/\//);
assert.equal(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: {},
}).ok, false);
});
test('validateCustomProviderConfig rejects missing credentials', () => {
assert.equal(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok, false);
assert.equal(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}, { hasStoredAuth: true }).ok, true);
assert.equal(validateCustomProviderConfig('ok', {
name: 'X',
env: ['MY_KEY'],
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok, true);
});
test('upsertProviderConfig writes and round-trips project config', () => {
const result = upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: {
'fast-model': { name: 'Fast' },
},
env: ['CAMPUS_KEY'],
}, projectDir, 'project');
assert.equal(result.providerId, 'campus-llm');
assert.equal(fs.existsSync(result.path), true);
assert.equal(result.path.startsWith(projectDir), true);
const written = readJson(result.path);
assert.deepEqual(written.provider['campus-llm'], {
npm: '@ai-sdk/openai-compatible',
name: 'Campus LLM',
env: ['CAMPUS_KEY'],
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: {
'fast-model': { name: 'Fast' },
},
});
const sources = getProviderSources('campus-llm', projectDir);
assert.equal(sources.project.exists, true);
assert.equal(sources.project.path, result.path);
});
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
const configPath = path.join(projectDir, 'opencode.json');
writeJson(configPath, {
provider: {
'campus-llm': {
npm: '@ai-sdk/openai-compatible',
name: 'Old',
options: { baseURL: 'https://old.example.edu/v1' },
models: { a: { name: 'A' } },
},
},
disabled_providers: ['campus-llm', 'other'],
});
upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { b: { name: 'B' } },
env: ['CAMPUS_KEY'],
}, projectDir, 'project');
const written = readJson(configPath);
assert.equal(written.provider['campus-llm'].name, 'Campus LLM');
assert.deepEqual(written.provider['campus-llm'].models, { b: { name: 'B' } });
assert.deepEqual(written.disabled_providers, ['other']);
});
test('upsert then remove restores absence', () => {
upsertProviderConfig('temp-provider', {
name: 'Temp',
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
env: ['TEMP_KEY'],
}, projectDir, 'project');
assert.equal(getProviderSources('temp-provider', projectDir).project.exists, true);
assert.equal(removeProviderConfig('temp-provider', projectDir, 'project'), true);
assert.equal(getProviderSources('temp-provider', projectDir).project.exists, false);
});
test('failed validation does not write config', () => {
const configPath = path.join(projectDir, 'opencode.json');
assert.throws(
() => upsertProviderConfig('ok', {
name: 'X',
options: { baseURL: 'not-a-url' },
models: { m: { name: 'M' } },
env: ['X'],
}, projectDir, 'project'),
/Base URL/,
);
assert.equal(fs.existsSync(configPath), false);
});
test('upsert with hasStoredAuth allows config without env', () => {
const result = upsertProviderConfig('keyed-provider', {
name: 'Keyed',
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
assert.equal(result.providerId, 'keyed-provider');
assert.equal(result.config.env, undefined);
});
test('project-scope edit updates project layer without creating a user entry', () => {
const providerId = `proj-scope-${Date.now()}`;
const configPath = path.join(projectDir, 'opencode.json');
upsertProviderConfig(providerId, {
name: 'Project Scoped',
options: { baseURL: 'https://project.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Project Scoped Updated',
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
models: { m: { name: 'M2' } },
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath);
assert.deepEqual(written.provider[providerId], {
npm: '@ai-sdk/openai-compatible',
name: 'Project Scoped Updated',
options: {
baseURL: 'https://project.example.com/v2',
headers: { 'X-Project': '1' },
},
models: { m: { name: 'M2' } },
});
const sources = getProviderSources(providerId, projectDir);
assert.equal(sources.project.exists, true);
assert.equal(sources.user.exists, false);
assert.equal(sources.custom.exists, false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
assert.equal(userConfig.provider?.[providerId], undefined);
assert.equal(userConfig.providers?.[providerId], undefined);
}
});
test('custom-scope edit updates custom layer without creating a user entry', () => {
const providerId = `custom-scope-${Date.now()}`;
const customPath = path.join(projectDir, 'custom-opencode.json');
const previousEnv = process.env.OPENCODE_CONFIG;
process.env.OPENCODE_CONFIG = customPath;
try {
upsertProviderConfig(providerId, {
name: 'Custom Scoped',
options: { baseURL: 'https://custom.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'custom', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Custom Scoped Updated',
options: { baseURL: 'https://custom.example.com/v2' },
models: { n: { name: 'N' } },
}, projectDir, 'custom', { hasStoredAuth: true });
const written = readJson(customPath);
assert.equal(written.provider[providerId].name, 'Custom Scoped Updated');
assert.equal(written.provider[providerId].options.baseURL, 'https://custom.example.com/v2');
const sources = getProviderSources(providerId, projectDir);
assert.equal(sources.custom.exists, true);
assert.equal(sources.user.exists, false);
assert.equal(sources.project.exists, false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
assert.equal(userConfig.provider?.[providerId], undefined);
assert.equal(userConfig.providers?.[providerId], undefined);
}
} finally {
if (previousEnv === undefined) {
delete process.env.OPENCODE_CONFIG;
} else {
process.env.OPENCODE_CONFIG = previousEnv;
}
}
});
});
+285 -5
View File
@@ -10,9 +10,6 @@ const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
const SNIPPET_EXTENSION = '.md';
const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
@@ -541,7 +538,10 @@ const getConfigPaths = (workingDirectory?: string) => ({
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
});
const getPrimaryUserConfigPath = (userPaths: string[]): string => {
@@ -1666,6 +1666,9 @@ export const updateAgent = (agentName: string, updates: Record<string, unknown>,
const creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates || {})) {
// Skip undefined values — they would overwrite existing frontmatter fields with nothing
if (value === undefined) continue;
if (field === 'prompt') {
if (value === null) {
if (mdExists || creatingNewMd) {
@@ -2165,6 +2168,163 @@ export const removeProviderConfig = (providerId: string, workingDirectory?: stri
return true;
};
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
export const validateCustomProviderConfig = (
providerId: string,
config: unknown,
options: { hasStoredAuth?: boolean } = {},
) => {
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
return { ok: false as const, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
}
if (!isPlainObject(config)) {
return { ok: false as const, error: 'Provider config must be an object' };
}
const name = typeof config.name === 'string' ? config.name.trim() : '';
if (!name) {
return { ok: false as const, error: 'Provider name is required' };
}
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
if (npm !== OPENAI_COMPATIBLE_NPM) {
return { ok: false as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
}
const optionsBlock = isPlainObject(config.options) ? config.options : null;
if (!optionsBlock) {
return { ok: false as const, error: 'Provider options are required' };
}
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
if (!baseURL) {
return { ok: false as const, error: 'Base URL is required' };
}
if (!BASE_URL_PATTERN.test(baseURL)) {
return { ok: false as const, error: 'Base URL must start with http:// or https://' };
}
const models = isPlainObject(config.models) ? config.models : null;
if (!models || Object.keys(models).length === 0) {
return { ok: false as const, error: 'At least one model is required' };
}
const normalizedModels: Record<string, { name: string }> = {};
for (const [modelId, modelValue] of Object.entries(models)) {
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
if (!trimmedId) {
return { ok: false as const, error: 'Model id is required' };
}
if (!isPlainObject(modelValue)) {
return { ok: false as const, error: `Model "${trimmedId}" must be an object` };
}
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
if (!modelName) {
return { ok: false as const, error: `Model "${trimmedId}" requires a name` };
}
normalizedModels[trimmedId] = { name: modelName };
}
const normalized: Record<string, unknown> = {
npm: OPENAI_COMPATIBLE_NPM,
name,
options: {
baseURL,
},
models: normalizedModels,
};
let env: string[] = [];
if (Array.isArray(config.env)) {
env = config.env
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim());
if (env.length > 0) {
normalized.env = env;
}
}
if (env.length === 0 && !options.hasStoredAuth) {
return { ok: false as const, error: 'API key or {env:VAR} credentials are required' };
}
if (isPlainObject(optionsBlock.headers)) {
const headers: Record<string, string> = {};
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
if (typeof headerKey !== 'string' || !headerKey.trim()) {
continue;
}
if (typeof headerValue !== 'string' || !headerValue.trim()) {
return { ok: false as const, error: `Header "${headerKey}" requires a non-empty value` };
}
headers[headerKey.trim()] = headerValue.trim();
}
if (Object.keys(headers).length > 0) {
(normalized.options as Record<string, unknown>).headers = headers;
}
}
return { ok: true as const, value: { providerId, config: normalized } };
};
export const upsertProviderConfig = (
providerId: string,
config: unknown,
workingDirectory?: string,
scope: 'user' | 'project' | 'custom' = 'user',
options: { hasStoredAuth?: boolean } = {},
) => {
const validated = validateCustomProviderConfig(providerId, config, options);
if (!validated.ok) {
const error = new Error(validated.error) as Error & { statusCode?: number };
error.statusCode = 400;
throw error;
}
const layers = readConfigLayers(workingDirectory);
let targetPath: string | null | undefined = layers.paths.userPath;
if (scope === 'project') {
if (!workingDirectory) {
throw new Error('Working directory is required for project scope');
}
targetPath = layers.paths.projectPath ?? targetPath;
} else if (scope === 'custom') {
if (!layers.paths.customPath) {
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
}
targetPath = layers.paths.customPath;
} else if (scope !== 'user') {
throw new Error('Invalid scope');
}
const targetConfig = getConfigForPath(layers, targetPath) as Record<string, unknown>;
const providerConfig = isPlainObject(targetConfig.provider)
? { ...(targetConfig.provider as Record<string, unknown>) }
: {};
providerConfig[validated.value.providerId] = validated.value.config;
targetConfig.provider = providerConfig;
if (Array.isArray(targetConfig.disabled_providers)) {
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
(entry) => entry !== validated.value.providerId,
);
}
const writePath = targetPath || CONFIG_FILE;
writeConfig(targetConfig, writePath);
return {
providerId: validated.value.providerId,
path: writePath,
config: validated.value.config,
};
};
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
let deleted = false;
@@ -2758,7 +2918,7 @@ export const updateSkill = (skillName: string, updates: Record<string, unknown>,
let mdModified = false;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'scope') continue;
if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') continue;
if (field === 'instructions') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
@@ -2830,3 +2990,123 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
throw new Error(`Skill "${skillName}" not found`);
}
};
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
const resolvedCandidate = path.resolve(candidatePath);
const resolvedParent = path.resolve(parentPath);
return resolvedCandidate === resolvedParent
|| resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`);
};
const getManagedSkillRoots = (workingDirectory?: string): string[] => {
const roots: string[] = [];
const pushRoot = (dir?: string | null) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!roots.includes(resolved)) {
roots.push(resolved);
}
};
pushRoot(SKILL_DIR);
pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill'));
pushRoot(path.join(os.homedir(), '.opencode', 'skills'));
pushRoot(path.join(os.homedir(), '.opencode', 'skill'));
pushRoot(path.join(os.homedir(), '.claude', 'skills'));
pushRoot(path.join(os.homedir(), '.agents', 'skills'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
pushRoot(customConfigDir ? path.join(customConfigDir, 'skills') : null);
pushRoot(customConfigDir ? path.join(customConfigDir, 'skill') : null);
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) {
pushRoot(path.join(ancestor, '.opencode', 'skills'));
pushRoot(path.join(ancestor, '.opencode', 'skill'));
pushRoot(path.join(ancestor, '.claude', 'skills'));
pushRoot(path.join(ancestor, '.agents', 'skills'));
}
}
return roots;
};
const isManagedSkillPath = (skillMdPath: string, workingDirectory?: string): boolean => {
if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) {
return false;
}
const skillDir = path.dirname(path.resolve(skillMdPath));
return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root));
};
export { isManagedSkillPath };
export const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => {
ensureSkillDirs();
validateSkillName(newName);
if (oldName === newName) {
return;
}
const existing = getSkillScope(oldName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${oldName}" not found`);
}
if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) {
throw new Error(`Skill "${oldName}" cannot be renamed`);
}
if (path.basename(existing.path) !== 'SKILL.md') {
throw new Error(`Skill "${oldName}" target must be a SKILL.md file`);
}
if (!isManagedSkillPath(existing.path, workingDirectory)) {
throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`);
}
const mdDataBeforeMove = parseMdFile(existing.path);
const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string'
? mdDataBeforeMove.frontmatter.name
: oldName;
if (frontmatterName !== oldName) {
throw new Error(`Skill "${oldName}" does not match ${existing.path}`);
}
const conflict = getSkillScope(newName, workingDirectory);
if (conflict.path) {
throw new Error(`Skill ${newName} already exists at ${conflict.path}`);
}
const oldDir = path.dirname(existing.path);
const newDir = path.join(path.dirname(oldDir), newName);
const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir);
if (directoriesDiffer && fs.existsSync(newDir)) {
throw new Error(`Skill directory already exists at ${newDir}`);
}
if (directoriesDiffer) {
fs.renameSync(oldDir, newDir);
}
const newPath = path.join(newDir, 'SKILL.md');
try {
const mdData = parseMdFile(newPath);
mdData.frontmatter = {
...mdData.frontmatter,
name: newName,
};
writeMdFile(newPath, mdData.frontmatter, mdData.body);
} catch (error) {
if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) {
try {
fs.renameSync(newDir, oldDir);
} catch {
// Best-effort rollback; surface the original write failure.
}
}
throw error;
}
};
+28
View File
@@ -0,0 +1,28 @@
type OpenCodeGoCredential = { workspaceId: string; authCookie: string };
const toWindow = (usedPercent: number, resetInSec: number) => ({
usedPercent: Math.min(100, Math.max(0, usedPercent)),
remainingPercent: 100 - Math.min(100, Math.max(0, usedPercent)),
windowSeconds: null,
resetAfterSeconds: Math.max(0, resetInSec),
resetAt: Date.now() + Math.max(0, resetInSec) * 1000,
resetAtFormatted: null,
resetAfterFormatted: null,
});
export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => {
const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, { headers: { Accept: 'text/html', Cookie: `auth=${credential.authCookie}` }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed');
if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`);
const html = (await response.text()).replaceAll('&quot;', '"').replaceAll('&#34;', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"');
const windows: Record<string, ReturnType<typeof toWindow>> = {};
for (const [key, field] of Object.entries({ '5h': 'rollingUsage', weekly: 'weeklyUsage', monthly: 'monthlyUsage' })) {
const body = html.match(new RegExp(`["']?${field}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, 's'))?.[1];
if (!body) continue;
const used = Number(body.match(/usagePercent\s*:\s*["']?(-?\d+(?:\.\d+)?)/)?.[1]);
const reset = Number(body.match(/resetInSec\s*:\s*["']?(-?\d+(?:\.\d+)?)/)?.[1]);
if (Number.isFinite(used) && Number.isFinite(reset)) windows[key] = toWindow(used, reset);
}
if (!Object.keys(windows).length) throw new Error('OpenCode Go usage data could not be parsed');
return windows;
};
+75
View File
@@ -0,0 +1,75 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
export type ManagedProvider = 'opencode-go' | 'ollama-cloud' | 'cursor';
export type ManagedCredential = Record<string, string>;
const providers = new Set<ManagedProvider>(['opencode-go', 'ollama-cloud', 'cursor']);
const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota');
const target = (provider: ManagedProvider) => {
if (!providers.has(provider)) throw new Error('Unsupported credential provider');
return path.join(directory(), `${provider}.json`);
};
const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => {
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {};
if (provider === 'opencode-go') {
const workspaceId = clean(data.workspaceId);
let authCookie = clean(data.authCookie);
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
return workspaceId && authCookie ? { workspaceId, authCookie } : null;
}
if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null;
const accessToken = clean(data.accessToken);
const refreshToken = clean(data.refreshToken);
return accessToken || refreshToken ? { accessToken, refreshToken } : null;
};
export const readCredential = (provider: ManagedProvider) => {
try { return normalizeCredential(provider, JSON.parse(fs.readFileSync(target(provider), 'utf8'))); }
catch (error) { if ((error as { code?: string }).code !== 'ENOENT') console.warn(`Failed to read ${provider} quota credentials`); return null; }
};
export const credentialStatus = (provider: ManagedProvider) => {
const value = readCredential(provider);
if (!value) return { configured: false };
return { configured: true, ...(provider === 'opencode-go' ? { workspaceId: value.workspaceId } : {}), ...(provider === 'cursor' ? { hasRefreshToken: Boolean(value.refreshToken) } : {}), secretMasked: '••••••••' };
};
export const writeCredential = (provider: ManagedProvider, value: ManagedCredential) => {
const dir = directory(); const file = target(provider); const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.chmodSync(dir, 0o700);
try { fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(temp, file); fs.chmodSync(file, 0o600); }
finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
return credentialStatus(provider);
};
export const deleteCredential = (provider: ManagedProvider) => { try { fs.unlinkSync(target(provider)); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } };
export const importCursorCredential = () => {
const db = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
if (process.platform !== 'darwin' || !fs.existsSync(db)) throw new Error('Cursor credential import is unavailable');
const rows = JSON.parse(execFileSync('sqlite3', ['-json', db, "SELECT key,value FROM ItemTable WHERE key IN ('cursorAuth/accessToken','cursorAuth/refreshToken');"], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }) || '[]') as Array<{ key: string; value: string }>;
const credential = normalizeCredential('cursor', { accessToken: rows.find((row) => row.key.endsWith('accessToken'))?.value, refreshToken: rows.find((row) => row.key.endsWith('refreshToken'))?.value });
if (!credential) throw new Error('Cursor credentials are unavailable');
return credential;
};
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
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');
}
if (provider === 'cursor') {
if (!credential.accessToken && credential.refreshToken) {
const refresh = await fetch('https://api2.cursor.sh/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', client_id: 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB', refresh_token: credential.refreshToken }), signal: AbortSignal.timeout(15_000) });
const payload = await refresh.json().catch(() => null) as { access_token?: string } | null;
if (!refresh.ok || !payload?.access_token) throw new Error('Cursor authentication failed');
credential.accessToken = payload.access_token;
}
if (!credential.accessToken) throw new Error('Cursor access token is required');
const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${credential.accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error('Cursor authentication failed');
}
};
+512
View File
@@ -0,0 +1,512 @@
import { afterEach, beforeEach, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
// readAuthFile reads ~/.local/share/opencode/auth.json via fs.readFileSync.
// Stub fs to serve a known auth entry so the providers treat themselves as
// configured and proceed straight to fetch.
const ORIGINAL_FS = { ...fs };
const AUTH = JSON.stringify({
openai: { access: 'test-token' },
crof: { key: 'test-token' },
neuralwatt: { key: 'test-token' },
'zai-coding-plan': { key: 'test-token' },
deepseek: { key: 'test-token' },
});
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH;
import { fetchQuotaForProvider } from './quotaProviders';
type MockResponseInit = { ok?: boolean; status?: number };
const mockResponse = (body: unknown, init: MockResponseInit = {}): Response => ({
ok: 'ok' in init ? init.ok! : true,
status: init.status ?? 200,
json: async () => body,
} as unknown as Response);
// Documented NeuralWatt payload from https://portal.neuralwatt.com/docs/api/quota.
// plan="standard", kwh_included=20.0, kwh_used=13.9023.
const DOCUMENTED_SUBSCRIPTION_PAYLOAD = {
snapshot_at: '2026-04-16T18:30:00Z',
balance: { credits_remaining_usd: 32.6774, total_credits_usd: 52.34, credits_used_usd: 19.6626, accounting_method: 'energy' },
usage: {
lifetime: { cost_usd: 243.9145, requests: 37801, tokens: 1235477176, energy_kwh: 15.6009 },
current_month: { cost_usd: 160.1463, requests: 23902, tokens: 1116658995, energy_kwh: 9.7278 },
},
limits: { overage_limit_usd: null, rate_limit_tier: 'standard' },
subscription: {
plan: 'standard',
status: 'active',
billing_interval: 'year',
current_period_start: '2026-04-11T05:05:25Z',
current_period_end: '2027-04-11T05:05:25Z',
auto_renew: true,
kwh_included: 20.0,
kwh_used: 13.9023,
kwh_remaining: 6.0977,
in_overage: false,
},
key: { name: 'my-production-key', allowance: null },
} as const;
let ORIGINAL_FETCH: typeof globalThis.fetch;
beforeEach(() => {
ORIGINAL_FETCH = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
});
const stubFetchReturning = (resolver: () => Promise<unknown>): void => {
globalThis.fetch = (async () => resolver()) as typeof fetch;
};
const stubFetchFailing = (json: () => Promise<unknown>, init: MockResponseInit): void => {
globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch;
};
describe('Crof quota provider (VS Code parity)', () => {
test('reports credits balance as valueLabel with null percent', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 450, credits: 12.3456 })));
const result = await fetchQuotaForProvider('crof');
assert.equal(result.ok, true);
assert.equal(result.providerId, 'crof');
assert.equal(result.usage!.windows.credits!.usedPercent, null);
assert.equal(result.usage!.windows.credits!.valueLabel, '$12.35');
});
test('tolerates missing credits field', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 0 })));
const result = await fetchQuotaForProvider('crof');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.credits!.valueLabel, undefined);
assert.equal(result.usage!.windows.credits!.usedPercent, null);
});
test('maps 401 to session-expired with CrofAI branding', async () => {
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
const result = await fetchQuotaForProvider('crof');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'Session expired — please re-authenticate with CrofAI');
});
test('reports invalid-response on JSON 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('crof');
assert.equal(result.ok, false);
assert.equal(result.error, 'Invalid response from provider');
});
});
describe('Codex quota provider (VS Code parity)', () => {
test('surfaces spend_control individual limit for business accounts', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
plan_type: 'business',
rate_limit: null,
credits: { has_credits: true, unlimited: false, balance: null },
spend_control: {
individual_limit: {
limit: '7500',
used: '2674.8724080324173',
remaining: '4825.127591967583',
used_percent: 36,
remaining_percent: 64,
},
},
})));
const result = await fetchQuotaForProvider('codex');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.credits!.usedPercent, 36);
assert.equal(result.usage!.windows.credits!.valueLabel, '2675 / 7500 used');
});
});
describe('Z.ai quota provider (VS Code parity)', () => {
test('surfaces 5-hour, weekly, and MCP quota windows', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
data: {
limits: [
{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 0 },
{ type: 'TOKENS_LIMIT', unit: 6, number: 1, percentage: 100, nextResetTime: 1785659659993 },
{ type: 'TIME_LIMIT', unit: 5, number: 1, percentage: 0, nextResetTime: 1787128459979 },
],
},
})));
const result = await fetchQuotaForProvider('zai-coding-plan');
const windows = result.usage!.windows;
assert.equal(result.ok, true);
assert.equal(windows['5h']!.usedPercent, 0);
assert.equal(windows['5h']!.windowSeconds, 5 * 60 * 60);
assert.equal(windows.weekly!.usedPercent, 100);
assert.equal(windows.weekly!.windowSeconds, 7 * 24 * 60 * 60);
assert.equal(windows.weekly!.resetAt, 1785659659993);
assert.equal(windows['MCP Tools']!.usedPercent, 0);
assert.equal(windows['MCP Tools']!.windowSeconds, 30 * 24 * 60 * 60);
assert.equal(windows['MCP Tools']!.resetAt, 1787128459979);
});
});
describe('NeuralWatt quota provider (VS Code parity)', () => {
test('builds subscription window keyed by plan name (windowSeconds null)', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse(DOCUMENTED_SUBSCRIPTION_PAYLOAD)));
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.ok, true);
assert.equal(result.providerId, 'neuralwatt');
// Subscription window is keyed by the plan name; windowSeconds is null
// because the API exposes no kWh window start to derive duration from.
const window = result.usage!.windows.standard;
assert.ok(window, 'subscription window should be defined');
assert.ok(Math.abs((window.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
assert.equal(window.windowSeconds, null);
assert.equal(window.resetAt, Date.parse('2027-04-11T05:05:25Z'));
// allowance is null → credits_balance also surfaced
assert.ok(result.usage!.windows.credits_balance, 'credits_balance should be defined');
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
});
test('falls back to plan_limit title when plan is missing', async () => {
const payload = {
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, plan: null },
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
assert.ok(result.usage!.windows.plan_limit);
assert.ok(Math.abs((result.usage!.windows.plan_limit!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
});
test('marks in-overage subscription as 100%, still shows credits', async () => {
const payload = {
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, in_overage: true, kwh_used: 25.0 },
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.standard;
assert.ok(window);
assert.equal(window!.usedPercent, 100);
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 () => {
const payload = {
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
balance: { credits_remaining_usd: 200 },
key: {
name: 'Prod',
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const subWindow = result.usage!.windows.standard;
assert.ok(subWindow);
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.
const allowWindow = result.usage!.windows.monthly;
assert.ok(allowWindow);
assert.equal(allowWindow!.usedPercent, 25);
assert.equal(allowWindow!.valueLabel, 'Prod');
assert.equal(allowWindow!.resetAt, Date.parse('2026-08-01T00:00:00Z'));
assert.equal(result.usage!.windows.credits_balance, undefined);
});
test('uses allowance effective limit = min(limit, credits_remaining + spent)', async () => {
const payload = {
balance: { credits_remaining_usd: 30 },
subscription: null,
key: {
name: 'prod-key',
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.monthly;
assert.ok(window);
// effectiveLimit = min(100, 30+25) = 55; usedPercent = 25/55 * 100 ≈ 45.4545
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(result.usage!.windows.credits_balance, undefined);
});
test('binds allowance ceiling to limit when limit < credits_remaining + spent', async () => {
const payload = {
balance: { credits_remaining_usd: 200 },
subscription: null,
key: {
name: 'prod-key',
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.monthly;
assert.ok(window);
assert.equal(window!.usedPercent, 25);
});
test('uses weekly as the allowance key when period is weekly', async () => {
const payload = {
balance: { credits_remaining_usd: 200 },
subscription: null,
key: {
name: 'Prod',
allowance: { limit_usd: 100, period: 'weekly', spent_usd: 20, blocked: false, reset_at: '2026-07-04T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.weekly;
assert.ok(window);
assert.equal(window!.windowSeconds, 604800);
assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z'));
assert.equal(window!.valueLabel, 'Prod');
});
test('uses daily as the allowance key when period is daily', async () => {
const payload = {
balance: { credits_remaining_usd: 200 },
subscription: null,
key: {
name: 'Prod',
allowance: { limit_usd: 10, period: 'daily', spent_usd: 2, blocked: false, reset_at: '2026-07-04T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.daily;
assert.ok(window);
assert.equal(window!.windowSeconds, 86400);
assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z'));
});
test('falls back to billing_cycle when allowance period is missing or unknown', async () => {
const payload = {
balance: { credits_remaining_usd: 200 },
subscription: null,
key: {
name: 'Prod',
allowance: { limit_usd: 100, period: 'fortnightly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.billing_cycle;
assert.ok(window);
assert.equal(window!.usedPercent, 25);
});
test('marks blocked allowance as 100% with valueLabel set', async () => {
const payload = {
balance: { credits_remaining_usd: 30 },
subscription: null,
key: {
name: 'sample',
allowance: { limit_usd: 50, period: 'monthly', spent_usd: 10, blocked: true, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.monthly;
assert.ok(window);
assert.equal(window!.usedPercent, 100);
assert.equal(window!.valueLabel, 'sample');
});
test('falls back to credits_balance when neither subscription nor allowance exists', async () => {
const payload = {
balance: { credits_remaining_usd: 32.6774 },
subscription: null,
key: { name: 'sample', allowance: null },
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
assert.equal(result.usage!.windows.credits_balance!.usedPercent, null);
});
test('maps 401 to session-expired', async () => {
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.ok, false);
assert.equal(result.error, 'Session expired — please re-authenticate with NeuralWatt');
});
test('reports invalid-response on JSON 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('neuralwatt');
assert.equal(result.ok, false);
assert.equal(result.error, 'Invalid response from provider');
});
test('returns no-quota-data on a 200 payload with no usable windows', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
balance: { credits_remaining_usd: null },
subscription: null,
key: { name: 'sample', allowance: null },
})));
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'No quota data in response');
assert.equal(result.usage, null);
});
// Restore fs so other test files (which use the real auth file) are unaffected.
test('teardown: restore fs', () => {
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown };
fsMock.existsSync = ORIGINAL_FS.existsSync;
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
});
});
describe('DeepSeek quota provider (VS Code parity)', () => {
beforeEach(() => {
const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string };
fsMock.existsSync = () => true;
fsMock.readFileSync = () => AUTH;
});
test('builds credits_balance window from documented USD payload (string balance)', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
is_available: true,
balance_infos: [
{ currency: 'USD', total_balance: '7.54', granted_balance: '0.00', topped_up_balance: '7.54' },
],
})));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, true);
assert.equal(result.providerId, 'deepseek');
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$7.54');
assert.equal(result.usage!.windows.credits_balance!.usedPercent, null);
assert.equal(result.usage!.windows.credits_balance!.windowSeconds, null);
assert.equal(result.usage!.windows.credits_balance!.resetAt, null);
});
test('falls back to CNY entry with ¥ symbol when no USD entry is present', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
is_available: true,
balance_infos: [
{ currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' },
],
})));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '¥100.00');
});
test('maps 401 to session-expired', async () => {
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, false);
assert.equal(result.error, 'Session expired — please re-authenticate with DeepSeek');
});
test('reports a normalized timeout error', async () => {
stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError')));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, false);
assert.equal(result.error, 'Request timed out');
});
test('returns no-quota-data on a 200 payload with no usable balance', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
is_available: true,
balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }],
})));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'No quota data in response');
assert.equal(result.usage, null);
});
test('keeps a literal zero balance as a valid valueLabel', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
is_available: true,
balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }],
})));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00');
});
test('teardown: restore fs', () => {
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown };
fsMock.existsSync = ORIGINAL_FS.existsSync;
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
});
});
+494 -36
View File
@@ -1,6 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
import { readCredential } from './quotaCredentials';
type AuthEntry = Record<string, unknown> | string;
type AuthFile = Record<string, AuthEntry>;
@@ -38,6 +40,13 @@ type OpenAiUsagePayload = {
balance?: number | string;
unlimited?: boolean;
};
spend_control?: {
individual_limit?: {
limit?: number | string;
used?: number | string;
used_percent?: number | string;
};
};
};
type GoogleModelsPayload = {
@@ -110,6 +119,47 @@ type WaferPayload = {
plan_tier?: string;
};
type CrofPayload = {
usable_requests?: number | null;
credits?: number | string;
};
type DeepseekPayload = {
is_available?: boolean;
balance_infos?: Array<{
currency?: string;
total_balance?: number | string;
granted_balance?: number | string;
topped_up_balance?: number | string;
}>;
};
type NeuralwattPayload = {
balance?: {
credits_remaining_usd?: number | string;
};
subscription?: {
plan?: string;
billing_interval?: string;
current_period_start?: string;
current_period_end?: string;
kwh_included?: number | string;
kwh_used?: number | string;
in_overage?: boolean;
kwh_reset_date?: string;
} | null;
key?: {
name?: string;
allowance?: {
limit_usd?: number | string;
period?: string;
spent_usd?: number | string;
blocked?: boolean;
reset_at?: string;
} | null;
};
};
export type ProviderResult = {
providerId: string;
providerName: string;
@@ -123,7 +173,6 @@ export type ProviderResult = {
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
const OLLAMA_CLOUD_COOKIE_PATH = path.join(os.homedir(), '.config', 'ollama-quota', 'cookie');
const ANTIGRAVITY_ACCOUNTS_PATHS = [
@@ -183,7 +232,10 @@ const resolveGoogleWindow = (sourceId: GoogleAuthSource['sourceId'], resetAt: nu
return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS } as const;
};
const ZAI_TOKEN_WINDOW_SECONDS: Record<number, number> = { 3: 3600 };
const ZAI_TOKEN_WINDOW_SECONDS: Record<number, number> = {
3: 60 * 60,
6: 7 * 24 * 60 * 60,
};
const readAuthFile = (): AuthFile => {
if (!fs.existsSync(AUTH_FILE)) {
@@ -219,19 +271,6 @@ const readJsonFile = (filePath: string): Record<string, unknown> | null => {
}
};
const readTextFile = (filePath: string): string | null => {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf8').trim();
return content || null;
} catch (error) {
console.warn(`Failed to read text file: ${filePath}`, error);
return null;
}
};
const getAuthEntry = (auth: AuthFile, aliases: string[]) => {
for (const alias of aliases) {
if (auth[alias]) {
@@ -388,6 +427,9 @@ const durationToSeconds = (duration?: number, unit?: string) => {
export const listConfiguredQuotaProviders = () => {
const auth = readAuthFile();
const configured = new Set<string>();
if (readCredential('opencode-go')) configured.add('opencode-go');
if (readCredential('ollama-cloud')) configured.add('ollama-cloud');
if (readCredential('cursor')) configured.add('cursor');
const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude']));
if (anthropicAuth && ((anthropicAuth as Record<string, unknown>).access || (anthropicAuth as Record<string, unknown>).token)) {
@@ -444,15 +486,27 @@ export const listConfiguredQuotaProviders = () => {
configured.add('github-copilot-addon');
}
if (readTextFile(OLLAMA_CLOUD_COOKIE_PATH)) {
configured.add('ollama-cloud');
}
const waferAuth = normalizeAuthEntry(getAuthEntry(auth, ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai']));
if (waferAuth && ((waferAuth as Record<string, unknown>).key || (waferAuth as Record<string, unknown>).token)) {
configured.add('wafer');
}
const crofAuth = normalizeAuthEntry(getAuthEntry(auth, ['crof']));
if (crofAuth && ((crofAuth as Record<string, unknown>).key || (crofAuth as Record<string, unknown>).token)) {
configured.add('crof');
}
const neuralwattAuth = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt']));
if (neuralwattAuth && ((neuralwattAuth as Record<string, unknown>).key || (neuralwattAuth as Record<string, unknown>).token)) {
configured.add('neuralwatt');
}
const deepseekAuth = normalizeAuthEntry(getAuthEntry(auth, ['deepseek']));
if (deepseekAuth && ((deepseekAuth as Record<string, unknown>).key || (deepseekAuth as Record<string, unknown>).token)) {
configured.add('deepseek');
}
return Array.from(configured);
};
@@ -499,16 +553,18 @@ const fetchCodexQuota = async (): Promise<ProviderResult> => {
const windows: Record<string, UsageWindow> = {};
if (primary) {
windows['5h'] = toUsageWindow({
const windowSeconds = toNumber(primary.limit_window_seconds);
windows[resolveWindowLabel(windowSeconds)] = toUsageWindow({
usedPercent: toNumber(primary.used_percent),
windowSeconds: toNumber(primary.limit_window_seconds),
windowSeconds,
resetAt: toTimestamp(primary.reset_at),
});
}
if (secondary) {
windows['weekly'] = toUsageWindow({
const windowSeconds = toNumber(secondary.limit_window_seconds);
windows[resolveWindowLabel(windowSeconds)] = toUsageWindow({
usedPercent: toNumber(secondary.used_percent),
windowSeconds: toNumber(secondary.limit_window_seconds),
windowSeconds,
resetAt: toTimestamp(secondary.reset_at),
});
}
@@ -527,6 +583,20 @@ const fetchCodexQuota = async (): Promise<ProviderResult> => {
valueLabel,
});
}
if (payload?.spend_control?.individual_limit) {
const spendLimit = payload.spend_control.individual_limit;
const used = toNumber(spendLimit.used);
const limit = toNumber(spendLimit.limit);
const valueLabel = used !== null && limit !== null
? `${used.toFixed(0)} / ${limit.toFixed(0)} used`
: null;
windows.credits = toUsageWindow({
usedPercent: toNumber(spendLimit.used_percent),
windowSeconds: null,
resetAt: null,
valueLabel,
});
}
return buildResult({
providerId: 'codex',
@@ -1082,6 +1152,24 @@ const fetchCopilotAddonQuota = async (): Promise<ProviderResult> => {
}
};
// Kimi's weekly `usage` block reports `used`; its rate-limit `limits[].detail`
// blocks report `remaining` instead. Neither field is guaranteed present, so
// derive usedPercent from whichever one the API actually returned.
const computeKimiUsedPercent = (
total: number | null,
used: number | null,
remaining: number | null,
): number | null => {
if (!total) return null;
if (used !== null) {
return Math.max(0, Math.min(100, (used / total) * 100));
}
if (remaining !== null) {
return Math.max(0, Math.min(100, 100 - (remaining / total) * 100));
}
return null;
};
const fetchKimiQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])) as Record<string, unknown> | null;
@@ -1121,10 +1209,9 @@ const fetchKimiQuota = async (): Promise<ProviderResult> => {
const usage = payload.usage as Record<string, unknown> | undefined;
if (usage) {
const limit = toNumber(usage.limit);
const used = toNumber(usage.used);
const remaining = toNumber(usage.remaining);
const usedPercent = limit && remaining !== null
? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100))
: null;
const usedPercent = computeKimiUsedPercent(limit, used, remaining);
windows.weekly = toUsageWindow({
usedPercent,
windowSeconds: null,
@@ -1140,10 +1227,9 @@ const fetchKimiQuota = async (): Promise<ProviderResult> => {
const windowSeconds = durationToSeconds(window?.duration as number | undefined, window?.timeUnit as string | undefined);
const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel;
const total = toNumber(detail?.limit);
const used = toNumber(detail?.used);
const remaining = toNumber(detail?.remaining);
const usedPercent = total && remaining !== null
? Math.max(0, Math.min(100, 100 - (remaining / total) * 100))
: null;
const usedPercent = computeKimiUsedPercent(total, used, remaining);
windows[label] = toUsageWindow({
usedPercent,
windowSeconds,
@@ -1344,7 +1430,7 @@ const parseOllamaSettingsHtml = (html: string) => {
};
const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
const cookie = readTextFile(OLLAMA_CLOUD_COOKIE_PATH);
const cookie = readCredential('ollama-cloud')?.cookie;
if (!cookie) {
return buildResult({
@@ -1393,6 +1479,19 @@ const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
}
};
const fetchCursorQuota = async (): Promise<ProviderResult> => {
const accessToken = readCredential('cursor')?.accessToken;
if (!accessToken) return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: false, error: 'Not configured' });
try {
const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error(response.status === 401 ? 'Cursor session expired' : `API error: ${response.status}`);
const payload = await response.json() as Record<string, unknown>;
const plan = (payload.planUsage as Record<string, unknown> | undefined) ?? {};
const usedPercent = toNumber(plan.totalPercentUsed);
return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: true, configured: true, usage: { windows: { billing_cycle: toUsageWindow({ usedPercent, windowSeconds: null, resetAt: toTimestamp(payload.billingCycleEnd) }) } } });
} catch (error) { return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); }
};
const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record<string, unknown> | null;
@@ -1527,14 +1626,13 @@ const fetchZaiQuota = async (): Promise<ProviderResult> => {
const payload = await response.json() as ZaiPayload;
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
const tokensLimit = limits.find((limit: Record<string, unknown>) => limit?.type === 'TOKENS_LIMIT');
const windowSeconds = resolveWindowSeconds(tokensLimit as Record<string, unknown> | undefined);
const windowLabel = resolveWindowLabel(windowSeconds);
const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null;
const windows: Record<string, UsageWindow> = {};
if (tokensLimit) {
for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) {
const windowSeconds = resolveWindowSeconds(tokensLimit as Record<string, unknown>);
const windowLabel = resolveWindowLabel(windowSeconds);
const resetAt = tokensLimit.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
const usedPercent = typeof tokensLimit.percentage === 'number' ? tokensLimit.percentage : null;
windows[windowLabel] = toUsageWindow({
usedPercent,
windowSeconds,
@@ -1542,6 +1640,15 @@ const fetchZaiQuota = async (): Promise<ProviderResult> => {
});
}
const mcpToolsTimeLimit = limits.find((limit) => limit?.type === 'TIME_LIMIT');
if (mcpToolsTimeLimit) {
windows['MCP Tools'] = toUsageWindow({
usedPercent: typeof mcpToolsTimeLimit.percentage === 'number' ? mcpToolsTimeLimit.percentage : null,
windowSeconds: 30 * 24 * 60 * 60,
resetAt: mcpToolsTimeLimit.nextResetTime ? normalizeTimestamp(mcpToolsTimeLimit.nextResetTime) : null,
});
}
return buildResult({
providerId: 'zai-coding-plan',
providerName: 'z.ai',
@@ -1862,6 +1969,340 @@ const fetchWaferQuota = async (): Promise<ProviderResult> => {
}
};
const NEURALWATT_QUOTA_URL = 'https://api.neuralwatt.com/v1/quota';
// 30d month / 365d year are fixed approximations; real calendars vary but the
// window is for the UI's progress bar label, not billing decisions.
// Accepts both subscription (month/year) and allowance (monthly/weekly/daily) shapes.
const neuralwattWindowSeconds = (period: string | null | undefined): number | null => {
if (period === 'daily') return 86400;
if (period === 'weekly') return 604800;
if (period === 'monthly' || period === 'month') return 30 * 86400;
if (period === 'yearly' || period === 'year') return 365 * 86400;
return null;
};
const fetchNeuralwattQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt'])) as Record<string, unknown> | null;
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
if (!apiKey) {
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: false,
configured: false,
error: 'Not configured',
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetch(NEURALWATT_QUOTA_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: false,
configured: true,
error: response.status === 401
? 'Session expired — please re-authenticate with NeuralWatt'
: `API error: ${response.status}`,
});
}
const payload = await response.json() as NeuralwattPayload;
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> = {};
if (subscription) {
const kwhIncluded = toNumber(subscription.kwh_included);
const kwhUsed = toNumber(subscription.kwh_used);
const plan = typeof subscription.plan === 'string' && subscription.plan.trim()
? subscription.plan.trim()
: null;
// Subscription window title is the plan name; subscription limits reset
// monthly even on annual billing plans, but the API exposes no kWh window
// start to derive windowSeconds — pass null rather than fabricating a guess.
const subKey = plan ?? 'plan_limit';
const usedPercent = inOverage
? 100
: (kwhIncluded !== null && kwhIncluded > 0 && kwhUsed !== null
? Math.max(0, Math.min(100, (kwhUsed / kwhIncluded) * 100))
: null);
const subResetAt = toTimestamp(subscription.kwh_reset_date) ?? toTimestamp(subscription.current_period_end);
windows[subKey] = toUsageWindow({
usedPercent,
windowSeconds: null,
resetAt: subResetAt,
});
}
if (allowance) {
const spent = toNumber(allowance.spent_usd);
const limit = toNumber(allowance.limit_usd);
// Credits wallet is reduced by each period's spend before the allowance cap
// bites, so the real ceiling is min(limit, creditsRemaining + spent).
const effectiveSpent = spent ?? 0;
const effectiveLimit = limit !== null && creditsRemaining !== null
? Math.min(limit, creditsRemaining + effectiveSpent)
: (limit ?? creditsRemaining);
const period = typeof allowance.period === 'string' && allowance.period.trim()
? allowance.period.trim()
: null;
const blocked = Boolean(allowance.blocked);
const usedPercent = blocked
? 100
: (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).
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({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel: `$${formatMoney(creditsRemaining)}`,
});
}
if (Object.keys(windows).length === 0) {
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: true,
configured: true,
usage: { windows },
});
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted;
const isParseError = error instanceof SyntaxError;
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed'),
});
}
};
const CROF_USAGE_URL = 'https://crof.ai/usage_api/';
const fetchCrofQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['crof'])) as Record<string, unknown> | null;
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
if (!apiKey) {
return buildResult({
providerId: 'crof',
providerName: 'CrofAI',
ok: false,
configured: false,
error: 'Not configured',
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetch(CROF_USAGE_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
return buildResult({
providerId: 'crof',
providerName: 'CrofAI',
ok: false,
configured: true,
error: response.status === 401
? 'Session expired — please re-authenticate with CrofAI'
: `API error: ${response.status}`,
});
}
const payload = await response.json() as CrofPayload;
const credits = toNumber(payload?.credits);
const valueLabel = credits !== null ? `$${formatMoney(credits)}` : null;
const windows: Record<string, UsageWindow> = {
credits: toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel,
}),
};
return buildResult({
providerId: 'crof',
providerName: 'CrofAI',
ok: true,
configured: true,
usage: { windows },
});
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted;
const isParseError = error instanceof SyntaxError;
return buildResult({
providerId: 'crof',
providerName: 'CrofAI',
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> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['deepseek'])) as Record<string, unknown> | null;
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
if (!apiKey) {
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
ok: false,
configured: false,
error: 'Not configured',
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetch(DEEPSEEK_QUOTA_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
ok: false,
configured: true,
error: response.status === 401 || response.status === 403
? 'Session expired — please re-authenticate with DeepSeek'
: `API error: ${response.status}`,
});
}
const payload = await response.json() as DeepseekPayload;
const balanceInfos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : [];
const balanceInfo = balanceInfos.find((info) => info?.currency === 'USD')
?? balanceInfos.find((info) => info?.currency === 'CNY')
?? null;
const rawBalance = balanceInfo?.total_balance;
const totalBalance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== ''))
? toNumber(rawBalance)
: null;
if (totalBalance === null) {
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
const symbol = balanceInfo?.currency === 'CNY' ? '¥' : '$';
const windows: Record<string, UsageWindow> = {
credits_balance: toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel: `${symbol}${formatMoney(totalBalance)}`,
}),
};
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
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: 'deepseek',
providerName: 'DeepSeek',
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed'),
});
}
};
export const fetchQuotaForProvider = async (providerId: string): Promise<ProviderResult> => {
switch (providerId) {
case 'claude':
@@ -1892,6 +2333,23 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
return fetchZhipuaiCodingPlanQuota();
case 'wafer':
return fetchWaferQuota();
case 'opencode-go': {
const credential = readCredential('opencode-go') as { workspaceId: string; authCookie: string } | null;
if (!credential) return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: false, error: 'Not configured' });
try {
return buildResult({ providerId, providerName: 'OpenCode Go', ok: true, configured: true, usage: { windows: await fetchOpenCodeGoUsage(credential) } });
} catch (error) {
return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
}
}
case 'cursor':
return fetchCursorQuota();
case 'crof':
return fetchCrofQuota();
case 'deepseek':
return fetchDeepseekQuota();
case 'neuralwatt':
return fetchNeuralwattQuota();
default:
return buildResult({
providerId,
+101
View File
@@ -0,0 +1,101 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import type { OpenCodeManager } from './opencode';
import { openSseProxy } from './sseProxy';
const createManager = (): OpenCodeManager => ({
start: async () => {},
stop: async () => {},
restart: async () => {},
setWorkingDirectory: async (path) => ({ success: true, path }),
getStatus: () => 'connected',
getApiUrl: () => 'http://127.0.0.1:3902',
getOpenCodeAuthHeaders: () => ({}),
getWorkingDirectory: () => '/workspace',
isCliAvailable: () => true,
getDebugInfo: () => ({
mode: 'managed',
status: 'connected',
workingDirectory: '/workspace',
cliAvailable: true,
cliPath: null,
configuredApiUrl: null,
configuredPort: null,
detectedPort: 3902,
apiPrefix: '',
apiPrefixDetected: true,
startCount: 1,
restartCount: 0,
lastStartAt: null,
lastConnectedAt: null,
lastExitCode: null,
serverUrl: 'http://127.0.0.1:3902',
lastReadyElapsedMs: null,
lastReadyAttempts: null,
lastStartAttempts: null,
version: null,
secureConnection: false,
authSource: null,
}),
onStatusChange: (callback) => {
callback('connected');
return { dispose: () => {} };
},
});
describe('VS Code SSE proxy', () => {
test('closes a quiet upstream SSE stream after the stall timeout', async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({}), {
status: 200,
headers: { 'content-type': 'text/event-stream' },
})) as typeof fetch;
const controller = new AbortController();
const proxy = await openSseProxy({
manager: createManager(),
path: '/global/event',
signal: controller.signal,
stallTimeoutMs: 20,
onChunk: () => assert.fail('quiet stream should not emit chunks'),
});
await assert.doesNotReject(proxy.run);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resets the stall timeout when upstream bytes arrive', async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
start(controller) {
setTimeout(() => controller.enqueue(new TextEncoder().encode(':first\n\n')), 5);
setTimeout(() => controller.enqueue(new TextEncoder().encode('data: second\n\n')), 15);
},
}), {
status: 200,
headers: { 'content-type': 'text/event-stream' },
})) as typeof fetch;
const chunks: string[] = [];
const controller = new AbortController();
const proxy = await openSseProxy({
manager: createManager(),
path: '/global/event',
signal: controller.signal,
stallTimeoutMs: 18,
onChunk: (chunk) => chunks.push(chunk),
});
await assert.doesNotReject(proxy.run);
assert.deepEqual(chunks, [':first\n\n', 'data: second\n\n']);
} finally {
globalThis.fetch = originalFetch;
}
});
});
+44 -3
View File
@@ -7,6 +7,7 @@ type OpenSseProxyOptions = {
headers?: Record<string, string>;
signal: AbortSignal;
onChunk: (chunk: string) => void;
stallTimeoutMs?: number;
};
type OpenSseProxyResult = {
@@ -22,6 +23,7 @@ const SSE_RESPONSE_HEADERS = {
// SSE reconnect configuration
const MAX_RECONNECTS = 3;
const BASE_RECONNECT_DELAY = 1000; // 1 second
const DEFAULT_UPSTREAM_STALL_TIMEOUT_MS = 20000;
const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((resolve) => {
if (signal.aborted) {
@@ -117,21 +119,54 @@ const fetchSseResponse = async (
return response;
};
const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk: (chunk: string) => void): Promise<void> => {
const resolveStallTimeoutMs = (value: number | undefined): number => (
Number.isFinite(value) && typeof value === 'number' ? value : DEFAULT_UPSTREAM_STALL_TIMEOUT_MS
);
const pipeSseResponse = async (
response: Response,
signal: AbortSignal,
onChunk: (chunk: string) => void,
stallTimeoutMs?: number,
): Promise<void> => {
if (!response.body) {
throw new Error('OpenCode SSE response missing body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let stalled = false;
let stallTimer: ReturnType<typeof setTimeout> | null = null;
const clearStallTimer = () => {
if (!stallTimer) {
return;
}
clearTimeout(stallTimer);
stallTimer = null;
};
const resetStallTimer = () => {
clearStallTimer();
const timeoutMs = resolveStallTimeoutMs(stallTimeoutMs);
if (timeoutMs <= 0) {
return;
}
stallTimer = setTimeout(() => {
stalled = true;
void reader.cancel().catch(() => {});
}, timeoutMs);
};
try {
resetStallTimer();
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (value && value.length > 0) {
resetStallTimer();
const chunk = decoder.decode(value, { stream: true });
if (chunk.length > 0) {
onChunk(chunk);
@@ -143,7 +178,12 @@ const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk:
if (!signal.aborted && remaining.length > 0) {
onChunk(remaining);
}
} catch (error) {
if (!stalled) {
throw error;
}
} finally {
clearStallTimer();
try {
await reader.cancel();
} catch {
@@ -163,6 +203,7 @@ export const openSseProxy = async ({
headers,
signal,
onChunk,
stallTimeoutMs,
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
// Reconnect logic with exponential backoff
let reconnectAttempts = 0;
@@ -208,7 +249,7 @@ export const openSseProxy = async ({
const run = (async () => {
let activeResponse = response;
try {
await pipeSseResponse(activeResponse, signal, onChunk);
await pipeSseResponse(activeResponse, signal, onChunk, stallTimeoutMs);
} catch (error: unknown) {
const cause = (error as { cause?: { code?: string } } | null)?.cause;
@@ -228,7 +269,7 @@ export const openSseProxy = async ({
// Attempt to reconnect
try {
activeResponse = await connect();
await pipeSseResponse(activeResponse, signal, onChunk);
await pipeSseResponse(activeResponse, signal, onChunk, stallTimeoutMs);
return; // Successfully reconnected
} catch (reconnectError) {
console.error('[SSE] Reconnect failed', reconnectError);
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
const source = readFileSync(new URL('./webviewHtml.ts', import.meta.url), 'utf8');
describe('VS Code webview content security policy', () => {
test('allows blob URLs for workers without allowing blob scripts', () => {
const workerSource = source.match(/const workerSrc = ([^\n]+);/)?.[1] ?? '';
const scriptSource = source.match(/const scriptSrc = ([^\n]+);/)?.[1] ?? '';
assert.match(workerSource, /'blob:'/);
assert.doesNotMatch(scriptSource, /'blob:'/);
assert.match(source, /worker-src \$\{workerSrc\}/);
});
});
+4 -1
View File
@@ -68,6 +68,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]);
const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]);
const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]);
// fflate's async browser inflater creates blob-backed workers. Keep blob:
// scoped to worker-src so document decompression works without allowing blob scripts.
const workerSrc = uniqueTokens([webview.cspSource, 'blob:', devServerOrigin]);
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
@@ -84,7 +87,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc};">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc}; worker-src ${workerSrc};">
<style>
html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; }
body {