Decouple bundled UI from runtime API and add remote instance tooling (#1228)

Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
@@ -0,0 +1,244 @@
import { afterEach, describe, expect, mock, test } from 'bun:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
mock.module('vscode', () => ({
workspace: {
workspaceFolders: [],
getConfiguration: () => ({ get: () => undefined }),
},
}));
const { handleConfigBridgeMessage } = await import('./bridge-config-runtime.ts');
const tempRoots = [];
const originalOpencodeConfig = process.env.OPENCODE_CONFIG;
const createCtx = (workingDirectory, restartImpl = async () => undefined) => {
const restart = mock(restartImpl);
return {
restart,
manager: {
getWorkingDirectory: () => workingDirectory,
restart,
},
};
};
const deps = {
readSettings: () => ({}),
persistSettings: async (changes) => changes,
readMagicPromptOverrides: () => ({ version: 1, overrides: {} }),
saveMagicPromptOverride: async () => ({ version: 1, overrides: {} }),
resetMagicPromptOverride: async () => ({ version: 1, overrides: {} }),
resetAllMagicPromptOverrides: async () => ({ version: 1, overrides: {} }),
fetchOpenCodeSkillsFromApi: async () => null,
clientReloadDelayMs: 800,
};
afterEach(() => {
if (originalOpencodeConfig === undefined) {
delete process.env.OPENCODE_CONFIG;
} else {
process.env.OPENCODE_CONFIG = originalOpencodeConfig;
}
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
describe('VS Code config bridge plugin parity', () => {
test('creates, lists, updates, and deletes project plugin entries', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugins-'));
tempRoots.push(root);
const ctx = createCtx(root);
const created = await handleConfigBridgeMessage({
id: 'create',
type: 'api:config/plugins',
payload: {
method: 'POST',
target: 'entry',
directory: root,
body: { scope: 'project', spec: 'plugin-a', options: { enabled: true } },
},
}, ctx, deps);
expect(created?.success).toBe(true);
expect(ctx.restart).toHaveBeenCalledTimes(1);
const listed = await handleConfigBridgeMessage({
id: 'list',
type: 'api:config/plugins',
payload: { method: 'GET', target: 'list', directory: root },
}, ctx, deps);
const entries = listed?.data?.entries || [];
const entry = entries.find((candidate) => candidate.spec === 'plugin-a');
expect(entry?.scope).toBe('project');
const updated = await handleConfigBridgeMessage({
id: 'update',
type: 'api:config/plugins',
payload: {
method: 'PATCH',
target: 'entry',
directory: root,
pluginId: entry?.id,
body: { spec: 'plugin-b' },
},
}, ctx, deps);
expect(updated?.success).toBe(true);
const config = JSON.parse(fs.readFileSync(path.join(root, '.opencode', 'opencode.json'), 'utf8'));
expect(config.plugin).toEqual([['plugin-b', { enabled: true }]]);
const relisted = await handleConfigBridgeMessage({
id: 'relist',
type: 'api:config/plugins',
payload: { method: 'GET', target: 'list', directory: root },
}, ctx, deps);
const updatedEntry = (relisted?.data?.entries || []).find((candidate) => candidate.spec === 'plugin-b');
const deleted = await handleConfigBridgeMessage({
id: 'delete',
type: 'api:config/plugins',
payload: { method: 'DELETE', target: 'entry', directory: root, pluginId: updatedEntry?.id },
}, ctx, deps);
expect(deleted?.success).toBe(true);
});
test('creates and reads project plugin files', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-files-'));
tempRoots.push(root);
const ctx = createCtx(root);
const created = await handleConfigBridgeMessage({
id: 'create-file',
type: 'api:config/plugins',
payload: {
method: 'POST',
target: 'file',
directory: root,
body: { scope: 'project', fileName: 'demo-plugin.ts', content: 'export default {}' },
},
}, ctx, deps);
expect(created?.success).toBe(true);
const listed = await handleConfigBridgeMessage({
id: 'list',
type: 'api:config/plugins',
payload: { method: 'GET', target: 'list', directory: root },
}, ctx, deps);
const files = listed?.data?.files || [];
const file = files.find((candidate) => candidate.fileName === 'demo-plugin.ts');
expect(file?.scope).toBe('project');
const read = await handleConfigBridgeMessage({
id: 'read-file',
type: 'api:config/plugins',
payload: { method: 'GET', target: 'file', directory: root, pluginId: file?.id },
}, ctx, deps);
expect(read?.data).toEqual({ fileName: 'demo-plugin.ts', scope: 'project', content: 'export default {}' });
});
test('updates and deletes user plugin entries from OPENCODE_CONFIG source', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-custom-config-'));
tempRoots.push(root);
const configDir = path.join(root, 'custom-config');
const configPath = path.join(configDir, 'opencode.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ plugin: ['custom-plugin'] }, null, 2), 'utf8');
process.env.OPENCODE_CONFIG = configPath;
const ctx = createCtx(root);
const listed = await handleConfigBridgeMessage({
id: 'list-custom',
type: 'api:config/plugins',
payload: { method: 'GET', target: 'list', directory: root },
}, ctx, deps);
const entry = (listed?.data?.entries || []).find((candidate) => candidate.spec === 'custom-plugin');
expect(entry?.scope).toBe('user');
const updated = await handleConfigBridgeMessage({
id: 'update-custom',
type: 'api:config/plugins',
payload: {
method: 'PATCH',
target: 'entry',
directory: root,
pluginId: entry?.id,
body: { spec: 'custom-plugin-next' },
},
}, ctx, deps);
expect(updated?.success).toBe(true);
expect(readJson(configPath).plugin).toEqual(['custom-plugin-next']);
const relisted = await handleConfigBridgeMessage({
id: 'relist-custom',
type: 'api:config/plugins',
payload: { method: 'GET', target: 'list', directory: root },
}, ctx, deps);
const updatedEntry = (relisted?.data?.entries || []).find((candidate) => candidate.spec === 'custom-plugin-next');
const deleted = await handleConfigBridgeMessage({
id: 'delete-custom',
type: 'api:config/plugins',
payload: { method: 'DELETE', target: 'entry', directory: root, pluginId: updatedEntry?.id },
}, ctx, deps);
expect(deleted?.success).toBe(true);
expect(readJson(configPath).plugin).toBeUndefined();
});
test('writes user plugin files next to OPENCODE_CONFIG', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-custom-files-'));
tempRoots.push(root);
const configDir = path.join(root, 'custom-config');
const configPath = path.join(configDir, 'opencode.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, '{}', 'utf8');
process.env.OPENCODE_CONFIG = configPath;
const ctx = createCtx(root);
const created = await handleConfigBridgeMessage({
id: 'create-custom-file',
type: 'api:config/plugins',
payload: {
method: 'POST',
target: 'file',
directory: root,
body: { scope: 'user', fileName: 'demo-plugin.ts', content: 'export default {}' },
},
}, ctx, deps);
expect(created?.success).toBe(true);
expect(fs.readFileSync(path.join(configDir, 'plugins', 'demo-plugin.ts'), 'utf8')).toBe('export default {}');
});
test('reports plugin mutation success when restart fails after writing config', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-restart-'));
tempRoots.push(root);
const ctx = createCtx(root, async () => {
throw new Error('restart failed');
});
const created = await handleConfigBridgeMessage({
id: 'create-restart-failure',
type: 'api:config/plugins',
payload: {
method: 'POST',
target: 'entry',
directory: root,
body: { scope: 'project', spec: 'plugin-restart' },
},
}, ctx, deps);
expect(created?.success).toBe(true);
expect(created?.data).toMatchObject({ success: true, requiresReload: false, reloadFailed: true });
expect(created?.data?.warning).toContain('restart failed');
expect(readJson(path.join(root, '.opencode', 'opencode.json')).plugin).toEqual(['plugin-restart']);
});
});
@@ -5,12 +5,16 @@ import * as path from 'path';
import {
createAgent,
createCommand,
createSnippet,
deleteAgent,
deleteCommand,
deleteSnippet,
getAgentSources,
getCommandSources,
getSnippet,
updateAgent,
updateCommand,
updateSnippet,
type AgentScope,
type CommandScope,
AGENT_SCOPE,
@@ -28,10 +32,23 @@ import {
type DiscoveredSkill,
SKILL_SCOPE,
listMcpConfigs,
listPluginDirFiles,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
queryPluginRegistry,
listSnippets,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
expandSnippets,
type SnippetScope,
} from './opencodeConfig';
import {
getSkillsCatalog,
@@ -67,6 +84,32 @@ const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: str
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath)
);
const pluginMutationPayload = async (
ctx: BridgeContext | undefined,
deps: ConfigRuntimeDeps,
label: string,
) => {
try {
await ctx?.manager?.restart();
return {
success: true,
requiresReload: true,
message: `${label}. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
reloadFailed: false,
};
} catch (error) {
return {
success: true,
requiresReload: false,
message: `${label}, but OpenCode reload failed.`,
reloadDelayMs: deps.clientReloadDelayMs,
reloadFailed: true,
warning: error instanceof Error ? error.message : String(error),
};
}
};
const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCatalogSourceConfig[] => {
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
if (!Array.isArray(rawCatalogs)) {
@@ -462,6 +505,144 @@ export async function handleConfigBridgeMessage(
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/plugins': {
const { method, target, pluginId, body, directory, specs, refresh } = (payload || {}) as {
method?: string;
target?: 'list' | 'registry' | 'entry' | 'file';
pluginId?: string;
body?: Record<string, unknown>;
directory?: string;
specs?: string[];
refresh?: boolean;
};
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
const workingDirectory = resolveWorkingDirectory(ctx, directory);
if ((target === 'list' || !target) && normalizedMethod === 'GET') {
return {
id,
type,
success: true,
data: {
entries: listPluginEntries(workingDirectory),
files: listPluginDirFiles(workingDirectory),
},
};
}
if (target === 'registry' && normalizedMethod === 'GET') {
const data = await queryPluginRegistry(Array.isArray(specs) ? specs : [], {
refresh: refresh === true,
workingDirectory,
});
return { id, type, success: true, data };
}
if (target === 'entry') {
if (normalizedMethod === 'GET') {
if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' };
const entry = getPluginEntry(pluginId, workingDirectory);
if (!entry) return { id, type, success: false, error: 'Plugin entry not found' };
return { id, type, success: true, data: entry };
}
if (normalizedMethod === 'POST') {
createPluginEntry(body || {}, workingDirectory);
} else if (normalizedMethod === 'PATCH') {
if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' };
updatePluginEntry(pluginId, body || {}, workingDirectory);
} else if (normalizedMethod === 'DELETE') {
if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' };
deletePluginEntry(pluginId, workingDirectory);
} else {
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
return {
id,
type,
success: true,
data: await pluginMutationPayload(ctx, deps, 'Plugin entry changed'),
};
}
if (target === 'file') {
if (normalizedMethod === 'GET') {
if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' };
const file = readPluginDirFile(pluginId, workingDirectory);
if (!file) return { id, type, success: false, error: 'Plugin file not found' };
return { id, type, success: true, data: file };
}
if (normalizedMethod === 'POST') {
writePluginDirFile(body || {}, workingDirectory);
} else if (normalizedMethod === 'PUT') {
if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' };
const existing = readPluginDirFile(pluginId, workingDirectory);
if (!existing) return { id, type, success: false, error: 'Plugin file not found' };
writePluginDirFile({ fileName: existing.fileName, scope: existing.scope, content: body?.content }, workingDirectory, { overwrite: true });
} else if (normalizedMethod === 'DELETE') {
if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' };
deletePluginDirFile(pluginId, workingDirectory);
} else {
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
return {
id,
type,
success: true,
data: await pluginMutationPayload(ctx, deps, 'Plugin file changed'),
};
}
return { id, type, success: false, error: 'Unsupported plugin config request' };
}
case 'api:config/snippets': {
const { method, name, body, directory } = (payload || {}) as {
method?: string;
name?: string;
body?: Record<string, unknown>;
directory?: string;
};
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
const snippetName = typeof name === 'string' ? name.trim() : '';
const workingDirectory = resolveWorkingDirectory(ctx, directory);
if (normalizedMethod === 'GET' && !snippetName) {
return { id, type, success: true, data: listSnippets(workingDirectory) };
}
if (normalizedMethod === 'POST' && !snippetName) {
return { id, type, success: true, data: { text: expandSnippets(typeof body?.text === 'string' ? body.text : '', workingDirectory) } };
}
if (!snippetName) {
return { id, type, success: false, error: 'Snippet name is required' };
}
if (normalizedMethod === 'GET') {
const snippet = getSnippet(snippetName, workingDirectory);
if (!snippet) return { id, type, success: false, error: `Snippet "${snippetName}" not found` };
return { id, type, success: true, data: snippet };
}
if (normalizedMethod === 'POST') {
const scope = body?.scope === 'project' ? 'project' : 'global';
const snippet = createSnippet(snippetName, (body || {}) as Record<string, unknown>, workingDirectory, scope as SnippetScope);
return { id, type, success: true, data: { success: true, snippet } };
}
if (normalizedMethod === 'PATCH') {
const snippet = updateSnippet(snippetName, (body || {}) as Record<string, unknown>, workingDirectory);
return { id, type, success: true, data: { success: true, snippet } };
}
if (normalizedMethod === 'DELETE') {
deleteSnippet(snippetName, workingDirectory);
return { id, type, success: true, data: { success: true } };
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/skills': {
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
const gitService = {
getGitRangeFiles: mock(),
getGitRangeDiff: mock(),
};
const sdkClient = {
v2: {
model: {
list: mock(),
},
},
session: {
create: mock(),
promptAsync: mock(),
messages: mock(),
delete: mock(),
},
};
const createOpencodeClient = mock(() => sdkClient);
const rawFetch = mock(async () => {
throw new Error('raw fetch should not be used');
});
mock.module('./gitService', () => gitService);
mock.module('@opencode-ai/sdk/v2', () => ({ createOpencodeClient }));
const { handleSpecialGitBridgeMessage } = await import('./bridge-git-special-runtime');
describe('bridge git special runtime', () => {
beforeEach(() => {
gitService.getGitRangeFiles.mockReset();
gitService.getGitRangeDiff.mockReset();
sdkClient.v2.model.list.mockReset();
sdkClient.session.create.mockReset();
sdkClient.session.promptAsync.mockReset();
sdkClient.session.messages.mockReset();
sdkClient.session.delete.mockReset();
createOpencodeClient.mockReset();
rawFetch.mockClear();
globalThis.fetch = rawFetch;
createOpencodeClient.mockImplementation(() => sdkClient);
gitService.getGitRangeFiles.mockImplementation(async () => ['src/a.ts']);
gitService.getGitRangeDiff.mockImplementation(async () => ({ diff: 'diff --git a/src/a.ts b/src/a.ts\n+new line' }));
sdkClient.v2.model.list.mockImplementation(async () => ({
data: [{ providerID: 'anthropic', id: 'claude-sonnet-4-5' }],
error: undefined,
}));
sdkClient.session.create.mockImplementation(async () => ({
data: { id: 'ses_1' },
error: undefined,
}));
sdkClient.session.promptAsync.mockImplementation(async () => ({ data: true, error: undefined }));
sdkClient.session.messages.mockImplementation(async () => ({
data: [{
info: { role: 'assistant', finish: 'stop' },
parts: [{ type: 'text', text: '{"title":"PR title","body":"PR body"}' }],
}],
error: undefined,
}));
sdkClient.session.delete.mockImplementation(async () => ({ data: true, error: undefined }));
});
it('generates PR descriptions through the OpenCode SDK session flow', async () => {
const response = await handleSpecialGitBridgeMessage({
id: '1',
type: 'api:git/pr-description',
payload: {
directory: '/repo',
base: 'main',
head: 'feature',
providerId: 'anthropic',
modelId: 'claude-sonnet-4-5',
},
}, {
manager: {
getApiUrl: () => 'http://opencode.test',
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test' }),
},
}, {
readSettings: () => ({}),
execGit: mock(),
});
expect(response).toEqual({
id: '1',
type: 'api:git/pr-description',
success: true,
data: { title: 'PR title', body: 'PR body' },
});
expect(rawFetch).not.toHaveBeenCalled();
expect(createOpencodeClient).toHaveBeenCalledWith({
baseUrl: 'http://opencode.test',
headers: { Authorization: 'Bearer test' },
});
expect(sdkClient.v2.model.list).toHaveBeenCalled();
expect(sdkClient.session.create).toHaveBeenCalledWith({
directory: '/repo',
title: 'Git Generation',
}, expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(sdkClient.session.promptAsync).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_1',
directory: '/repo',
model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' },
}), expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(sdkClient.session.messages).toHaveBeenCalledWith({
sessionID: 'ses_1',
directory: '/repo',
limit: 10,
}, expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(sdkClient.session.delete).toHaveBeenCalledWith({ sessionID: 'ses_1' }, expect.objectContaining({ signal: expect.any(AbortSignal) }));
});
});
@@ -1,5 +1,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import * as gitService from './gitService';
import type { BridgeContext, BridgeResponse } from './bridge';
@@ -28,6 +29,48 @@ const sleep = (ms: number) => new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
type BridgeSdkResult<T> = {
data?: T;
error?: unknown;
response?: { status?: number };
};
const formatBridgeSdkError = (error: unknown): string => {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') {
return (error as { message: string }).message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
};
const unwrapBridgeSdkData = <T,>(result: BridgeSdkResult<T>, operation: string): T => {
if (result.error) {
const status = result.response?.status;
throw new Error(`${operation} failed${status ? ` (${status})` : ''}: ${formatBridgeSdkError(result.error)}`);
}
if (result.data === undefined || result.data === null) {
throw new Error(`${operation} failed: empty response`);
}
return result.data;
};
const assertBridgeSdkSuccess = (result: BridgeSdkResult<unknown>, operation: string): void => {
if (result.error) {
const status = result.response?.status;
throw new Error(`${operation} failed${status ? ` (${status})` : ''}: ${formatBridgeSdkError(result.error)}`);
}
};
const createBridgeGitClient = (apiUrl: string, authHeaders?: Record<string, string>) => createOpencodeClient({
baseUrl: apiUrl.replace(/\/+$/, ''),
headers: authHeaders || {},
});
const readStringField = (value: unknown, key: string): string => {
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
@@ -44,22 +87,11 @@ const fetchBridgeGitModelCatalog = async (
return bridgeGitModelCatalogCache;
}
const headers = authHeaders || {};
const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`);
const response = await fetch(modelsUrl.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
...headers,
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
throw new Error('Failed to fetch model catalog');
}
const payload = await response.json().catch(() => null) as unknown;
const client = createBridgeGitClient(apiUrl, authHeaders);
const payload = unwrapBridgeSdkData(
await client.v2.model.list(undefined, { signal: AbortSignal.timeout(8_000) }),
'model.list'
);
const refs = new Set<string>();
if (Array.isArray(payload)) {
for (const item of payload) {
@@ -68,7 +100,9 @@ const fetchBridgeGitModelCatalog = async (
}
const record = item as Record<string, unknown>;
const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : '';
const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : '';
const modelID = typeof record.id === 'string'
? record.id.trim()
: (typeof record.modelID === 'string' ? record.modelID.trim() : '');
if (providerID && modelID) {
refs.add(`${providerID}/${modelID}`);
}
@@ -153,33 +187,19 @@ const generateBridgeTextWithSessionFlow = async ({
modelID: string;
authHeaders?: Record<string, string>;
}): Promise<string> => {
const headers = authHeaders || {};
const apiBase = apiUrl.replace(/\/+$/, '');
const client = createBridgeGitClient(apiUrl, authHeaders);
const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS;
const remainingMs = () => Math.max(1_000, deadlineAt - Date.now());
let sessionId: string | null = null;
try {
const sessionUrl = new URL(`${apiBase}/session`);
if (directory) {
sessionUrl.searchParams.set('directory', directory);
}
const createResponse = await fetch(sessionUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify({ title: 'Git Generation' }),
signal: AbortSignal.timeout(remainingMs()),
});
if (!createResponse.ok) {
throw new Error('Failed to create OpenCode session');
}
const session = await createResponse.json().catch(() => null) as unknown;
const session = unwrapBridgeSdkData(
await client.session.create({
...(directory ? { directory } : {}),
title: 'Git Generation',
}, { signal: AbortSignal.timeout(remainingMs()) }),
'session.create'
);
const sessionObj = session && typeof session === 'object' ? session as Record<string, unknown> : null;
const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : '';
if (!createdSessionId) {
@@ -187,54 +207,33 @@ const generateBridgeTextWithSessionFlow = async ({
}
sessionId = createdSessionId;
const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`);
if (directory) {
promptUrl.searchParams.set('directory', directory);
}
const promptResponse = await fetch(promptUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify({
assertBridgeSdkSuccess(
await client.session.promptAsync({
sessionID: sessionId,
...(directory ? { directory } : {}),
model: {
providerID,
modelID,
},
parts: [{ type: 'text', text: prompt }],
}),
signal: AbortSignal.timeout(remainingMs()),
});
if (!promptResponse.ok) {
throw new Error('Failed to send prompt');
}
const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`);
if (directory) {
messagesUrl.searchParams.set('directory', directory);
}
messagesUrl.searchParams.set('limit', '10');
}, { signal: AbortSignal.timeout(remainingMs()) }),
'session.promptAsync'
);
while (Date.now() < deadlineAt) {
await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS);
const messagesResponse = await fetch(messagesUrl.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
...headers,
},
signal: AbortSignal.timeout(remainingMs()),
});
const messagesResponse = await client.session.messages({
sessionID: sessionId,
...(directory ? { directory } : {}),
limit: 10,
}, { signal: AbortSignal.timeout(remainingMs()) });
if (!messagesResponse.ok) {
if (messagesResponse.error) {
continue;
}
const messages = await messagesResponse.json().catch(() => null) as unknown;
const messages = messagesResponse.data;
if (!Array.isArray(messages)) {
continue;
}
@@ -259,13 +258,8 @@ const generateBridgeTextWithSessionFlow = async ({
throw new Error('Timeout waiting for generation to complete');
} finally {
if (sessionId) {
const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`);
try {
await fetch(deleteUrl.toString(), {
method: 'DELETE',
headers,
signal: AbortSignal.timeout(5_000),
});
await client.session.delete({ sessionID: sessionId }, { signal: AbortSignal.timeout(5_000) });
} catch {
// ignore cleanup failures
}
@@ -40,6 +40,13 @@ const buildProxyJsonError = (status: number, error: string): ApiProxyResponsePay
bodyBase64: base64EncodeUtf8(JSON.stringify({ error })),
});
const normalizeFsProxyPath = (pathname: string): '/api/fs/stat' | '/api/fs/read' | '/api/fs/raw' | null => {
if (pathname === '/api/fs/stat' || pathname === '/fs/stat') return '/api/fs/stat';
if (pathname === '/api/fs/read' || pathname === '/fs/read') return '/api/fs/read';
if (pathname === '/api/fs/raw' || pathname === '/fs/raw') return '/api/fs/raw';
return null;
};
export const tryHandleLocalFsProxy = async (method: string, requestPath: string): Promise<ApiProxyResponsePayload | null> => {
let parsed: URL;
try {
@@ -48,7 +55,8 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
return buildProxyJsonError(400, 'Invalid request path');
}
if (parsed.pathname !== '/api/fs/stat' && parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') {
const fsProxyPath = normalizeFsProxyPath(parsed.pathname);
if (!fsProxyPath) {
return null;
}
@@ -68,7 +76,7 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
return buildProxyJsonError(400, 'Specified path is not a file');
}
if (parsed.pathname === '/api/fs/stat') {
if (fsProxyPath === '/api/fs/stat') {
return {
status: 200,
headers: {
@@ -84,7 +92,7 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
};
}
if (parsed.pathname === '/api/fs/read') {
if (fsProxyPath === '/api/fs/read') {
const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8');
return {
status: 200,
@@ -110,7 +118,7 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
if (err?.code === 'ENOENT') {
return buildProxyJsonError(404, 'File not found');
}
if (parsed.pathname === '/api/fs/stat') {
if (fsProxyPath === '/api/fs/stat') {
return buildProxyJsonError(500, 'Unable to stat file');
}
return buildProxyJsonError(500, 'Unable to read file');
@@ -0,0 +1,59 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import type { BridgeContext } from './bridge';
import { handleProxyBridgeMessage } from './bridge-proxy-runtime';
const deps = {
tryHandleLocalFsProxy: async () => null,
buildUnavailableApiResponse: () => ({ status: 503, headers: {}, bodyText: '' }),
sanitizeForwardHeaders: (input: Record<string, string> | undefined) => input ?? {},
collectHeaders: (headers: Headers) => {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
},
base64EncodeUtf8: (text: string) => Buffer.from(text, 'utf8').toString('base64'),
};
const ctx = {
manager: {
getApiUrl: () => 'http://127.0.0.1:3902',
getOpenCodeAuthHeaders: () => ({}),
},
} as unknown as BridgeContext;
describe('VS Code API proxy aborts', () => {
test('aborts non-SSE api:proxy fetches by bridge request id', async () => {
const originalFetch = globalThis.fetch;
let capturedSignal: AbortSignal | undefined;
try {
globalThis.fetch = (async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
capturedSignal = init?.signal ?? undefined;
return new Promise<Response>((_resolve, reject) => {
capturedSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
});
}) as typeof fetch;
const pending = handleProxyBridgeMessage(
{ id: 'req_1', type: 'api:proxy', payload: { method: 'POST', path: '/session/abc/prompt_async', bodyBase64: Buffer.from('{}').toString('base64') } },
ctx,
deps,
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(capturedSignal?.aborted, false);
await handleProxyBridgeMessage({ id: 'abort_req_1', type: 'api:proxy:abort', payload: { requestID: 'req_1' } }, ctx, deps);
assert.equal(capturedSignal?.aborted, true);
const response = await pending;
assert.equal(response?.success, true);
assert.equal((response?.data as { status?: number }).status, 502);
} finally {
globalThis.fetch = originalFetch;
}
});
});
+30 -1
View File
@@ -20,6 +20,10 @@ type ApiSessionMessageRequestPayload = {
bodyText?: string;
};
type ApiProxyAbortPayload = {
requestID?: string;
};
type ApiProxyResponsePayload = {
status: number;
headers: Record<string, string>;
@@ -59,6 +63,8 @@ type ProxyRuntimeDeps = {
base64EncodeUtf8: (text: string) => string;
};
const proxyAbortControllers = new Map<string, AbortController>();
export async function handleProxyBridgeMessage(
message: BridgeMessageInput,
ctx: BridgeContext | undefined,
@@ -67,6 +73,15 @@ export async function handleProxyBridgeMessage(
const { id, type, payload } = message;
switch (type) {
case 'api:proxy:abort': {
const { requestID } = (payload || {}) as ApiProxyAbortPayload;
if (typeof requestID === 'string' && requestID.length > 0) {
proxyAbortControllers.get(requestID)?.abort();
proxyAbortControllers.delete(requestID);
}
return { id, type, success: true, data: { aborted: true } };
}
case 'api:proxy': {
const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload;
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
@@ -104,6 +119,9 @@ export async function handleProxyBridgeMessage(
...ctx?.manager?.getOpenCodeAuthHeaders(),
};
const abortController = new AbortController();
proxyAbortControllers.set(id, abortController);
try {
const response = await fetch(targetUrl, {
method: normalizedMethod,
@@ -112,6 +130,7 @@ export async function handleProxyBridgeMessage(
typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD'
? Buffer.from(bodyBase64, 'base64')
: undefined,
signal: abortController.signal,
});
const responseHeaders = collectProxyResponseHeaders(response.headers, deps);
@@ -144,6 +163,8 @@ export async function handleProxyBridgeMessage(
bodyText: body,
};
return { id, type, success: true, data };
} finally {
proxyAbortControllers.delete(id);
}
}
@@ -178,13 +199,18 @@ export async function handleProxyBridgeMessage(
...deps.sanitizeForwardHeaders(headers),
...ctx?.manager?.getOpenCodeAuthHeaders(),
};
const timeoutSignal = AbortSignal.timeout(45000);
const abortController = new AbortController();
proxyAbortControllers.set(id, abortController);
const onTimeout = () => abortController.abort();
timeoutSignal.addEventListener('abort', onTimeout, { once: true });
try {
const response = await fetch(targetUrl, {
method: 'POST',
headers: requestHeaders,
body: typeof bodyText === 'string' ? bodyText : '',
signal: AbortSignal.timeout(45000),
signal: abortController.signal,
});
const responseHeaders = collectProxyResponseHeaders(response.headers, deps);
@@ -221,6 +247,9 @@ export async function handleProxyBridgeMessage(
bodyText: body,
};
return { id, type, success: true, data };
} finally {
timeoutSignal.removeEventListener('abort', onTimeout);
proxyAbortControllers.delete(id);
}
}
+744
View File
@@ -7,11 +7,17 @@ import { parse as parseJsonc } from 'jsonc-parser';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
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;
const HASHTAG_PATTERN = /#([a-z0-9_-]+)/gi;
const MAX_SNIPPET_EXPANSION_COUNT = 15;
// Scope types (shared by agents and commands)
export const AGENT_SCOPE = {
@@ -27,6 +33,46 @@ export const COMMAND_SCOPE = {
export type AgentScope = typeof AGENT_SCOPE[keyof typeof AGENT_SCOPE];
export type CommandScope = typeof COMMAND_SCOPE[keyof typeof COMMAND_SCOPE];
export type SnippetScope = 'global' | 'project';
export type Snippet = {
name: string;
content: string;
aliases: string[];
description?: string;
filePath: string;
source: SnippetScope;
};
export type PluginScope = 'user' | 'project';
export type PluginParsedKind = 'npm' | 'path';
export type PluginEntry = {
id: string;
spec: string;
options?: Record<string, unknown>;
scope: PluginScope;
kind: 'config';
parsedKind: PluginParsedKind;
};
export type PluginFile = {
id: string;
fileName: string;
scope: PluginScope;
kind: 'file';
};
export type PluginRegistryResult =
| { kind: 'npm-ok'; spec: string; name: string; currentVersion: string | null; latestVersion: string | null; versions: string[]; hasUpdate: boolean }
| { kind: 'npm-missing-version'; spec: string; name: string; currentVersion: string; latestVersion: string | null; versions: string[] }
| { kind: 'npm-missing-package'; spec: string; name: string; error: string }
| { kind: 'npm-malformed'; spec: string; error: string }
| { kind: 'npm-network'; spec: string; error: string }
| { kind: 'path-ok'; spec: string; absolutePath: string }
| { kind: 'path-missing'; spec: string; absolutePath: string }
| { kind: 'path-unreadable'; spec: string; absolutePath: string };
export type ConfigSources = {
md: { exists: boolean; path: string | null; fields: string[]; scope?: AgentScope | CommandScope | null };
json: { exists: boolean; path: string; fields: string[]; scope?: AgentScope | CommandScope | null };
@@ -263,6 +309,174 @@ const getCommandWritePath = (commandName: string, workingDirectory?: string, req
};
};
// ============== SNIPPET HELPERS ==============
const getProjectSnippetDirs = (workingDirectory?: string): Array<{ dir: string; source: SnippetScope }> => {
if (!workingDirectory) return [];
return [
{ dir: path.join(workingDirectory, '.opencode', 'snippets'), source: 'project' },
{ dir: path.join(workingDirectory, '.opencode', 'snippet'), source: 'project' },
];
};
const getGlobalSnippetDirs = (): Array<{ dir: string; source: SnippetScope }> => [
{ dir: GLOBAL_SNIPPET_DIR_ALT, source: 'global' },
{ dir: GLOBAL_SNIPPET_DIR, source: 'global' },
];
const assertValidSnippetName = (name: string): void => {
if (typeof name !== 'string' || !SNIPPET_NAME_PATTERN.test(name)) {
throw new Error('Snippet name must use letters, numbers, dashes, or underscores');
}
};
const normalizeSnippetAliases = (frontmatter: Record<string, unknown>): string[] => {
const raw = frontmatter.aliases ?? frontmatter.alias;
if (!raw) return [];
const aliases = Array.isArray(raw) ? raw : [raw];
return aliases.map((alias) => String(alias).trim()).filter(Boolean);
};
const loadSnippetFile = (dir: string, filename: string, source: SnippetScope): Snippet | null => {
const name = path.basename(filename, SNIPPET_EXTENSION);
if (!SNIPPET_NAME_PATTERN.test(name)) return null;
const filePath = path.join(dir, filename);
const { frontmatter, body } = parseMdFile(filePath);
return {
name,
content: body,
aliases: normalizeSnippetAliases(frontmatter),
description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
filePath,
source,
};
};
const registerSnippet = (registry: Map<string, Snippet>, snippet: Snippet): void => {
const key = snippet.name.toLowerCase();
const existing = registry.get(key);
if (existing) {
for (const alias of existing.aliases) registry.delete(alias.toLowerCase());
}
registry.set(key, snippet);
for (const alias of snippet.aliases) {
if (SNIPPET_NAME_PATTERN.test(alias)) registry.set(alias.toLowerCase(), snippet);
}
};
const loadSnippetRegistry = (workingDirectory?: string): Map<string, Snippet> => {
const registry = new Map<string, Snippet>();
for (const { dir, source } of [...getGlobalSnippetDirs(), ...getProjectSnippetDirs(workingDirectory)]) {
if (!fs.existsSync(dir)) continue;
for (const filename of fs.readdirSync(dir)) {
if (!filename.endsWith(SNIPPET_EXTENSION)) continue;
try {
const snippet = loadSnippetFile(dir, filename, source);
if (snippet) registerSnippet(registry, snippet);
} catch (error) {
console.warn(`[OpenChamber][VSCode] Failed to load snippet ${path.join(dir, filename)}:`, error);
}
}
}
return registry;
};
const listUniqueSnippets = (registry: Map<string, Snippet>): Snippet[] => {
const seen = new Set<string>();
const snippets: Snippet[] = [];
for (const snippet of registry.values()) {
const key = `${snippet.source}:${snippet.filePath}`;
if (seen.has(key)) continue;
seen.add(key);
snippets.push(snippet);
}
return snippets.sort((a, b) => a.name.localeCompare(b.name));
};
const getWritableSnippetDir = (scope: SnippetScope, workingDirectory?: string): string => {
if (scope === 'project') {
if (!workingDirectory) throw new Error('Project directory is required for project snippets');
const preferred = path.join(workingDirectory, '.opencode', 'snippet');
const alternate = path.join(workingDirectory, '.opencode', 'snippets');
return fs.existsSync(alternate) && !fs.existsSync(preferred) ? alternate : preferred;
}
return fs.existsSync(GLOBAL_SNIPPET_DIR_ALT) && !fs.existsSync(GLOBAL_SNIPPET_DIR)
? GLOBAL_SNIPPET_DIR_ALT
: GLOBAL_SNIPPET_DIR;
};
const findSnippetByName = (name: string, workingDirectory?: string): Snippet | null => {
assertValidSnippetName(name);
return loadSnippetRegistry(workingDirectory).get(name.toLowerCase()) ?? null;
};
const writeSnippetFile = (filePath: string, config: Record<string, unknown>): void => {
const aliases = Array.isArray(config.aliases)
? config.aliases.map((alias) => String(alias).trim()).filter(Boolean)
: [];
const frontmatter: Record<string, unknown> = {};
if (aliases.length > 0) frontmatter.aliases = aliases;
if (typeof config.description === 'string' && config.description.trim()) {
frontmatter.description = config.description.trim();
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
writeMdFile(filePath, frontmatter, typeof config.content === 'string' ? config.content : '');
};
const parseSnippetBlocks = (content: string): { inline: string; prepend: string[]; append: string[] } => {
const blocks = { prepend: [] as string[], append: [] as string[] };
let inline = content;
for (const type of ['prepend', 'append'] as const) {
const regex = new RegExp(`<${type}>([\\s\\S]*?)(?:<\\/${type}>|$)`, 'gi');
inline = inline.replace(regex, (_match, value: string) => {
const normalized = String(value).trim();
if (normalized) blocks[type].push(normalized);
return '';
});
}
inline = inline.replace(/<inject>[\s\S]*?(?:<\/inject>|$)/gi, '').trim();
return { inline, prepend: blocks.prepend, append: blocks.append };
};
const expandSnippetText = (
text: string,
registry: Map<string, Snippet>,
expansionCounts: Map<string, number>,
collector: { prepend: string[]; append: string[] },
): string => {
let expanded = text;
let changed = true;
while (changed) {
const previous = expanded;
let loopDetected = false;
HASHTAG_PATTERN.lastIndex = 0;
expanded = expanded.replace(HASHTAG_PATTERN, (match, name: string, offset: number, input: string) => {
if (name.toLowerCase() === 'skill' && input[offset + match.length] === '(') return match;
const snippet = registry.get(name.toLowerCase());
if (!snippet) return match;
const key = snippet.name.toLowerCase();
const count = (expansionCounts.get(key) || 0) + 1;
if (count > MAX_SNIPPET_EXPANSION_COUNT) {
loopDetected = true;
return match;
}
expansionCounts.set(key, count);
const parsed = parseSnippetBlocks(snippet.content);
for (const block of parsed.prepend) collector.prepend.push(expandSnippetText(block, registry, expansionCounts, collector));
for (const block of parsed.append) collector.append.push(expandSnippetText(block, registry, expansionCounts, collector));
return expandSnippetText(parsed.inline, registry, expansionCounts, collector);
});
changed = expanded !== previous && !loopDetected;
}
return expanded;
};
const isPromptFileReference = (value: unknown): value is string => {
return typeof value === 'string' && PROMPT_FILE_PATTERN.test(value.trim());
};
@@ -492,6 +706,494 @@ const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
};
const codedError = (message: string, code: string): Error & { code: string } => {
const error = new Error(message) as Error & { code: string };
error.code = code;
return error;
};
const validatePluginScope = (scope: unknown): PluginScope => {
if (scope === 'user' || scope === 'project') return scope;
throw codedError('Plugin scope must be user or project', 'INVALID_SCOPE');
};
const validatePluginSpec = (spec: unknown): string => {
if (typeof spec !== 'string' || spec.trim().length === 0) {
throw codedError('Plugin spec must be a non-empty string', 'INVALID_SPEC');
}
if (spec.includes('\0')) {
throw codedError('Plugin spec cannot contain null bytes', 'INVALID_SPEC');
}
return spec.trim();
};
const PLUGIN_FILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/;
const validatePluginFileName = (fileName: unknown): string => {
if (typeof fileName !== 'string' || fileName.trim().length === 0) {
throw codedError('Plugin file name is required', 'INVALID_FILENAME');
}
const normalized = fileName.trim();
if (
normalized.includes('/') ||
normalized.includes('\\') ||
normalized.includes('..') ||
!PLUGIN_FILE_NAME_PATTERN.test(normalized)
) {
throw codedError('Plugin file name must match /^[a-z0-9][a-z0-9-_.]*\\.(js|ts|mjs|cjs)$/ and cannot contain path traversal', 'INVALID_FILENAME');
}
return normalized;
};
const encodePluginId = (prefix: 'config' | 'file', value: string): string =>
Buffer.from(`${prefix}:${value}`, 'utf8').toString('base64url');
const decodePluginId = (id: string): { prefix: string; value: string } => {
try {
const decoded = Buffer.from(id, 'base64url').toString('utf8');
const separator = decoded.indexOf(':');
if (separator <= 0) throw new Error('invalid plugin id');
return { prefix: decoded.slice(0, separator), value: decoded.slice(separator + 1) };
} catch {
throw codedError('Invalid plugin id', 'INVALID_SPEC');
}
};
const parsePluginIdValue = (value: string): { scope: PluginScope; rest: string } => {
const separator = value.indexOf(':');
if (separator <= 0) {
throw codedError('Plugin id value must include scope', 'INVALID_SPEC');
}
return {
scope: validatePluginScope(value.slice(0, separator)),
rest: value.slice(separator + 1),
};
};
const parsePluginRaw = (raw: unknown): { spec: string; options?: Record<string, unknown> } => {
if (typeof raw === 'string') {
return { spec: validatePluginSpec(raw) };
}
if (Array.isArray(raw) && typeof raw[0] === 'string' && isPlainObject(raw[1])) {
return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } };
}
throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC');
};
const serializePluginEntry = (entry: { spec?: unknown; options?: unknown }): string | [string, Record<string, unknown>] => {
const spec = validatePluginSpec(entry.spec);
if (isPlainObject(entry.options) && Object.keys(entry.options).length > 0) {
return [spec, { ...entry.options }];
}
return spec;
};
const isPluginPathSpec = (spec: string): boolean =>
spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('~') || path.win32.isAbsolute(spec);
const parsePluginPathSpec = (spec: string, workingDirectory?: string | null): { absolutePath: string } => {
if (spec === '~') return { absolutePath: path.resolve(os.homedir()) };
if (spec.startsWith('~/')) return { absolutePath: path.resolve(os.homedir(), spec.slice(2)) };
if (spec.startsWith('./') || spec.startsWith('../')) {
return { absolutePath: path.resolve(workingDirectory || os.homedir(), spec) };
}
if (path.win32.isAbsolute(spec)) return { absolutePath: spec };
return { absolutePath: path.resolve(spec) };
};
const parsePluginNpmSpec = (spec: string): { name: string; version: string | null } | { malformed: true } => {
if (spec.startsWith('@')) {
const slashIdx = spec.indexOf('/');
if (slashIdx < 2) return { malformed: true };
const afterSlash = spec.slice(slashIdx + 1);
if (!afterSlash) return { malformed: true };
const atIdx = afterSlash.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
const version = afterSlash.slice(atIdx + 1);
if (!version) return { malformed: true };
return { name: spec.slice(0, slashIdx + 1 + atIdx), version };
}
if (!spec) return { malformed: true };
const atIdx = spec.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
if (atIdx === 0) return { malformed: true };
const version = spec.slice(atIdx + 1);
if (!version) return { malformed: true };
return { name: spec.slice(0, atIdx), version };
};
const isExactPluginSemver = (version: string): boolean => /^\d+\.\d+\.\d+([-+][\w.-]+)?$/.test(version);
const getActiveCustomConfigPath = (): string | null =>
process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
const getActiveOpencodeConfigDir = (): string => {
const customConfigPath = getActiveCustomConfigPath();
return customConfigPath ? path.dirname(customConfigPath) : OPENCODE_CONFIG_DIR;
};
const getActiveUserConfigPaths = (): string[] => {
const configDir = getActiveOpencodeConfigDir();
return [
path.join(configDir, 'config.json'),
path.join(configDir, 'opencode.json'),
path.join(configDir, 'opencode.jsonc'),
];
};
const getActivePrimaryUserConfigPath = (): string => {
const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths();
for (const userPath of [defaultPath, ...fallbackPaths]) {
if (fs.existsSync(userPath)) {
return userPath;
}
}
return defaultPath;
};
const ensureProjectPluginConfigPath = (workingDirectory?: string | null): string => {
if (!workingDirectory) throw codedError('Project plugin scope requires working directory', 'INVALID_SCOPE');
return path.join(workingDirectory, '.opencode', 'opencode.json');
};
const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope: PluginScope; path: string; config: Record<string, unknown> }> => {
const customPath = getActiveCustomConfigPath();
const userPath = getActivePrimaryUserConfigPath();
const projectPath = getProjectConfigPath(workingDirectory || undefined);
return [
customPath
? { scope: 'user', path: customPath, config: readConfigFile(customPath) }
: { scope: 'user', path: userPath, config: readConfigFile(userPath) },
...(projectPath
? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }]
: []),
];
};
const readPluginArray = (config: Record<string, unknown>): unknown[] => Array.isArray(config.plugin) ? config.plugin : [];
const writePluginArray = (config: Record<string, unknown>, plugin: unknown[]): Record<string, unknown> => {
const next = { ...config };
if (plugin.length > 0) {
next.plugin = plugin;
} else {
delete next.plugin;
}
return next;
};
const hasPluginSpec = (plugin: unknown[], spec: string): boolean => plugin.some((raw) => {
try {
return parsePluginRaw(raw).spec === spec;
} catch {
return false;
}
});
const getPluginTarget = (id: string, workingDirectory?: string | null): null | {
scope: PluginScope;
path: string;
config: Record<string, unknown>;
plugin: unknown[];
index: number;
spec: string;
} => {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'config') {
throw codedError('Plugin entry id must use config prefix', 'INVALID_SPEC');
}
const { scope, rest: spec } = parsePluginIdValue(decoded.value);
const source = getPluginConfigSources(workingDirectory).find((candidate) => candidate.scope === scope);
if (!source) return null;
const plugin = readPluginArray(source.config);
const index = plugin.findIndex((raw) => {
try {
return parsePluginRaw(raw).spec === spec;
} catch {
return false;
}
});
if (index < 0) return null;
return { scope, path: source.path, config: source.config, plugin: [...plugin], index, spec };
};
export const listPluginEntries = (workingDirectory?: string): PluginEntry[] => {
const entries: PluginEntry[] = [];
for (const source of getPluginConfigSources(workingDirectory)) {
for (const raw of readPluginArray(source.config)) {
try {
const parsed = parsePluginRaw(raw);
entries.push({
id: encodePluginId('config', `${source.scope}:${parsed.spec}`),
spec: parsed.spec,
...(parsed.options ? { options: parsed.options } : {}),
scope: source.scope,
kind: 'config',
parsedKind: isPluginPathSpec(parsed.spec) ? 'path' : 'npm',
});
} catch {
// Ignore malformed persisted plugin entries so the settings page remains usable.
}
}
}
return entries;
};
export const getPluginEntry = (id: string, workingDirectory?: string): PluginEntry | null =>
listPluginEntries(workingDirectory).find((entry) => entry.id === id) || null;
export const createPluginEntry = (entry: { spec?: unknown; options?: unknown; scope?: unknown }, workingDirectory?: string): void => {
const spec = validatePluginSpec(entry.spec);
const scope = validatePluginScope(entry.scope || 'user');
const sources = getPluginConfigSources(workingDirectory);
if (sources.some((source) => source.scope === scope && hasPluginSpec(readPluginArray(source.config), spec))) {
throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
}
const userSource = sources.find((source) => source.scope === 'user');
const targetPath = scope === 'project'
? ensureProjectPluginConfigPath(workingDirectory)
: userSource?.path ?? getActivePrimaryUserConfigPath();
const config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
const plugin = readPluginArray(config);
writeConfig(writePluginArray(config, [...plugin, serializePluginEntry({ spec, options: entry.options })]), targetPath);
};
export const updatePluginEntry = (id: string, updates: { spec?: unknown; options?: unknown }, workingDirectory?: string): void => {
const target = getPluginTarget(id, workingDirectory);
if (!target) throw codedError('Plugin entry not found', 'NOT_FOUND');
const existing = parsePluginRaw(target.plugin[target.index]);
const nextSpec = updates.spec === undefined ? existing.spec : validatePluginSpec(updates.spec);
const nextOptions = updates.options === undefined ? existing.options : updates.options;
target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions });
writeConfig(writePluginArray(target.config, target.plugin), target.path);
};
export const deletePluginEntry = (id: string, workingDirectory?: string): void => {
const target = getPluginTarget(id, workingDirectory);
if (!target) throw codedError('Plugin entry not found', 'NOT_FOUND');
target.plugin.splice(target.index, 1);
writeConfig(writePluginArray(target.config, target.plugin), target.path);
};
const getPluginDir = (scope: PluginScope, workingDirectory?: string | null): string => {
if (scope === 'project') {
if (!workingDirectory) throw codedError('Project plugin scope requires working directory', 'INVALID_SCOPE');
return path.join(workingDirectory, '.opencode', 'plugins');
}
return path.join(getActiveOpencodeConfigDir(), 'plugins');
};
const getPluginFileTarget = (id: string, workingDirectory?: string): { scope: PluginScope; fileName: string; filePath: string } => {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'file') {
throw codedError('Plugin file id must use file prefix', 'INVALID_FILENAME');
}
const { scope, rest } = parsePluginIdValue(decoded.value);
const fileName = validatePluginFileName(rest);
return { scope, fileName, filePath: path.join(getPluginDir(scope, workingDirectory), fileName) };
};
export const listPluginDirFiles = (workingDirectory?: string): PluginFile[] => {
const files: PluginFile[] = [];
for (const scope of ['user', 'project'] as const) {
let dir: string;
try {
dir = getPluginDir(scope, workingDirectory);
} catch {
continue;
}
if (!fs.existsSync(dir)) continue;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
try {
const fileName = validatePluginFileName(entry.name);
files.push({
id: encodePluginId('file', `${scope}:${fileName}`),
fileName,
scope,
kind: 'file',
});
} catch {
// Ignore unsupported files in the plugins directory.
}
}
}
return files.sort((a, b) => `${a.scope}:${a.fileName}`.localeCompare(`${b.scope}:${b.fileName}`));
};
export const readPluginDirFile = (id: string, workingDirectory?: string): { fileName: string; scope: PluginScope; content: string } | null => {
const target = getPluginFileTarget(id, workingDirectory);
if (!fs.existsSync(target.filePath)) return null;
return {
fileName: target.fileName,
scope: target.scope,
content: fs.readFileSync(target.filePath, 'utf8'),
};
};
export const writePluginDirFile = (
file: { fileName?: unknown; content?: unknown; scope?: unknown },
workingDirectory?: string,
opts: { overwrite?: boolean } = {},
): void => {
const scope = validatePluginScope(file.scope || 'user');
const fileName = validatePluginFileName(file.fileName);
const filePath = path.join(getPluginDir(scope, workingDirectory), fileName);
if (!opts.overwrite && fs.existsSync(filePath)) {
throw codedError(`Plugin file "${fileName}" already exists`, 'FILE_EXISTS');
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, typeof file.content === 'string' ? file.content : '', 'utf8');
};
export const deletePluginDirFile = (id: string, workingDirectory?: string): void => {
const target = getPluginFileTarget(id, workingDirectory);
if (!fs.existsSync(target.filePath)) {
throw codedError(`Plugin file "${target.fileName}" not found`, 'NOT_FOUND');
}
fs.rmSync(target.filePath, { force: true });
};
type NpmLookupResult =
| { ok: true; latest: string | null; versions: string[] }
| { ok: false; status: number | 'network'; error: string };
const npmInfoCache = new Map<string, { fetchedAt: number; payload: NpmLookupResult }>();
const npmInfoInFlight = new Map<string, Promise<NpmLookupResult>>();
const NPM_CACHE_TTL_MS = 3_600_000;
const lookupNpmPackage = async (name: string): Promise<NpmLookupResult> => {
try {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name).replace(/^%40/, '@')}`, {
headers: { Accept: 'application/json', 'User-Agent': 'openchamber-vscode/dev' },
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
const data = await response.json() as { versions?: unknown; 'dist-tags'?: { latest?: unknown } };
return {
ok: true,
latest: typeof data['dist-tags']?.latest === 'string' ? data['dist-tags'].latest : null,
versions: isPlainObject(data.versions) ? Object.keys(data.versions) : [],
};
}
if (response.status === 404) return { ok: false, status: 404, error: 'Package not found' };
return { ok: false, status: response.status, error: `Registry returned ${response.status}` };
} catch (error) {
return { ok: false, status: 'network', error: error instanceof Error ? error.message : String(error) };
}
};
const getNpmInfo = async (name: string, forceRefresh = false): Promise<NpmLookupResult> => {
const cached = npmInfoCache.get(name);
if (cached && !forceRefresh && Date.now() - cached.fetchedAt < NPM_CACHE_TTL_MS) {
return cached.payload;
}
const existing = npmInfoInFlight.get(name);
if (existing && !forceRefresh) return existing;
const lookup = lookupNpmPackage(name);
npmInfoInFlight.set(name, lookup);
try {
const result = await lookup;
if (result.ok || result.status === 404) {
npmInfoCache.set(name, { fetchedAt: Date.now(), payload: result });
}
return result;
} finally {
if (npmInfoInFlight.get(name) === lookup) {
npmInfoInFlight.delete(name);
}
}
};
export const queryPluginRegistry = async (
specs: string[],
opts: { refresh?: boolean; workingDirectory?: string } = {},
): Promise<{ results: PluginRegistryResult[] }> => {
const uniqueSpecs = Array.from(new Set(specs.filter((spec) => spec.length > 0)));
if (uniqueSpecs.length > 100) {
throw codedError('too many specs', 'INVALID_SPEC');
}
const npmJobs = new Map<string, string[]>();
const malformedSpecs = new Set<string>();
for (const spec of uniqueSpecs) {
if (isPluginPathSpec(spec)) continue;
const parsed = parsePluginNpmSpec(spec);
if ('malformed' in parsed) {
malformedSpecs.add(spec);
continue;
}
npmJobs.set(parsed.name, [...(npmJobs.get(parsed.name) || []), spec]);
}
const npmInfoByName = new Map<string, NpmLookupResult>();
await Promise.all(Array.from(npmJobs.keys()).map(async (name) => {
npmInfoByName.set(name, await getNpmInfo(name, opts.refresh === true));
}));
const results: PluginRegistryResult[] = [];
for (const spec of uniqueSpecs) {
if (malformedSpecs.has(spec)) {
results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
continue;
}
if (isPluginPathSpec(spec)) {
const { absolutePath } = parsePluginPathSpec(spec, opts.workingDirectory || os.homedir());
try {
fs.statSync(absolutePath);
} catch {
results.push({ kind: 'path-missing', spec, absolutePath });
continue;
}
try {
fs.accessSync(absolutePath, fs.constants.R_OK);
results.push({ kind: 'path-ok', spec, absolutePath });
} catch {
results.push({ kind: 'path-unreadable', spec, absolutePath });
}
continue;
}
const parsed = parsePluginNpmSpec(spec);
if ('malformed' in parsed) {
results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
continue;
}
const info = npmInfoByName.get(parsed.name);
if (!info?.ok) {
if (info?.status === 404) {
results.push({ kind: 'npm-missing-package', spec, name: parsed.name, error: info.error });
} else {
results.push({ kind: 'npm-network', spec, error: info?.status === 'network' ? info.error : `Registry returned ${info?.status ?? 'unknown'}` });
}
continue;
}
const currentVersion = parsed.version;
if (currentVersion !== null && isExactPluginSemver(currentVersion) && !info.versions.includes(currentVersion)) {
results.push({
kind: 'npm-missing-version',
spec,
name: parsed.name,
currentVersion,
latestVersion: info.latest,
versions: info.versions,
});
continue;
}
results.push({
kind: 'npm-ok',
spec,
name: parsed.name,
currentVersion,
latestVersion: info.latest,
versions: info.versions,
hasUpdate: currentVersion !== null && isExactPluginSemver(currentVersion) && currentVersion !== info.latest,
});
}
return { results };
};
export type McpLocalConfig = {
type: 'local';
command?: string[];
@@ -1284,6 +1986,48 @@ export const deleteCommand = (commandName: string, workingDirectory?: string) =>
}
};
export const listSnippets = (workingDirectory?: string): Snippet[] => {
return listUniqueSnippets(loadSnippetRegistry(workingDirectory));
};
export const getSnippet = (name: string, workingDirectory?: string): Snippet | null => {
return findSnippetByName(name, workingDirectory);
};
export const createSnippet = (
name: string,
config: Record<string, unknown>,
workingDirectory?: string,
scope: SnippetScope = 'global',
): Snippet | null => {
assertValidSnippetName(name);
const dir = getWritableSnippetDir(scope, workingDirectory);
const filePath = path.join(dir, `${name}${SNIPPET_EXTENSION}`);
if (fs.existsSync(filePath)) throw new Error(`Snippet "${name}" already exists`);
writeSnippetFile(filePath, config || {});
return getSnippet(name, workingDirectory);
};
export const updateSnippet = (name: string, updates: Record<string, unknown>, workingDirectory?: string): Snippet | null => {
const existing = findSnippetByName(name, workingDirectory);
if (!existing) throw new Error(`Snippet "${name}" not found`);
writeSnippetFile(existing.filePath, { ...existing, ...(updates || {}) });
return getSnippet(name, workingDirectory);
};
export const deleteSnippet = (name: string, workingDirectory?: string): void => {
const existing = findSnippetByName(name, workingDirectory);
if (!existing) throw new Error(`Snippet "${name}" not found`);
fs.unlinkSync(existing.filePath);
};
export const expandSnippets = (text: string, workingDirectory?: string): string => {
const registry = loadSnippetRegistry(workingDirectory);
const collector = { prepend: [] as string[], append: [] as string[] };
const expanded = expandSnippetText(text || '', registry, new Map(), collector).trim();
return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n');
};
// ============== SKILL SCOPE HELPERS ==============
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');