Merge branch 'main' into reproduce/issue-1720
Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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('"', '"').replaceAll('"', '"').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;
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user