Merge remote-tracking branch 'origin/main' into feat/gitlab-issues-mrs

# Conflicts:
#	packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx
#	packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx
This commit is contained in:
2026-08-20 14:47:33 +00:00
164 changed files with 4467 additions and 1731 deletions
+1
View File
@@ -115,6 +115,7 @@ OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber
| `OPENCHAMBER_VERBOSE_REQUEST_LOGS` | Set to `true` to log every HTTP request; disabled by default to keep user logs small |
| `OPENCHAMBER_SKIP_API_COMPRESSION` | Set to `true` to disable gzip compression for `/api/*` responses |
| `OPENCHAMBER_COMPRESS_API` | Set to `true` to force `/api/*` compression, or `false` to disable it. Desktop runtime disables API compression by default to reduce local sidecar CPU use |
| `OPENCHAMBER_FS_UPLOAD_MAX_BYTES` | Maximum file upload size in bytes (default: 100 MiB) |
| `OPENCHAMBER_TERMINAL_SHELL` | Preferred terminal shell executable used by the `Auto` setting before platform defaults |
</details>
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@openchamber/web",
"version": "1.18.4",
"version": "1.19.0",
"private": false,
"type": "module",
"main": "./server/index.js",
@@ -27,7 +27,6 @@
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "1.18.18",
"@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.6.0",
"bun-pty": "^0.4.5",
"compression": "^1.8.1",
"cron-parser": "^4.9.0",
@@ -63,7 +62,6 @@
"@remixicon/react": "^4.7.0",
"@simplewebauthn/browser": "13.3.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/adm-zip": "^0.5.7",
"@types/node": "^24.3.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
@@ -89,8 +87,8 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
"supertest": "^7.2.2",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.0.0",
"tsx": "^4.20.6",
"tw-animate-css": "^1.3.8",
+35
View File
@@ -72,6 +72,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut
import { resolveOpenCodeUpgradeCapability } from './lib/opencode/upgrade-capability.js';
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
import { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } from './lib/small-model/runtime-providers.js';
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
import { createSessionGoalRuntime } from './lib/session-goal/runtime.js';
@@ -530,6 +531,9 @@ let openCodeApiPrefixDetected = true;
let openCodeApiDetectionTimer = null;
let lastOpenCodeError = null;
let lastOpenCodeLaunchDiagnostics = null;
let lastOpenCodeHealthFailure = null;
let lastManagedOpenCodeProcess = null;
let lastOpenCodeRestartDiagnostics = null;
let isOpenCodeReady = false;
let openCodeNotReadySince = 0;
let isExternalOpenCode = false;
@@ -661,6 +665,11 @@ const buildOpenCodeUrl = (...args) => openCodeNetworkRuntime.buildOpenCodeUrl(..
const ensureOpenCodeApiPrefix = (...args) => openCodeNetworkRuntime.ensureOpenCodeApiPrefix(...args);
const scheduleOpenCodeApiDetection = (...args) => openCodeNetworkRuntime.scheduleOpenCodeApiDetection(...args);
// Plugin-registered providers exist only inside the running OpenCode process.
// Small-model callers resolve them through this connection; without it they
// stay on the file-based resolution and plugin models remain unreachable.
configureOpenCodeRuntimeProviders({ buildOpenCodeUrl, getOpenCodeAuthHeaders });
const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
);
@@ -1089,6 +1098,9 @@ Object.defineProperties(openCodeLifecycleState, {
openCodeApiDetectionTimer: { get: () => openCodeApiDetectionTimer, set: (value) => { openCodeApiDetectionTimer = value; } },
lastOpenCodeError: { get: () => lastOpenCodeError, set: (value) => { lastOpenCodeError = value; } },
lastOpenCodeLaunchDiagnostics: { get: () => lastOpenCodeLaunchDiagnostics, set: (value) => { lastOpenCodeLaunchDiagnostics = value; } },
lastOpenCodeHealthFailure: { get: () => lastOpenCodeHealthFailure, set: (value) => { lastOpenCodeHealthFailure = value; } },
lastManagedOpenCodeProcess: { get: () => lastManagedOpenCodeProcess, set: (value) => { lastManagedOpenCodeProcess = value; } },
lastOpenCodeRestartDiagnostics: { get: () => lastOpenCodeRestartDiagnostics, set: (value) => { lastOpenCodeRestartDiagnostics = value; } },
isOpenCodeReady: { get: () => isOpenCodeReady, set: (value) => { isOpenCodeReady = value; } },
openCodeNotReadySince: { get: () => openCodeNotReadySince, set: (value) => { openCodeNotReadySince = value; } },
isExternalOpenCode: { get: () => isExternalOpenCode, set: (value) => { isExternalOpenCode = value; } },
@@ -1156,11 +1168,31 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
// process (#2638). The runtime is created later by the startup pipeline;
// by the time any restart runs, it is assigned.
onOpenCodeRestarted: () => {
// A restart reloads plugins: provider ports, credentials and the provider
// list itself can all differ from what was cached.
resetOpenCodeRuntimeProviders();
try {
messageStreamRuntime?.rebindUpstream();
} catch (error) {
console.warn('Failed to rebind message stream after OpenCode restart:', error?.message ?? error);
}
try {
const { sessionIds } = sessionRuntime.interruptBusySessionsAfterRestart();
if (sessionIds.length > 0) {
const multiple = sessionIds.length > 1;
broadcastUiNotification({
title: multiple ? 'Chats interrupted' : 'Chat interrupted',
body: multiple
? 'OpenCode restarted during running responses. Send a message in each chat to continue.'
: 'OpenCode restarted during a running response. Send a message to continue.',
tag: 'opencode-restart-interrupted',
kind: 'opencode-restart-interrupted',
sessionId: sessionIds[0],
});
}
} catch (error) {
console.warn('Failed to reconcile sessions after OpenCode restart:', error?.message ?? error);
}
},
getManagedOpenCodeEnv: async () => {
const settings = await readSettingsFromDiskMigrated().catch(() => null);
@@ -1658,6 +1690,9 @@ async function main(options = {}) {
isOpenCodeReady,
lastOpenCodeError,
lastOpenCodeLaunchDiagnostics,
lastOpenCodeHealthFailure,
lastManagedOpenCodeProcess,
lastOpenCodeRestartDiagnostics,
opencodeBinaryResolved: resolvedOpencodeBinary || null,
opencodeBinarySource: resolvedOpencodeBinarySource || null,
opencodeLaunchBinary: launchSpec?.binary || null,
@@ -69,7 +69,13 @@ export const createContextObligatoryRuntime = ({
*/
const knowledge = sessionKnowledgeRuntime
? await sessionKnowledgeRuntime
.resolvePending(directory, sessionKnowledgeRuntime.readDeliveredSignature(session))
.resolvePending(
directory,
// Compaction removed the previously delivered block, so its stored
// signature is no longer evidence that the session still carries it.
'',
sessionKnowledgeRuntime.readPins(session),
)
.catch(() => ({ text: '', signature: '' }))
: { text: '', signature: '' };
@@ -73,7 +73,10 @@ describe('context obligatory runtime', () => {
const url = new URL(typeof input === 'string' ? input : input.url);
requests.push({ path: url.pathname, method: init.method ?? 'GET', body: init.body });
if (url.pathname === '/session/ses_1' && init.method === 'PATCH') return json({});
if (url.pathname === '/session/ses_1') return json({ id: 'ses_1', metadata: {} });
if (url.pathname === '/session/ses_1') return json({
id: 'ses_1',
metadata: { openchamber: { knowledge_context_delivered: 'sig-before-compaction' } },
});
if (url.pathname === '/session/ses_1/message') return json([
{ info: { id: 'msg_agent', role: 'assistant', providerID: 'provider', modelID: 'model', agent: 'build' } },
{ info: { id: 'msg_summary', role: 'assistant', summary: true, time: { completed: 30 } } },
@@ -81,18 +84,30 @@ describe('context obligatory runtime', () => {
if (url.pathname === '/session/ses_1/prompt_async') return json({});
throw new Error(`Unexpected ${url.pathname}`);
}));
const resolvePending = vi.fn(async () => ({
text: '## Pinned notes\n\n- Remember this.',
signature: 'sig-1',
}));
const runtime = createContextObligatoryRuntime({
buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => '',
resolvePending: async () => ({ text: '## Pinned notes\n\n- Remember this.', signature: 'sig-1' }),
readPins: () => ({ notes: ['n1'], plans: [] }),
resolvePending,
},
});
await runtime.processPayload({ type: 'session.compacted', properties: { sessionID: 'ses_1' } });
await runtime.processPayload({
type: 'session.compacted',
properties: { sessionID: 'ses_1', directory: '/work/project' },
});
expect(resolvePending).toHaveBeenCalledWith(
'/work/project',
'',
{ notes: ['n1'], plans: [] },
);
const prompt = requests.find((request) => request.path.endsWith('/prompt_async'));
expect(JSON.parse(prompt.body).parts[0].text).toContain('Remember this.');
const patch = requests.find((request) => request.method === 'PATCH');
@@ -123,7 +138,7 @@ describe('context obligatory runtime', () => {
getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => '',
readPins: () => ({ notes: ['n1'], plans: [] }),
resolvePending: async () => ({ text: 'Pinned notes block', signature: 'sig-1' }),
},
});
@@ -152,7 +167,7 @@ describe('context obligatory runtime', () => {
getOpenCodeAuthHeaders: () => ({}),
sessionKnowledgeRuntime: {
metadataKey: 'knowledge_context_delivered',
readDeliveredSignature: () => 'sig-1',
readPins: () => ({ notes: [], plans: [] }),
resolvePending: async () => ({ text: '', signature: 'sig-1' }),
},
});
+1 -1
View File
@@ -39,4 +39,4 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
- Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks.
- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
- `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks.
- `POST /api/fs/upload` accepts one `application/octet-stream` body (up to 100 MB) with `path` and optional `overwrite=true` query parameters. It rejects existing files with `409` unless overwrite is explicit, and resolves the destination parent before writing so uploads cannot escape through workspace symlinks.
- `POST /api/fs/upload` accepts one `application/octet-stream` body with `path` and optional `overwrite=true` query parameters. The body streams into a same-directory temp file with a 100 MiB default cap configurable through `OPENCHAMBER_FS_UPLOAD_MAX_BYTES`; failed and oversized uploads clean up that temp file. New files commit through an atomic no-replace link, existing files return `409` unless overwrite is explicit, directory targets are rejected, and the destination parent resolves before writing so uploads cannot escape through workspace symlinks.
+72 -27
View File
@@ -108,6 +108,12 @@ const createGitCheckIgnoreTimeoutMs = () => {
return 2500;
};
const createUploadMaxBytes = () => {
const raw = Number(process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES);
if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
return 100 * 1024 * 1024;
};
const FILE_MIME_MAP = Object.freeze({
'.html': 'text/html',
'.htm': 'text/html',
@@ -139,28 +145,26 @@ const FILE_MIME_MAP = Object.freeze({
});
const MAX_SERVE_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const readUploadBody = async (req) => {
const declaredSize = Number.parseInt(req.headers?.['content-length'] || '0', 10);
if (Number.isFinite(declaredSize) && declaredSize > MAX_UPLOAD_BYTES) {
req.resume?.();
return null;
}
const chunks = [];
let size = 0;
const streamUploadBody = async (req, handle, maxBytes) => {
let received = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_UPLOAD_BYTES) {
received += buffer.length;
if (received > maxBytes) {
req.resume?.();
return null;
throw Object.assign(new Error('Upload exceeds the maximum allowed size'), { uploadTooLarge: true });
}
chunks.push(buffer);
}
return Buffer.concat(chunks, size);
let offset = 0;
while (offset < buffer.length) {
const { bytesWritten } = await handle.write(buffer, offset, buffer.length - offset, null);
if (!Number.isFinite(bytesWritten) || bytesWritten <= 0) {
throw new Error('Failed to write upload');
}
offset += bytesWritten;
}
}
};
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
@@ -1082,6 +1086,13 @@ export const registerFsRoutes = (app, dependencies) => {
return res.status(415).json({ error: 'Content-Type must be application/octet-stream' });
}
const maxUploadBytes = createUploadMaxBytes();
const declaredSize = Number(req.headers?.['content-length']);
if (Number.isFinite(declaredSize) && declaredSize > maxUploadBytes) {
req.resume?.();
return res.status(413).json({ error: `File exceeds maximum size of ${maxUploadBytes} bytes` });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
@@ -1114,22 +1125,50 @@ export const registerFsRoutes = (app, dependencies) => {
return res.status(403).json({ error: 'Access denied' });
}
const body = await readUploadBody(req);
if (!body) {
return res.status(413).json({ error: `File exceeds maximum size of ${MAX_UPLOAD_BYTES} bytes` });
if (existingPath) {
const stats = await fsPromises.stat(existingPath);
if (stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is a directory' });
}
if (!overwrite) {
req.resume?.();
return res.status(409).json({ error: 'File already exists', reason: 'already-exists' });
}
}
if (!overwrite) {
await fsPromises.writeFile(writePath, body, { flag: 'wx' });
} else {
const tmp = `${writePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const tmp = `${writePath}.upload-${crypto.randomUUID()}`;
let tempExists = false;
try {
const handle = await fsPromises.open(tmp, 'wx');
tempExists = true;
let streamError = null;
try {
await fsPromises.writeFile(tmp, body, { flag: 'wx' });
await fsPromises.rename(tmp, writePath);
await streamUploadBody(req, handle, maxUploadBytes);
} catch (error) {
await fsPromises.unlink(tmp).catch(() => {});
throw error;
streamError = error;
}
try {
await handle.close();
} catch (error) {
if (!streamError) throw error;
}
if (streamError) throw streamError;
if (overwrite) {
await fsPromises.rename(tmp, writePath);
} else {
// A same-directory hard link commits without replacing a target that
// appeared after the existence check. The temp file is already fully
// flushed, so readers never observe a partial upload.
await fsPromises.link(tmp, writePath);
await fsPromises.unlink(tmp).catch(() => {});
}
tempExists = false;
} catch (error) {
if (tempExists) {
await fsPromises.unlink(tmp).catch(() => {});
}
throw error;
}
return res.json({ success: true, path: resolved.resolved });
@@ -1141,6 +1180,12 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'Destination directory not found', reason: 'not-found' });
}
if (err && typeof err === 'object' && err.uploadTooLarge) {
return res.status(413).json({ error: `File exceeds maximum size of ${maxUploadBytes} bytes` });
}
if (err && typeof err === 'object' && (err.code === 'EISDIR' || err.code === 'ENOTDIR')) {
return res.status(400).json({ error: 'Specified path is a directory' });
}
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access denied');
}
+122 -40
View File
@@ -146,7 +146,11 @@ const registerUpload = (fsPromises) => {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
realpath: async (targetPath) => {
if (targetPath === '/repo') return targetPath;
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
},
stat: async () => ({ isDirectory: () => false }),
...fsPromises,
},
spawn: vi.fn(),
@@ -253,16 +257,22 @@ const callWrite = async (handler, body) => {
return res;
};
const callUpload = async (handler, { body = Buffer.from('upload'), path: filePath = '/repo/file.bin', overwrite = false } = {}) => {
const callUpload = async (handler, {
body = Buffer.from('upload'),
chunks,
includeContentLength = true,
path: filePath = '/repo/file.bin',
overwrite = false,
} = {}) => {
const res = createMockResponse();
const uploadChunks = chunks ?? [body];
const headers = { 'content-type': 'application/octet-stream' };
if (includeContentLength) headers['content-length'] = String(body.length);
const req = {
headers: {
'content-type': 'application/octet-stream',
'content-length': String(body.length),
},
headers,
query: { path: filePath, overwrite: overwrite ? 'true' : undefined },
async *[Symbol.asyncIterator]() {
yield body;
yield* uploadChunks;
},
};
await handler(req, res);
@@ -403,29 +413,40 @@ describe('fs write', () => {
});
describe('fs upload', () => {
it('creates a binary file without overwriting existing content', async () => {
it('streams a binary file to temp storage before committing it without overwrite', async () => {
const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length }));
const close = vi.fn(async () => undefined);
const fsPromises = {
writeFile: vi.fn(async () => undefined),
open: vi.fn(async () => ({ write, close })),
link: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
unlink: vi.fn(async () => undefined),
};
const handler = registerUpload(fsPromises);
const res = await callUpload(handler, { body: Buffer.from([0, 1, 2, 255]) });
const body = Buffer.from([0, 1, 2, 255]);
const res = await callUpload(handler, {
body,
chunks: [body.subarray(0, 2), body.subarray(2)],
});
expect(res.body).toEqual({ success: true, path: '/repo/file.bin' });
expect(fsPromises.writeFile).toHaveBeenCalledWith(
'/repo/file.bin',
Buffer.from([0, 1, 2, 255]),
{ flag: 'wx' },
);
const tmp = fsPromises.open.mock.calls[0][0];
expect(tmp).toMatch(/^\/repo\/file\.bin\.upload-/);
expect(fsPromises.open).toHaveBeenCalledWith(tmp, 'wx');
expect(write).toHaveBeenNthCalledWith(1, Buffer.from([0, 1]), 0, 2, null);
expect(write).toHaveBeenNthCalledWith(2, Buffer.from([2, 255]), 0, 2, null);
expect(close).toHaveBeenCalledTimes(1);
expect(fsPromises.link).toHaveBeenCalledWith(tmp, '/repo/file.bin');
expect(fsPromises.unlink).toHaveBeenCalledWith(tmp);
expect(fsPromises.rename).not.toHaveBeenCalled();
});
it('returns a conflict instead of silently replacing an existing file', async () => {
const error = Object.assign(new Error('exists'), { code: 'EEXIST' });
const fsPromises = {
writeFile: vi.fn(async () => { throw error; }),
realpath: vi.fn(async (targetPath) => targetPath),
stat: vi.fn(async () => ({ isDirectory: () => false })),
open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })),
};
const handler = registerUpload(fsPromises);
@@ -433,11 +454,15 @@ describe('fs upload', () => {
expect(res.statusCode).toBe(409);
expect(res.body).toEqual({ error: 'File already exists', reason: 'already-exists' });
expect(fsPromises.open).not.toHaveBeenCalled();
});
it('atomically replaces a file only when overwrite is explicit', async () => {
const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length }));
const fsPromises = {
writeFile: vi.fn(async () => undefined),
realpath: vi.fn(async (targetPath) => targetPath),
stat: vi.fn(async () => ({ isDirectory: () => false })),
open: vi.fn(async () => ({ write, close: vi.fn(async () => undefined) })),
rename: vi.fn(async () => undefined),
unlink: vi.fn(async () => undefined),
};
@@ -446,16 +471,31 @@ describe('fs upload', () => {
const res = await callUpload(handler, { overwrite: true });
expect(res.body).toEqual({ success: true, path: '/repo/file.bin' });
const tmp = fsPromises.writeFile.mock.calls[0][0];
expect(tmp).toMatch(/^\/repo\/file\.bin\.tmp-/);
expect(fsPromises.writeFile).toHaveBeenCalledWith(tmp, Buffer.from('upload'), { flag: 'wx' });
const tmp = fsPromises.open.mock.calls[0][0];
expect(tmp).toMatch(/^\/repo\/file\.bin\.upload-/);
expect(write).toHaveBeenCalledWith(Buffer.from('upload'), 0, 6, null);
expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/file.bin');
});
it('rejects an existing directory before reading the upload body', async () => {
const fsPromises = {
realpath: vi.fn(async (targetPath) => targetPath),
stat: vi.fn(async () => ({ isDirectory: () => true })),
open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })),
};
const handler = registerUpload(fsPromises);
const res = await callUpload(handler);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Specified path is a directory' });
expect(fsPromises.open).not.toHaveBeenCalled();
});
it('rejects a destination parent that resolves outside the workspace', async () => {
const fsPromises = {
realpath: vi.fn(async (targetPath) => targetPath === '/repo/link' ? '/outside' : targetPath),
writeFile: vi.fn(async () => undefined),
open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })),
};
const handler = registerUpload(fsPromises);
@@ -463,31 +503,73 @@ describe('fs upload', () => {
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: 'Access denied' });
expect(fsPromises.writeFile).not.toHaveBeenCalled();
expect(fsPromises.open).not.toHaveBeenCalled();
});
it('rejects streamed bodies larger than 100 MB', async () => {
it('cleans up a partial temp file when the configured streaming limit is exceeded', async () => {
const previous = process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES;
process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = '5';
const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length }));
const fsPromises = {
writeFile: vi.fn(async () => undefined),
open: vi.fn(async () => ({ write, close: vi.fn(async () => undefined) })),
link: vi.fn(async () => undefined),
unlink: vi.fn(async () => undefined),
};
try {
const handler = registerUpload(fsPromises);
const res = await callUpload(handler, {
body: Buffer.from('123456'),
chunks: [Buffer.from('123'), Buffer.from('456')],
includeContentLength: false,
});
expect(res.statusCode).toBe(413);
expect(res.body).toEqual({ error: 'File exceeds maximum size of 5 bytes' });
expect(write).toHaveBeenCalledWith(Buffer.from('123'), 0, 3, null);
expect(fsPromises.link).not.toHaveBeenCalled();
expect(fsPromises.unlink).toHaveBeenCalledWith(expect.stringMatching(/^\/repo\/file\.bin\.upload-/));
} finally {
if (previous === undefined) delete process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES;
else process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = previous;
}
});
it('rejects a declared oversized upload before opening a temp file', async () => {
const previous = process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES;
process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = '5';
const fsPromises = {
open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })),
};
try {
const handler = registerUpload(fsPromises);
const res = await callUpload(handler, { body: Buffer.from('123456') });
expect(res.statusCode).toBe(413);
expect(res.body).toEqual({ error: 'File exceeds maximum size of 5 bytes' });
expect(fsPromises.open).not.toHaveBeenCalled();
} finally {
if (previous === undefined) delete process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES;
else process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = previous;
}
});
it('keeps the existing file when a target appears before the atomic commit', async () => {
const error = Object.assign(new Error('exists'), { code: 'EEXIST' });
const fsPromises = {
open: vi.fn(async () => ({
write: vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length })),
close: vi.fn(async () => undefined),
})),
link: vi.fn(async () => { throw error; }),
unlink: vi.fn(async () => undefined),
};
const handler = registerUpload(fsPromises);
const chunk = Buffer.alloc(1024 * 1024);
const req = {
headers: { 'content-type': 'application/octet-stream' },
query: { path: '/repo/file.bin' },
async *[Symbol.asyncIterator]() {
for (let index = 0; index < 101; index += 1) {
yield chunk;
}
},
};
const res = createMockResponse();
await handler(req, res);
const res = await callUpload(handler);
expect(res.statusCode).toBe(413);
expect(res.body).toEqual({ error: `File exceeds maximum size of ${100 * 1024 * 1024} bytes` });
expect(fsPromises.writeFile).not.toHaveBeenCalled();
expect(res.statusCode).toBe(409);
expect(res.body).toEqual({ error: 'File already exists', reason: 'already-exists' });
expect(fsPromises.unlink).toHaveBeenCalledWith(expect.stringMatching(/^\/repo\/file\.bin\.upload-/));
});
});
@@ -111,12 +111,13 @@ This module provides OpenCode server integration utilities for the web server ru
- `markSessionUnviewed(sessionId, clientId)`
- `markUserMessageSent(sessionId)`
- `resetAllSessionActivityToIdle()`
- `interruptBusySessionsAfterRestart()`: settles every session whose authoritative status is `busy`/`retry` or whose activity phase is still busy, broadcasts `openchamber:session-status` idle plus an OpenCode-shaped `session.error`, resets leftover activity/cooldowns, and returns the interrupted session IDs in stable order.
- `dispose()`
The runtime maintains active-session count incrementally from idempotent activity phase transitions. Upstream stall-timeout and lifecycle health checks read it in O(1); the hourly cleanup removes activity phases older than 24 hours without broadcasting synthetic state transitions. Snapshot generation remains reserved for the session-activity API.
## Public exports (lifecycle.js)
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart; `index.js` wires it to `messageStreamRuntime.rebindUpstream()` so event-stream readers rebind to the possibly-new port (a restart can land on a new port while an orphaned process keeps the old one, which would otherwise leave the chat UI silent — issue #2638).
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart. `index.js` rebinds event-stream readers to the possibly-new port (#2638), then calls `interruptBusySessionsAfterRestart()` and broadcasts one `opencode-restart-interrupted` UI notification when interrupted turns exist (#2943).
- Returned API:
- `startOpenCode()`
- `restartOpenCode()`
@@ -147,6 +148,8 @@ macOS `say` voice enumeration starts concurrently with server composition. The s
Transport-triggered health checks share the periodic monitor's failure accounting interval. Rapid WS reconnect callbacks therefore cannot exhaust the managed-process restart threshold using one cached unhealthy result; an exited managed process still restarts immediately.
Managed health failures are classified as `timeout`, `connection_refused`, `connection_reset`, `invalid_response`, or `error`. The lifecycle retains the latest counted failure with a bounded detail string and source. Managed process wrappers continue capturing a sanitized, bounded stderr tail after readiness and retain exit code/signal. Before replacing a managed process, lifecycle snapshots the reason, latest health failure, process diagnostics/aliveness, busy-session count, and timestamp into `lastOpenCodeRestartDiagnostics`; successful startup does not clear this snapshot, and `/health` exposes it for post-restart diagnosis without process environment or credentials.
## Public exports (env-runtime.js)
- `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state.
- OpenCode CLI resolution order is persisted settings, environment overrides, bundled Desktop CLI when available, PATH, known install locations, then platform shell discovery.
@@ -48,12 +48,11 @@ import {
import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js';
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js';
import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js';
import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js';
import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js';
import { getCacheKey, scanWithCache } from '../skills-catalog/cache.js';
import { parseSkillRepoSource } from '../skills-catalog/source.js';
import { scanSkillsRepository } from '../skills-catalog/scan.js';
import { installSkillsFromRepository } from '../skills-catalog/install.js';
import { scanClawdHubPage } from '../skills-catalog/clawdhub/scan.js';
import { installSkillsFromClawdHub } from '../skills-catalog/clawdhub/install.js';
import { fetchGitHubRepoMetas } from '../skills-catalog/github-meta.js';
export const createFeatureRoutesRuntime = (dependencies) => {
const {
@@ -290,14 +289,11 @@ export const createFeatureRoutesRuntime = (dependencies) => {
SKILL_DIR,
getCuratedSkillsSources,
getCacheKey,
getCachedScan,
setCachedScan,
scanWithCache,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
scanClawdHubPage,
installSkillsFromClawdHub,
isClawdHubSource,
fetchGitHubRepoMetas,
getProfiles,
getProfile,
});
+202 -26
View File
@@ -22,6 +22,65 @@ const OPENCODE_HEALTH_PATH = '/global/health';
// tails are unlikely to be the user's first click and just add background work.
const WARMUP_DIRECTORY_LIMIT = 4;
const WARMUP_REQUEST_TIMEOUT_MS = 30000;
const MANAGED_STDERR_TAIL_MAX_BYTES = 32 * 1024;
const HEALTH_FAILURE_DETAIL_MAX_LENGTH = 256;
const getBoundedTextTail = (value, maxBytes) => {
const buffer = Buffer.from(String(value ?? ''));
if (buffer.byteLength <= maxBytes) return buffer.toString();
return buffer.subarray(buffer.byteLength - maxBytes).toString();
};
const sanitizeDiagnosticText = (value) => String(value ?? '')
.replace(/(https?:\/\/)[^/\s:@]+:[^/\s@]+@/gi, '$1[redacted]@')
.replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]')
// Unquoted `Authorization: <scheme> <credential>` values must be handled
// before the generic key/value rule below: that rule stops at whitespace, so
// it would redact only the scheme word and leave the credential intact.
// Scoped to authorization-style keys so ordinary prose using "basic" or
// "token" is not mangled.
.replace(
/(^|[\s,{\[])((?:"|')?[a-z0-9_.-]{0,80}authorization[a-z0-9_.-]{0,80}(?:"|')?\s*[:=]\s*(?:"|')?(?:basic|bearer|token)\s+)[^\s,;"']+/gim,
'$1$2[redacted]',
)
.replace(/([?&][^=&#\s]*(?:token|api[_-]?key|password|secret|authorization|credential|private[_-]?key)[^=&#\s]*=)[^&#\s]+/gi, '$1[redacted]')
.replace(
/(^|[\s,{\[])((?:"|')?[a-z0-9_.-]{0,80}(?:token|api[_-]?key|password|secret|authorization|credential|private[_-]?key)[a-z0-9_.-]{0,80}(?:"|')?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gim,
'$1$2[redacted]',
);
const getHealthFailureDetail = (error) => {
const name = String(error?.name || 'Error');
const message = String(error?.message || error || 'Unknown error');
return sanitizeDiagnosticText(`${name}: ${message}`).slice(0, HEALTH_FAILURE_DETAIL_MAX_LENGTH);
};
const classifyHealthProbeError = (error) => {
const name = String(error?.name || '');
const code = String(error?.code || '').toUpperCase();
const message = String(error?.message || error || '');
const normalizedMessage = message.toLowerCase();
if (
name === 'AbortError'
|| name === 'TimeoutError'
|| normalizedMessage.includes('the operation was aborted')
|| normalizedMessage.includes('abortsignal.timeout')
) {
return { class: 'timeout', detail: getHealthFailureDetail(error) };
}
if (code === 'ECONNREFUSED' || normalizedMessage.includes('econnrefused')) {
return { class: 'connection_refused', detail: getHealthFailureDetail(error) };
}
if (
code === 'ECONNRESET'
|| normalizedMessage.includes('econnreset')
|| normalizedMessage.includes('socket hang up')
) {
return { class: 'connection_reset', detail: getHealthFailureDetail(error) };
}
return { class: 'error', detail: getHealthFailureDetail(error) };
};
export const createOpenCodeLifecycleRuntime = (deps) => {
const {
@@ -88,6 +147,36 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
}
};
const snapshotManagedOpenCodeProcess = (child = state.openCodeProcess) => {
if (!child) return null;
const snapshot = {
pid: child.pid || null,
exitCode: child.exitCode ?? null,
signalCode: child.signalCode ?? null,
stderrTail: getBoundedTextTail(
sanitizeDiagnosticText(child.stderrTail ?? ''),
MANAGED_STDERR_TAIL_MAX_BYTES,
),
};
state.lastManagedOpenCodeProcess = snapshot;
return snapshot;
};
const captureRestartDiagnostics = (reason) => {
const processSnapshot = snapshotManagedOpenCodeProcess();
const diagnostics = {
reason: sanitizeDiagnosticText(String(reason || 'managed-restart')).slice(0, HEALTH_FAILURE_DETAIL_MAX_LENGTH),
healthFailure: state.lastOpenCodeHealthFailure ? { ...state.lastOpenCodeHealthFailure } : null,
process: processSnapshot
? { ...processSnapshot, alive: isManagedOpenCodeProcessAlive() }
: null,
busySessionCount: getActiveSessionCount(),
at: new Date(now()).toISOString(),
};
state.lastOpenCodeRestartDiagnostics = diagnostics;
console.warn('[lifecycle] managed OpenCode restart diagnostics', diagnostics);
};
const waitForChildProcessClose = (child, timeoutMs) => new Promise((resolve) => {
if (!child || hasChildProcessExited(child)) {
resolve(true);
@@ -297,6 +386,34 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
let runtimeStderrTail = '';
let runtimeStderrAttached = false;
let observedExitCode = null;
let observedSignalCode = null;
const getManagedProcessSnapshot = () => ({
pid: child.pid || null,
exitCode: observedExitCode ?? child.exitCode ?? null,
signalCode: observedSignalCode ?? child.signalCode ?? null,
stderrTail: getBoundedTextTail(sanitizeDiagnosticText(runtimeStderrTail), MANAGED_STDERR_TAIL_MAX_BYTES),
});
const recordManagedProcessExit = (code, signal) => {
if (code !== null && code !== undefined) observedExitCode = code;
if (signal !== null && signal !== undefined) observedSignalCode = signal;
state.lastManagedOpenCodeProcess = getManagedProcessSnapshot();
};
const attachRuntimeStderrCapture = () => {
if (runtimeStderrAttached) return;
runtimeStderrAttached = true;
child.stderr?.on('data', (chunk) => {
runtimeStderrTail = getBoundedTextTail(
`${runtimeStderrTail}${chunk.toString()}`,
MANAGED_STDERR_TAIL_MAX_BYTES,
);
});
};
child.on('exit', recordManagedProcessExit);
child.on('close', recordManagedProcessExit);
const url = await new Promise((resolve, reject) => {
let stdout = '';
@@ -323,6 +440,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
finish(reject, new Error(`Failed to parse server url from output: ${line}`));
return;
}
attachRuntimeStderrCapture();
finish(resolve, match[1]);
return;
}
@@ -371,10 +489,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
url,
pid: child.pid || null,
get exitCode() {
return child.exitCode;
return observedExitCode ?? child.exitCode;
},
get signalCode() {
return child.signalCode;
return observedSignalCode ?? child.signalCode;
},
get stderrTail() {
return getManagedProcessSnapshot().stderrTail;
},
async close() {
await closeManagedOpenCodeChild(child);
@@ -416,9 +537,15 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
});
};
const isOpenCodeProcessHealthy = async () => {
const probeOpenCodeHealthDetailed = async () => {
if (!state.openCodeProcess || !state.openCodePort) {
return false;
return {
healthy: false,
failure: {
class: 'error',
detail: 'Managed OpenCode process or port is unavailable',
},
};
}
try {
@@ -430,14 +557,47 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
},
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
if (!response.ok) return false;
const body = await response.json().catch(() => null);
return body?.healthy === true;
} catch {
return false;
if (!response.ok) {
return {
healthy: false,
failure: {
class: 'invalid_response',
detail: `Health endpoint returned HTTP ${response.status ?? 'unknown'}`,
},
};
}
let body;
try {
body = await response.json();
} catch {
return {
healthy: false,
failure: {
class: 'invalid_response',
detail: 'Health endpoint returned invalid JSON',
},
};
}
if (body?.healthy !== true) {
return {
healthy: false,
failure: {
class: 'invalid_response',
detail: 'Health endpoint did not report healthy=true',
},
};
}
return { healthy: true, failure: null };
} catch (error) {
return {
healthy: false,
failure: classifyHealthProbeError(error),
};
}
};
const isOpenCodeProcessHealthy = async () => (await probeOpenCodeHealthDetailed()).healthy;
const probeExternalOpenCode = async (port, origin) => {
if (!port || port <= 0) {
return false;
@@ -617,7 +777,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
throw lastError;
};
const restartOpenCode = async () => {
const restartOpenCode = async (reason = 'managed-restart') => {
if (state.isShuttingDown) return;
if (state.currentRestartPromise) {
await state.currentRestartPromise;
@@ -655,6 +815,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
return;
}
captureRestartDiagnostics(reason);
const portToKill = state.openCodePort;
if (state.openCodeProcess) {
@@ -820,7 +981,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
clearResolvedOpenCodeBinary();
await applyOpencodeBinaryFromSettings();
await restartOpenCode();
await restartOpenCode(reason || 'config-change');
// A managed OpenCode process is restarted (and thus re-reads config from
// disk) by restartOpenCode(). An external OpenCode server is NOT owned by
@@ -1010,17 +1171,17 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
const probeOpenCodeHealth = async () => {
const checkedAt = now();
if (lastHealthProbeResult && checkedAt - lastHealthProbeResult.at < HEALTH_CHECK_RESULT_CACHE_MS) {
return lastHealthProbeResult.healthy;
return lastHealthProbeResult;
}
if (healthProbePromise) {
return healthProbePromise;
}
healthProbePromise = isOpenCodeProcessHealthy()
.then((healthy) => {
lastHealthProbeResult = { at: now(), healthy };
return healthy;
healthProbePromise = probeOpenCodeHealthDetailed()
.then((result) => {
lastHealthProbeResult = { at: now(), ...result };
return lastHealthProbeResult;
})
.finally(() => {
healthProbePromise = null;
@@ -1033,13 +1194,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
const activeCount = getActiveSessionCount();
if (activeCount === 0) {
lastUnhealthyWithBusySessionsAt = 0;
return false;
return { skip: false, staleBusy: false };
}
const checkedAt = now();
if (!lastUnhealthyWithBusySessionsAt) {
lastUnhealthyWithBusySessionsAt = checkedAt;
return true;
return { skip: true, staleBusy: false };
}
if (checkedAt - lastUnhealthyWithBusySessionsAt >= STALE_BUSY_GRACE_MS) {
@@ -1047,10 +1208,10 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
`[lifecycle] OpenCode unhealthy with ${activeCount} busy session(s) for > 2 min — forcing restart`
);
lastUnhealthyWithBusySessionsAt = 0;
return false;
return { skip: false, staleBusy: true };
}
return true;
return { skip: true, staleBusy: false };
};
const runHealthCheckCycle = async (source) => {
@@ -1058,13 +1219,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
if (healthCheckCyclePromise) return healthCheckCyclePromise;
healthCheckCyclePromise = (async () => {
const healthy = await probeOpenCodeHealth();
if (!healthy) {
const healthResult = await probeOpenCodeHealth();
if (!healthResult.healthy) {
if (!isManagedOpenCodeProcessAlive()) {
console.log(`[lifecycle] ${source} health check: OpenCode process exited, restarting...`);
consecutiveHealthFailures = 0;
lastHealthProbeResult = null;
await restartOpenCode();
await restartOpenCode(`${source}-process-exited`);
return;
}
const checkedAt = now();
@@ -1073,15 +1234,30 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
}
lastCountedHealthFailureAt = checkedAt;
consecutiveHealthFailures += 1;
const healthFailure = healthResult.failure || {
class: 'error',
detail: 'Health check failed without diagnostic detail',
};
state.lastOpenCodeHealthFailure = {
class: healthFailure.class,
detail: healthFailure.detail,
at: new Date(checkedAt).toISOString(),
source,
};
console.warn(
`[lifecycle] ${source} health check failed (${consecutiveHealthFailures}/${HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES})`
`[lifecycle] ${source} health check failed (${consecutiveHealthFailures}/${HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES}) class=${healthFailure.class}`
);
if (consecutiveHealthFailures < HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES) return;
if (shouldSkipRestartForBusySessions()) return;
const busyDecision = shouldSkipRestartForBusySessions();
if (busyDecision.skip) return;
console.log(`[lifecycle] ${source} health check failure threshold reached, restarting OpenCode...`);
consecutiveHealthFailures = 0;
lastHealthProbeResult = null;
await restartOpenCode();
await restartOpenCode(
busyDecision.staleBusy
? `${source}-stale-busy-health-failure`
: `${source}-health-failure`,
);
} else {
resetHealthFailureState();
}
@@ -62,6 +62,9 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
openCodeApiPrefixDetected: false,
openCodeApiDetectionTimer: null,
lastOpenCodeError: null,
lastOpenCodeHealthFailure: null,
lastManagedOpenCodeProcess: null,
lastOpenCodeRestartDiagnostics: null,
isOpenCodeReady: false,
openCodeNotReadySince: 0,
isExternalOpenCode: false,
@@ -75,7 +78,7 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
...stateOverrides,
};
return createOpenCodeLifecycleRuntime({
const runtime = createOpenCodeLifecycleRuntime({
state,
env: {
ENV_CONFIGURED_OPENCODE_PORT: 45678,
@@ -111,6 +114,8 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
})),
...overrides,
});
runtime.testState = state;
return runtime;
};
describe('OpenCode lifecycle', () => {
@@ -234,6 +239,61 @@ describe('OpenCode lifecycle', () => {
warn.mockRestore();
});
it.each([
{
name: 'timeout',
expectedClass: 'timeout',
fetchResult: () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
throw error;
},
},
{
name: 'connection refusal',
expectedClass: 'connection_refused',
fetchResult: () => {
const error = new Error('connect ECONNREFUSED 127.0.0.1:45678');
error.code = 'ECONNREFUSED';
throw error;
},
},
{
name: 'invalid JSON',
expectedClass: 'invalid_response',
fetchResult: () => ({
ok: true,
json: async () => {
throw new SyntaxError('Unexpected token');
},
}),
},
])('classifies and stores a counted $name health failure', async ({ expectedClass, fetchResult }) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
globalThis.fetch = vi.fn(fetchResult);
const runtime = createRuntime({}, {
openCodePort: 45678,
openCodeProcess: {
pid: process.pid,
exitCode: null,
signalCode: null,
close: vi.fn(async () => {}),
},
isOpenCodeReady: true,
});
await runtime.triggerHealthCheck();
expect(runtime.testState.lastOpenCodeHealthFailure).toEqual({
class: expectedClass,
detail: expect.any(String),
at: expect.any(String),
source: 'immediate',
});
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`class=${expectedClass}`));
warn.mockRestore();
});
it('does not mistake a live managed process wrapper for an exited child', async () => {
const close = vi.fn(async () => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -320,6 +380,124 @@ describe('OpenCode lifecycle', () => {
expect(onOpenCodeRestarted).toHaveBeenCalledTimes(1);
});
it('retains post-listen stderr and exited process diagnostics across restart', async () => {
const firstChild = createMockChild();
const replacement = createMockChild();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
globalThis.fetch = vi.fn(async () => ({
ok: false,
status: 503,
json: async () => null,
}));
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
firstChild.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return firstChild;
});
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return replacement;
});
const runtime = createRuntime();
const server = await runtime.startOpenCode();
runtime.testState.openCodeProcess = server;
firstChild.stderr.emit(
'data',
`${'x'.repeat(40 * 1024)}\ntoken=runtime-secret\nruntime worker failed after startup\n`,
);
firstChild.exitCode = 7;
firstChild.emit('exit', 7, null);
expect(server.exitCode).toBe(7);
expect(Buffer.byteLength(server.stderrTail)).toBeLessThanOrEqual(32 * 1024);
expect(server.stderrTail).not.toContain('runtime-secret');
expect(server.stderrTail).toContain('runtime worker failed after startup');
await runtime.triggerHealthCheck();
expect(runtime.testState.lastOpenCodeRestartDiagnostics).toEqual({
reason: 'immediate-process-exited',
healthFailure: null,
process: {
pid: 12345,
exitCode: 7,
signalCode: null,
stderrTail: expect.stringContaining('runtime worker failed after startup'),
alive: false,
},
busySessionCount: 0,
at: expect.any(String),
});
expect(runtime.testState.lastManagedOpenCodeProcess).toEqual({
pid: 12345,
exitCode: 7,
signalCode: null,
stderrTail: expect.stringContaining('runtime worker failed after startup'),
});
await runtime.testState.openCodeProcess.close();
warn.mockRestore();
});
it('redacts Authorization scheme credentials from stderr diagnostics', async () => {
const firstChild = createMockChild();
const replacement = createMockChild();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
globalThis.fetch = vi.fn(async () => ({
ok: false,
status: 503,
json: async () => null,
}));
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
firstChild.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return firstChild;
});
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return replacement;
});
const runtime = createRuntime();
const server = await runtime.startOpenCode();
runtime.testState.openCodeProcess = server;
firstChild.stderr.emit(
'data',
'request rejected: Authorization: Basic dXNlcjpwYXNz\n'
+ 'authorization: basic bG93ZXI6Y2FzZQ==\n'
+ 'Authorization: Bearer fake-bearer-token-value\n'
+ 'falling back to basic health monitor\n'
+ 'runtime worker failed after startup\n',
);
firstChild.exitCode = 7;
firstChild.emit('exit', 7, null);
expect(server.stderrTail).not.toContain('dXNlcjpwYXNz');
expect(server.stderrTail).not.toContain('bG93ZXI6Y2FzZQ');
expect(server.stderrTail).not.toContain('fake-bearer-token-value');
expect(server.stderrTail).toContain('falling back to basic health monitor');
expect(server.stderrTail).toContain('runtime worker failed after startup');
await runtime.triggerHealthCheck();
const diagnosticsTail = runtime.testState.lastOpenCodeRestartDiagnostics.process.stderrTail;
expect(diagnosticsTail).not.toContain('dXNlcjpwYXNz');
expect(diagnosticsTail).not.toContain('bG93ZXI6Y2FzZQ');
expect(diagnosticsTail).not.toContain('fake-bearer-token-value');
expect(diagnosticsTail).toContain('falling back to basic health monitor');
expect(diagnosticsTail).toContain('runtime worker failed after startup');
await runtime.testState.openCodeProcess.close();
warn.mockRestore();
});
it('does not call onOpenCodeRestarted when a managed restart fails', async () => {
const close = vi.fn(async () => {});
const onOpenCodeRestarted = vi.fn();
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { createSessionRuntime } from './session-runtime.js';
describe('managed OpenCode restart session recovery', () => {
it('settles busy sessions and broadcasts one interruption notification', () => {
const events = [];
const broadcastUiNotification = vi.fn();
const rebindUpstream = vi.fn();
const sessionRuntime = createSessionRuntime({
writeSseEvent() {},
getNotificationClients: () => new Set(),
broadcastEvent: (event) => events.push(event),
});
const onOpenCodeRestarted = () => {
rebindUpstream();
const { sessionIds } = sessionRuntime.interruptBusySessionsAfterRestart();
if (sessionIds.length > 0) {
const multiple = sessionIds.length > 1;
broadcastUiNotification({
title: multiple ? 'Chats interrupted' : 'Chat interrupted',
body: multiple
? 'OpenCode restarted during running responses. Send a message in each chat to continue.'
: 'OpenCode restarted during a running response. Send a message to continue.',
tag: 'opencode-restart-interrupted',
kind: 'opencode-restart-interrupted',
sessionId: sessionIds[0],
});
}
};
const markBusy = (sessionID) => sessionRuntime.processOpenCodeSsePayload({
type: 'session.status',
properties: { sessionID, status: { type: 'busy' } },
});
try {
markBusy('session-1');
markBusy('session-2');
markBusy('session-3');
events.length = 0;
onOpenCodeRestarted();
expect(rebindUpstream).toHaveBeenCalledOnce();
expect(sessionRuntime.getActiveSessionCount()).toBe(0);
expect(Object.values(sessionRuntime.getSessionStateSnapshot()).map((state) => state.status))
.toEqual(['idle', 'idle', 'idle']);
expect(events.filter((event) => event.type === 'openchamber:session-status')).toHaveLength(3);
expect(events.filter((event) => event.type === 'session.error')).toHaveLength(3);
expect(broadcastUiNotification).toHaveBeenCalledOnce();
expect(broadcastUiNotification).toHaveBeenCalledWith(expect.objectContaining({
kind: 'opencode-restart-interrupted',
sessionId: 'session-1',
}));
} finally {
sessionRuntime.dispose();
}
});
});
@@ -130,7 +130,8 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
const now = Date.now();
const existing = sessionStates.get(sessionId);
const existingAttentionState = sessionAttentionStates.get(sessionId);
if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) {
const isRestartInterruption = metadata.reason === 'opencode-restart';
if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status && !isRestartInterruption) {
return;
}
@@ -145,7 +146,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
const attentionState = sessionAttentionStates.get(sessionId);
const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention;
const clients = getNotificationClients();
if (!existing || existing.status !== status || attentionChanged) {
if (!existing || existing.status !== status || attentionChanged || isRestartInterruption) {
const state = sessionStates.get(sessionId);
const syntheticPayload = {
type: 'openchamber:session-status',
@@ -293,6 +294,41 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
}
};
const interruptBusySessionsAfterRestart = () => {
const interruptedSessionIds = new Set();
for (const [sessionId, state] of sessionStates) {
if (state.status === 'busy' || state.status === 'retry') {
interruptedSessionIds.add(sessionId);
}
}
for (const [sessionId, activity] of sessionActivityPhases) {
if (activity.phase === 'busy') {
interruptedSessionIds.add(sessionId);
}
}
const eventId = `opencode-restart-${Date.now()}`;
for (const sessionId of interruptedSessionIds) {
updateSessionState(sessionId, 'idle', eventId, {
message: 'Interrupted by OpenCode restart',
reason: 'opencode-restart',
});
broadcastEvent?.({
type: 'session.error',
properties: {
sessionID: sessionId,
error: {
name: 'MessageAbortedError',
message: 'The running turn was interrupted when OpenCode restarted.',
},
},
});
}
resetAllSessionActivityToIdle();
return { sessionIds: [...interruptedSessionIds] };
};
const cleanupOldSessionStates = () => {
const now = Date.now();
for (const [sessionId, data] of sessionStates) {
@@ -358,6 +394,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
markSessionUnviewed,
markUserMessageSent,
resetAllSessionActivityToIdle,
interruptBusySessionsAfterRestart,
dispose,
};
};
@@ -179,6 +179,80 @@ describe('session runtime', () => {
expect(runtime.getActiveSessionCount()).toBe(0);
});
it('interrupts busy sessions after restart and broadcasts terminal events once', () => {
const events = [];
const runtime = createSessionRuntime({
writeSseEvent() {},
getNotificationClients: () => new Set(),
broadcastEvent: (event) => events.push(event),
});
runtimes.push(runtime);
const status = (sessionID, type) => runtime.processOpenCodeSsePayload({
type: 'session.status',
properties: { sessionID, status: { type } },
});
status('session-busy-1', 'busy');
status('session-busy-2', 'retry');
status('session-busy-3', 'busy');
status('session-idle', 'idle');
expect(runtime.getActiveSessionCount()).toBe(3);
events.length = 0;
expect(runtime.interruptBusySessionsAfterRestart()).toEqual({
sessionIds: ['session-busy-1', 'session-busy-2', 'session-busy-3'],
});
expect(runtime.getActiveSessionCount()).toBe(0);
expect(runtime.getSessionActivitySnapshot()).toEqual({
'session-busy-1': { type: 'idle' },
'session-busy-2': { type: 'idle' },
'session-busy-3': { type: 'idle' },
'session-idle': { type: 'idle' },
});
expect(runtime.getSessionStateSnapshot()).toEqual({
'session-busy-1': expect.objectContaining({
status: 'idle',
metadata: expect.objectContaining({
message: 'Interrupted by OpenCode restart',
reason: 'opencode-restart',
}),
}),
'session-busy-2': expect.objectContaining({ status: 'idle' }),
'session-busy-3': expect.objectContaining({ status: 'idle' }),
'session-idle': expect.objectContaining({ status: 'idle' }),
});
const terminalEvents = events.filter((event) => (
event.type === 'openchamber:session-status' || event.type === 'session.error'
));
expect(terminalEvents).toHaveLength(6);
for (const sessionId of ['session-busy-1', 'session-busy-2', 'session-busy-3']) {
expect(terminalEvents).toContainEqual({
type: 'openchamber:session-status',
properties: expect.objectContaining({
sessionID: sessionId,
status: 'idle',
}),
});
expect(terminalEvents).toContainEqual({
type: 'session.error',
properties: {
sessionID: sessionId,
error: {
name: 'MessageAbortedError',
message: 'The running turn was interrupted when OpenCode restarted.',
},
},
});
}
expect(terminalEvents.some((event) => event.properties.sessionID === 'session-idle')).toBe(false);
events.length = 0;
expect(runtime.interruptBusySessionsAfterRestart()).toEqual({ sessionIds: [] });
expect(events).toEqual([]);
});
it('restores activity when busy interrupts cooldown without timer underflow', () => {
vi.useFakeTimers();
const runtime = createSessionRuntime({
@@ -40,14 +40,11 @@ export const registerSkillRoutes = (app, dependencies) => {
SKILL_DIR,
getCuratedSkillsSources,
getCacheKey,
getCachedScan,
setCachedScan,
scanWithCache,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
scanClawdHubPage,
installSkillsFromClawdHub,
isClawdHubSource,
fetchGitHubRepoMetas,
getProfiles,
getProfile,
} = dependencies;
@@ -305,9 +302,26 @@ export const registerSkillRoutes = (app, dependencies) => {
}));
const sources = [...curatedSources, ...customSources];
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest);
res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} });
const githubRepos = sources
.map((src) => parseSkillRepoSource(src.source))
.filter((parsed) => parsed.ok && parsed.host === 'github.com')
.map((parsed) => parsed.normalizedRepo);
const repoMetas = await fetchGitHubRepoMetas(githubRepos);
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => {
const parsed = parseSkillRepoSource(rest.source);
const meta = parsed.ok && parsed.host === 'github.com'
? repoMetas[parsed.normalizedRepo] || {}
: {};
return {
...rest,
stars: typeof meta.stars === 'number' ? meta.stars : null,
repoUpdatedAt: typeof meta.repoUpdatedAt === 'string' ? meta.repoUpdatedAt : null,
};
});
res.json({ ok: true, sources: sourcesForUi, itemsBySource: {} });
} catch (error) {
console.error('Failed to load skills catalog:', error);
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } });
@@ -327,7 +341,6 @@ export const registerSkillRoutes = (app, dependencies) => {
}
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null;
const curatedSources = getCuratedSkillsSources();
const settings = await readSettingsFromDisk();
@@ -355,26 +368,6 @@ export const registerSkillRoutes = (app, dependencies) => {
);
const installedByName = new Map(resolvedDiscovered.map((s) => [s.name, s]));
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
const scanned = await scanClawdHubPage({ cursor: cursor || null });
if (!scanned.ok) {
return res.status(500).json({ ok: false, error: scanned.error });
}
const items = (scanned.items || []).map((item) => {
const installed = installedByName.get(item.skillName);
return {
...item,
sourceId: src.id,
installed: installed
? { isInstalled: true, scope: installed.scope, source: installed.source }
: { isInstalled: false },
};
});
return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null });
}
const parsed = parseSkillRepoSource(src.source);
if (!parsed.ok) {
return res.status(400).json({ ok: false, error: parsed.error });
@@ -387,21 +380,19 @@ export const registerSkillRoutes = (app, dependencies) => {
identityId: src.gitIdentityId || '',
});
let scanResult = !refresh ? getCachedScan(cacheKey) : null;
if (!scanResult) {
const scanned = await scanSkillsRepository({
const scanResult = await scanWithCache(
cacheKey,
() => scanSkillsRepository({
source: src.source,
subpath: src.defaultSubpath,
defaultSubpath: src.defaultSubpath,
identity: resolveGitIdentity(src.gitIdentityId),
});
}),
{ refresh },
);
if (!scanned.ok) {
return res.status(500).json({ ok: false, error: scanned.error });
}
scanResult = scanned;
setCachedScan(cacheKey, scanResult);
if (!scanResult.ok) {
return res.status(500).json({ ok: false, error: scanResult.error });
}
const items = (scanResult.items || []).map((item) => {
@@ -483,41 +474,6 @@ export const registerSkillRoutes = (app, dependencies) => {
workingDirectory = resolved.directory;
}
if (isClawdHubSource(source)) {
const result = await installSkillsFromClawdHub({
scope,
targetSource,
workingDirectory,
userSkillDir: SKILL_DIR,
selections,
conflictPolicy,
conflictDecisions,
});
if (!result.ok) {
if (result.error?.kind === 'conflicts') {
return res.status(409).json({ ok: false, error: result.error });
}
return res.status(400).json({ ok: false, error: result.error });
}
const installed = result.installed || [];
const skipped = result.skipped || [];
const requiresRestart = installed.length > 0;
return res.json({
ok: true,
installed,
skipped,
...(requiresRestart
? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.')
: {
requiresReload: false,
message: 'No skills were installed',
}),
});
}
const identity = resolveGitIdentity(gitIdentityId);
const result = await installSkillsFromRepository({
@@ -69,14 +69,11 @@ const startSkillsApp = ({ projectRoot }) => {
SKILL_DIR,
getCuratedSkillsSources: () => [],
getCacheKey: () => 'k',
getCachedScan: () => null,
setCachedScan: () => {},
scanWithCache: async (_key, loader) => loader(),
parseSkillRepoSource: () => ({ ok: false }),
scanSkillsRepository: async () => ({ ok: false }),
installSkillsFromRepository: async () => ({ ok: false }),
scanClawdHubPage: async () => ({ ok: false }),
installSkillsFromClawdHub: async () => ({ ok: false }),
isClawdHubSource: () => false,
fetchGitHubRepoMetas: async () => ({}),
getProfiles: () => [],
getProfile: () => null,
});
@@ -27,11 +27,10 @@ Nothing outside this module may write `context.json` or the `plans` directory.
"notes": [{
"id": "", "body": "", "createdAt": 0, "updatedAt": 0,
"source": "manual | selection | agent",
"pinned": false,
"origin": { "sessionId": "", "messageId": "" }
}],
"todos": [{ "id": "", "text": "", "completed": false, "createdAt": 0 }],
"plans": [{ "id": "", "file": "1700000000-title.md", "title": "", "createdAt": 0, "pinned": false }]
"plans": [{ "id": "", "file": "1700000000-title.md", "title": "", "createdAt": 0 }]
}
```
@@ -39,6 +38,8 @@ Notes are entries, not one blob. Version 1 stored a single string; it converts
to a single `manual` note on read (an empty string converts to no notes at
all). The conversion lives in the read path rather than a separate migration
pass so that every reader — including one racing a writer — sees one shape.
Legacy `pinned` fields may remain in existing files but are ignored; attachment
ownership lives in each session's metadata.
`source` records where a note came from, and `origin` links it back to the
message it was distilled from, so a note taken off a chat selection can be
@@ -62,9 +63,9 @@ the two ever disagree.
| GET | `/api/project-context/:projectId` | full context; missing file is `200` empty |
| PUT | `/api/project-context/:projectId/todos` | replaces the whole list; returns committed context |
| POST | `/api/project-context/:projectId/notes` | `201`; takes `{body, source?, origin?}` |
| PATCH | `/api/project-context/:projectId/notes/:noteId` | patches `body` and/or `pinned`; `404` when unknown |
| PATCH | `/api/project-context/:projectId/notes/:noteId` | patches `body`; legacy `pinned` input is ignored by session knowledge; `404` when unknown |
| DELETE | `/api/project-context/:projectId/notes/:noteId` | `404` when unknown |
| PATCH | `/api/project-context/:projectId/plans/:planId` | pin state only; `404` when unknown |
| PATCH | `/api/project-context/:projectId/plans/:planId` | legacy project pin state only; session attachment uses session knowledge; `404` when unknown |
| GET | `/api/project-context/:projectId/plans/:planId` | `404` when the link or its markdown is gone |
| POST | `/api/project-context/:projectId/plans` | `201`; takes `{title, body}`, never a path |
| PUT | `/api/project-context/:projectId/plans/:planId` | takes the whole `{raw}` document; `404` when the link or its markdown is gone |
@@ -1,6 +1,6 @@
# Session Knowledge
What a session must be told about the project — the user's pinned notes and
What a session must be told about the project — that session's pinned notes and
plans, and the index of what the agent has remembered — and whether it has been
told yet.
@@ -19,6 +19,11 @@ on believing it does and never sends it again.
## The contract
`session.metadata.openchamber.project_context_pins` owns the note and plan ids
attached to that session. Pins never come from project-wide note or plan state.
A new-session draft passes its pins into this metadata when its first message
creates the session.
`session.metadata.openchamber.knowledge_context_delivered` holds the signature
of what the session is carrying. It lives with the session, so it survives the
tab closing and is visible to every sender, including the ones with no tab.
@@ -78,5 +83,5 @@ no session index, no settings row and no panel tab — absent rather than switch
off, which would invite turning on something never announced. The setting itself
also defaults to off, so setting the variable does not enable memory by itself.
Pinned notes and plans are unaffected: they ship as normal and travel with every
message whether or not memory exists.
Pinned notes and plans are unaffected by the memory switch and remain scoped to
the session that pinned them.
@@ -55,14 +55,34 @@ export const registerSessionKnowledgeRoutes = (app, dependencies) => {
if (!directory) {
return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } });
}
const sessionId = asNonEmptyString(req.query.sessionId);
try {
return res.json(await sessionKnowledgeRuntime.collectSummary(directory));
return res.json(sessionId
? await sessionKnowledgeRuntime.collectSummaryForSession(sessionId, directory)
: await sessionKnowledgeRuntime.collectSummary(directory));
} catch {
// A panel that cannot read this shows nothing rather than an error.
return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } });
}
});
app.post('/api/session-knowledge/pin', parseJsonBody, async (req, res) => {
const body = req.body;
if (!isRecord(body)) return res.status(400).json({ error: 'Body must be an object' });
const sessionId = asNonEmptyString(body.sessionId);
const directory = asNonEmptyString(body.directory);
const id = asNonEmptyString(body.id);
const kind = body.kind === 'note' || body.kind === 'plan' ? body.kind : '';
if (!sessionId || !directory || !id || !kind || typeof body.pinned !== 'boolean') {
return res.status(400).json({ error: 'sessionId, directory, kind, id and pinned are required' });
}
try {
return res.json({ pins: await sessionKnowledgeRuntime.setPin(sessionId, directory, kind, id, body.pinned) });
} catch (error) {
return res.status(500).json({ error: error?.message ?? 'Unable to update pin' });
}
});
app.post('/api/session-knowledge/delivered', parseJsonBody, async (req, res) => {
const body = req.body;
if (!isRecord(body)) {
@@ -16,6 +16,7 @@
*/
const KNOWLEDGE_METADATA_KEY = 'knowledge_context_delivered';
const PINS_METADATA_KEY = 'project_context_pins';
/** Total budget for the assembled block; anything past it is cut, loudly. */
const KNOWLEDGE_MAX_LENGTH = 8000;
@@ -119,7 +120,17 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
* source never blanks the rest: a memory store that will not load must not
* take the user's pinned notes down with it.
*/
const collect = async (directory) => {
const readPins = (session) => {
const metadata = isRecord(session?.metadata) ? session.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const pins = isRecord(openchamber[PINS_METADATA_KEY]) ? openchamber[PINS_METADATA_KEY] : {};
const strings = (value) => Array.isArray(value)
? [...new Set(value.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim()))]
: [];
return { notes: strings(pins.notes), plans: strings(pins.plans) };
};
const collect = async (directory, pins = { notes: [], plans: [] }) => {
const projectId = directory ? await resolveProjectId(directory) : '';
let notes = [];
@@ -127,8 +138,10 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
if (projectId) {
try {
const context = await projectContextRuntime.readContext(projectId);
notes = (context.notes || []).filter((note) => note.pinned);
const pinnedPlans = (context.plans || []).filter((plan) => plan.pinned);
const noteIds = new Set(pins.notes);
const planIds = new Set(pins.plans);
notes = (context.notes || []).filter((note) => noteIds.has(note.id));
const pinnedPlans = (context.plans || []).filter((plan) => planIds.has(plan.id));
plans = await Promise.all(pinnedPlans.map(async (plan) => {
try {
const content = await projectContextRuntime.readPlan(projectId, plan.id);
@@ -175,7 +188,7 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
* off disk to show a number would make opening a panel cost what sending a
* message costs.
*/
const collectSummary = async (directory) => {
const collectSummary = async (directory, pins = { notes: [], plans: [] }) => {
const projectId = directory ? await resolveProjectId(directory) : '';
const empty = { notes: [], plans: [], memory: { global: 0, project: 0 } };
if (!projectId) return empty;
@@ -184,9 +197,11 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
let plans = [];
try {
const context = await projectContextRuntime.readContext(projectId);
notes = (context.notes || []).filter((note) => note.pinned)
const noteIds = new Set(pins.notes);
const planIds = new Set(pins.plans);
notes = (context.notes || []).filter((note) => noteIds.has(note.id))
.map((note) => ({ id: note.id, body: note.body }));
plans = (context.plans || []).filter((plan) => plan.pinned)
plans = (context.plans || []).filter((plan) => planIds.has(plan.id))
.map((plan) => ({ id: plan.id, title: plan.title }));
} catch {
notes = [];
@@ -223,8 +238,8 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
* The text this session still owes, or an empty string when it is already
* carrying it. `deliveredSignature` comes from the session's metadata.
*/
const resolvePending = async (directory, deliveredSignature) => {
const collected = await collect(directory);
const resolvePending = async (directory, deliveredSignature, pins = { notes: [], plans: [] }) => {
const collected = await collect(directory, pins);
const signature = buildKnowledgeSignature(collected);
if (!signature || signature === deliveredSignature) {
return { text: '', signature };
@@ -241,7 +256,38 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
*/
const resolvePendingForSession = async (sessionId, directory) => {
const session = await readSession(sessionId, directory).catch(() => null);
return resolvePending(directory, readDeliveredSignature(session));
return resolvePending(directory, readDeliveredSignature(session), readPins(session));
};
const collectSummaryForSession = async (sessionId, directory) => {
const session = await readSession(sessionId, directory).catch(() => null);
return collectSummary(directory, readPins(session));
};
const setPin = async (sessionId, directory, kind, id, pinned) => {
const fresh = await readSession(sessionId, directory);
const metadata = isRecord(fresh?.metadata) ? fresh.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const pins = readPins(fresh);
const key = kind === 'note' ? 'notes' : 'plans';
const next = new Set(pins[key]);
if (pinned) next.add(id);
else next.delete(id);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...metadata,
openchamber: {
...openchamber,
[PINS_METADATA_KEY]: { ...pins, [key]: [...next] },
[KNOWLEDGE_METADATA_KEY]: '',
},
},
},
});
return { ...pins, [key]: [...next] };
};
/**
@@ -272,10 +318,14 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
return {
collect,
collectSummary,
collectSummaryForSession,
resolvePending,
resolvePendingForSession,
recordDelivered,
readDeliveredSignature,
readPins,
setPin,
metadataKey: KNOWLEDGE_METADATA_KEY,
pinsMetadataKey: PINS_METADATA_KEY,
};
};
@@ -4,6 +4,7 @@ import { buildKnowledgeSignature, buildKnowledgeText, createSessionKnowledgeRunt
const DIRECTORY = '/work/project';
const PROJECT_ID = 'path_project';
const PINS = { notes: ['n1'], plans: ['p1'] };
const note = (overrides = {}) => ({
id: 'n1', body: 'Pinned note body.', createdAt: 1, updatedAt: 1, pinned: true, source: 'manual', ...overrides,
@@ -26,12 +27,13 @@ const createRuntime = (overrides = {}) => createSessionKnowledgeRuntime({
readAll: async () => ({ global: [memory()], project: [], globalFailed: false, projectFailed: false }),
...overrides.agentMemoryRuntime,
},
...('openCodeFetch' in overrides ? { openCodeFetch: overrides.openCodeFetch } : {}),
...('isAgentMemoryEnabled' in overrides ? { isAgentMemoryEnabled: overrides.isAgentMemoryEnabled } : {}),
});
describe('what the session is owed', () => {
test('carries pinned notes, pinned plan bodies, and the memory index', async () => {
const { text } = await createRuntime().resolvePending(DIRECTORY, '');
const { text } = await createRuntime().resolvePending(DIRECTORY, '', PINS);
expect(text).toContain('Pinned note body.');
expect(text).toContain('Migration plan');
@@ -52,7 +54,7 @@ describe('what the session is owed', () => {
},
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', { notes: [], plans: [] });
expect(text).not.toContain('Pinned note body.');
});
@@ -123,7 +125,7 @@ describe('when a source will not load', () => {
agentMemoryRuntime: { readAll: async () => { throw new Error('unreadable'); } },
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).toContain('Pinned note body.');
});
@@ -135,7 +137,7 @@ describe('when a source will not load', () => {
},
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).not.toContain('Uses bun');
});
@@ -148,7 +150,7 @@ describe('when a source will not load', () => {
},
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).toContain('Migration plan');
expect(text).toContain('plan content unavailable');
@@ -159,7 +161,7 @@ describe('when a source will not load', () => {
projectContextRuntime: { readContext: async () => { throw new Error('unreadable'); } },
});
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).toContain('Uses bun');
});
@@ -169,7 +171,7 @@ describe('the memory switch', () => {
test('memory is left out entirely while the feature is off', async () => {
const runtime = createRuntime({ isAgentMemoryEnabled: async () => false });
const { text } = await runtime.resolvePending(DIRECTORY, '');
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
expect(text).not.toContain('Uses bun');
expect(text).toContain('Pinned note body.');
@@ -187,6 +189,39 @@ describe('the memory switch', () => {
});
describe('reading what a session was told', () => {
test('project context pins are isolated in each session metadata record', () => {
const runtime = createRuntime();
expect(runtime.readPins({
metadata: { openchamber: { project_context_pins: { notes: ['n1'], plans: [] } } },
})).toEqual({ notes: ['n1'], plans: [] });
expect(runtime.readPins({
metadata: { openchamber: { project_context_pins: { notes: [], plans: ['p1'] } } },
})).toEqual({ notes: [], plans: ['p1'] });
expect(runtime.readPins({})).toEqual({ notes: [], plans: [] });
});
test('pinning updates only the target session and invalidates its delivered signature', async () => {
const requests = [];
const runtime = createRuntime({
openCodeFetch: async (path, options = {}) => {
requests.push({ path, options });
if (options.method === 'PATCH') return {};
return {
metadata: { openchamber: { project_context_pins: { notes: [], plans: [] }, knowledge_context_delivered: 'old' } },
};
},
});
await runtime.setPin('ses_a', DIRECTORY, 'note', 'n1', true);
expect(requests.map((request) => request.path)).toEqual(['/session/ses_a', '/session/ses_a']);
expect(requests[1].options.body.metadata.openchamber).toEqual({
project_context_pins: { notes: ['n1'], plans: [] },
knowledge_context_delivered: '',
});
});
test('finds the signature stored on the session', () => {
const runtime = createRuntime();
@@ -1,21 +1,17 @@
# Skills Catalog Module Documentation
## Purpose
This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports multiple skill sources including git repositories and the ClawHub registry, with caching and conflict resolution for skill installation.
This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports skill sources backed by git repositories, with caching and conflict resolution for skill installation.
## Entrypoints and structure
- `packages/web/server/lib/skills-catalog/`: Skills catalog module directory containing all skill-related functionality.
- `cache.js`: In-memory cache for scan results with TTL support.
- `curated-sources.js`: Predefined skill sources (Anthropic, ClawHub).
- `curated-sources.js`: Predefined skill sources (Anthropic, OpenAI, Cursor, Matt Pocock).
- `github-meta.js`: Best-effort GitHub repository metadata (stars, last push) with in-memory TTL cache.
- `git.js`: Git operations helpers for cloning and auth error detection.
- `install.js`: Skills installation from git repositories.
- `scan.js`: Skills scanning from git repositories.
- `source.js`: Source string parsing for git repositories.
- `clawdhub/`: ClawHub registry integration.
- `index.js`: Public API exports for ClawHub.
- `scan.js`: Scanning ClawHub registry with pagination.
- `install.js`: Installation from ClawHub (ZIP download).
- `api.js`: ClawHub API client with rate limiting.
## Public API
@@ -24,13 +20,19 @@ The following functions are exported and used by the web server:
### Cache (`cache.js`)
- `getCacheKey({ normalizedRepo, subpath, identityId })`: Generate cache key for scan results.
- `getCachedScan(key)`: Retrieve cached scan result if not expired.
- `setCachedScan(key, value, ttlMs)`: Store scan result with TTL (default 30 minutes).
- `setCachedScan(key, value, ttlMs)`: Store scan result with TTL (default 3 hours).
- `scanWithCache(key, loader, { refresh })`: Run a scan loader with cache lookup, in-flight deduplication, and a global concurrency limit (2 concurrent scans); only `ok: true` results are cached.
- `clearCache()`: Clear all cached scan results.
- Scan results persist to `skills-catalog-cache.json` in the OpenChamber data dir (debounced, atomic rename) and survive server restarts within the TTL.
### Curated Sources (`curated-sources.js`)
- `getCuratedSkillsSources()`: Return list of curated skill sources (Anthropic, ClawHub).
- `getCuratedSkillsSources()`: Return list of curated skill sources (Anthropic, OpenAI, Cursor, Matt Pocock).
- `CURATED_SKILLS_SOURCES`: Constant array of predefined sources.
### GitHub Repository Metadata (`github-meta.js`)
- `fetchGitHubRepoMetas(normalizedRepos)`: Fetch `{ stars, repoUpdatedAt }` for GitHub `owner/repo` strings. Best-effort: failures resolve to `null`; in-flight requests deduplicate; results cached in memory and on disk (`skills-github-meta.json`) for three hours.
- `clearGitHubMetaCache()`: Test-only cache reset.
### Source Parsing (`source.js`)
- `parseSkillRepoSource(source, { subpath })`: Parse git repository source string into structured object with SSH/HTTPS clone URLs, normalized repo, and effective subpath. Supports SSH URLs, HTTPS URLs, and shorthand `owner/repo[/subpath]` format.
@@ -40,20 +42,6 @@ The following functions are exported and used by the web server:
### Git Repository Installation (`install.js`)
- `installSkillsFromRepository({ source, subpath, defaultSubpath, identity, scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from git repository. Supports user/project scopes, opencode/agents targets, conflict resolution (prompt/skipAll/overwriteAll), and sparse checkout for efficiency.
### ClawHub Integration (`clawdhub/index.js`)
- `isClawdHubSource(source)`: Check if source string refers to ClawHub.
- `scanClawdHub()`: Scan entire ClawHub registry for all skills (paginated, max 20 pages).
- `scanClawdHubPage({ cursor })`: Scan a single page of ClawHub results with cursor-based pagination.
- `installSkillsFromClawdHub({ scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from ClawHub by downloading ZIP files.
- `fetchClawdHubSkills({ cursor })`: Fetch paginated skills list from ClawHub API.
- `fetchClawdHubSkillVersion(slug, version)`: Fetch specific skill version details.
- `fetchClawdHubSkillInfo(slug)`: Fetch skill metadata without version details.
- `downloadClawdHubSkill(slug, version)`: Download skill package as ZIP buffer.
### ClawHub Constants (`clawdhub/index.js`)
- `CLAWDHUB_SOURCE_ID`: Source identifier for curated sources.
- `CLAWDHUB_SOURCE_STRING`: Source string format.
## Internal Helpers
The following functions are internal helpers used by exported functions:
@@ -63,10 +51,10 @@ The following functions are internal helpers used by exported functions:
- `looksLikeAuthError(message)`: Detect if error message indicates authentication failure (permission denied, publickey, etc.).
- `assertGitAvailable()`: Check if git is available in PATH.
### Skill Name Validation (used in `install.js`, `scan.js`, `clawdhub/install.js`)
### Skill Name Validation (used in `install.js`, `scan.js`)
- `validateSkillName(skillName)`: Validate skill name against pattern `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars, lowercase alphanumeric with hyphens).
### File System Helpers (`install.js`, `scan.js`, `clawdhub/install.js`)
### File System Helpers (`install.js`, `scan.js`)
- `safeRm(dir)`: Safely remove directory recursively (ignores errors).
- `ensureDir(dirPath)`: Ensure directory exists with recursive creation.
- `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection.
@@ -82,10 +70,6 @@ The following functions are internal helpers used by exported functions:
- `toFsPath(repoDir, repoRelPosixPath)`: Convert POSIX path to filesystem path.
- `getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName })`: Determine target installation directory based on scope (user/project), targetSource (opencode/agents), and skill name.
### ClawHub API Helpers (`clawdhub/api.js`)
- `rateLimitedFetch(url, options)`: Fetch with rate limiting (120 req/min limit, 100ms delay between requests, exponential backoff on 429/500 errors).
- `mapClawdHubItem(item)`: Transform ClawHub API response to SkillsCatalogItem format.
## Response Contracts
### Scan Skills Repository Response
@@ -101,12 +85,6 @@ The following functions are internal helpers used by exported functions:
- `skipped`: Array of skipped skills with `{ skillName, reason }`.
- `error`: Error object with `{ kind, message, conflicts? }` on failure. Kinds: `authRequired`, `networkError`, `conflicts`, `invalidSource`, `unknown`.
### ClawHub Scan Response
- `ok`: Boolean indicating success.
- `items`: Array of skill items with ClawHub-specific metadata in `clawdhub` property.
- `nextCursor`: Pagination cursor for next page (only for `scanClawdHubPage`).
- `error`: Error object with `{ kind, message }` on failure.
### Parse Source Response
- `ok`: Boolean indicating success.
- `host`: Git host (e.g., `github.com`, `gitlab.com`).
@@ -129,7 +107,7 @@ The following functions are internal helpers used by exported functions:
### Skill Name Validation
- All skill names must match `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars).
- Skill names are derived from directory basenames for git repos and slugs for ClawHub.
- Skill names are derived from directory basenames for git repos.
- Invalid names result in non-installable skills with appropriate warnings.
### Git Cloning Strategy
@@ -144,17 +122,12 @@ The following functions are internal helpers used by exported functions:
- Per-skill decisions override global policy via `conflictDecisions` map.
- Conflict response includes `{ skillName, scope, source }` for each conflict.
### ClawHub Integration
- ClawHub API base URL: `https://clawdhub.com/api/v1`.
- Pagination uses cursor-based approach with `MAX_PAGES=20` safety limit.
- Rate limiting: 120 req/min with 100ms delay between requests.
- Downloaded skills are extracted from ZIP files using `adm-zip`.
- Always validate `SKILL.md` exists before installation.
### Cache Management
- Cache keys include `normalizedRepo`, `subpath`, and `identityId` for isolation.
- Default TTL is 30 minutes; can be overridden via `ttlMs` parameter.
- Cache is in-memory (not persisted across restarts).
- Default TTL is 3 hours for both scan results and GitHub repository metadata.
- Scan and GitHub metadata caches persist to JSON files in the OpenChamber data dir, so app restarts and page refreshes reuse previous results instead of re-hitting GitHub.
- Scans run through a global concurrency limiter (2 at a time) with per-key in-flight deduplication.
- The refresh button passes `refresh: true` and bypasses the cache.
### Security Considerations
- Path traversal protection in `copyDirectoryNoSymlinks`: resolves real paths and checks containment.
+120 -1
View File
@@ -1,6 +1,58 @@
const DEFAULT_TTL_MS = 30 * 60 * 1000;
import { readDiskCache, writeDiskCache } from './disk-cache.js';
const DEFAULT_TTL_MS = 3 * 60 * 60 * 1000;
const DISK_CACHE_FILE = 'skills-catalog-cache.json';
const MAX_CONCURRENT_SCANS = 2;
const cache = new Map();
const inFlight = new Map();
let diskLoaded = false;
let diskWriteTimer = null;
const loadDiskEntries = () => {
if (diskLoaded) {
return;
}
diskLoaded = true;
const persisted = readDiskCache(DISK_CACHE_FILE);
if (!persisted) {
return;
}
const now = Date.now();
for (const [key, entry] of Object.entries(persisted)) {
if (
entry
&& typeof entry === 'object'
&& typeof entry.expiresAt === 'number'
&& entry.expiresAt > now
&& entry.value
&& typeof entry.value === 'object'
) {
cache.set(key, entry);
}
}
};
const scheduleDiskWrite = () => {
if (diskWriteTimer) {
return;
}
diskWriteTimer = setTimeout(() => {
diskWriteTimer = null;
const now = Date.now();
const persisted = {};
for (const [key, entry] of cache.entries()) {
if (entry.expiresAt > now) {
persisted[key] = entry;
}
}
writeDiskCache(DISK_CACHE_FILE, persisted);
}, 1000);
if (typeof diskWriteTimer.unref === 'function') {
diskWriteTimer.unref();
}
};
export function getCacheKey({ normalizedRepo, subpath, identityId }) {
const safeRepo = String(normalizedRepo || '').trim();
@@ -10,6 +62,7 @@ export function getCacheKey({ normalizedRepo, subpath, identityId }) {
}
export function getCachedScan(key) {
loadDiskEntries();
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() >= entry.expiresAt) {
@@ -22,4 +75,70 @@ export function getCachedScan(key) {
export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) {
const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS;
cache.set(key, { expiresAt: Date.now() + ttl, value });
scheduleDiskWrite();
}
export function clearCache() {
cache.clear();
inFlight.clear();
}
// ─── Concurrency-limited scan orchestration ───
let activeScans = 0;
const scanQueue = [];
const acquireScanSlot = () => new Promise((resolve) => {
scanQueue.push(resolve);
pumpScanQueue();
});
const releaseScanSlot = () => {
activeScans -= 1;
pumpScanQueue();
};
const pumpScanQueue = () => {
while (activeScans < MAX_CONCURRENT_SCANS && scanQueue.length > 0) {
const resolve = scanQueue.shift();
activeScans += 1;
resolve();
}
};
/**
* Run `loader` for a scan cache key with deduplication and a global
* concurrency limit. Concurrent callers for the same key share one loader
* run; at most MAX_CONCURRENT_SCANS loaders run at once. Only successful
* (`ok: true`) results are cached.
*/
export async function scanWithCache(key, loader, { refresh = false } = {}) {
if (!refresh) {
const cached = getCachedScan(key);
if (cached) {
return cached;
}
}
const existing = inFlight.get(key);
if (existing) {
return existing;
}
const run = (async () => {
await acquireScanSlot();
try {
const result = await loader();
if (result && result.ok) {
setCachedScan(key, result);
}
return result;
} finally {
releaseScanSlot();
inFlight.delete(key);
}
})();
inFlight.set(key, run);
return run;
}
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { clearCache, scanWithCache, setCachedScan, getCachedScan } from './cache.js';
let tempDataDir;
beforeEach(() => {
tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cache-test-'));
process.env.OPENCHAMBER_DATA_DIR = tempDataDir;
});
afterEach(() => {
delete process.env.OPENCHAMBER_DATA_DIR;
clearCache();
vi.restoreAllMocks();
fs.rmSync(tempDataDir, { recursive: true, force: true });
});
const flushDiskWrites = async () => new Promise((resolve) => setTimeout(resolve, 1200));
describe('scanWithCache', () => {
it('deduplicates concurrent loaders for the same key', async () => {
const loader = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
return { ok: true, items: [] };
});
const [a, b] = await Promise.all([
scanWithCache('k', loader),
scanWithCache('k', loader),
]);
expect(loader).toHaveBeenCalledTimes(1);
expect(a).toEqual(b);
});
it('limits concurrent scans across different keys', async () => {
let running = 0;
let peak = 0;
const loader = async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise((resolve) => setTimeout(resolve, 20));
running -= 1;
return { ok: true, items: [] };
};
await Promise.all(Array.from({ length: 6 }, (_, i) => scanWithCache(`key-${i}`, loader)));
expect(peak).toBeLessThanOrEqual(2);
});
it('does not cache failed scans', async () => {
await scanWithCache('bad', async () => ({ ok: false, error: { kind: 'networkError', message: 'x' } }));
expect(getCachedScan('bad')).toBeNull();
});
it('refresh bypasses the cache', async () => {
setCachedScan('fresh', { ok: true, items: ['cached'] });
const result = await scanWithCache('fresh', async () => ({ ok: true, items: ['reloaded'] }), { refresh: true });
expect(result.items).toEqual(['reloaded']);
expect(getCachedScan('fresh').items).toEqual(['reloaded']);
});
it('persists successful scans to disk for later processes', async () => {
await scanWithCache('persisted', async () => ({ ok: true, items: [{ skillName: 'x' }] }));
await flushDiskWrites();
const onDisk = JSON.parse(fs.readFileSync(path.join(tempDataDir, 'skills-catalog-cache.json'), 'utf8'));
expect(onDisk.persisted.value.items).toEqual([{ skillName: 'x' }]);
});
});
@@ -1,126 +0,0 @@
/**
* ClawdHub API client
*
* ClawdHub is a public skill registry at https://clawdhub.com
* This client provides methods to fetch skills list and download skill packages.
*/
const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1';
const CLAWDHUB_PAGE_LIMIT = 25;
// Rate limiting: ClawdHub allows 120 requests/minute
const RATE_LIMIT_DELAY_MS = 100;
let lastRequestTime = 0;
async function rateLimitedFetch(url, options = {}) {
const maxAttempts = 10;
let lastResponse = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const now = Date.now();
const elapsed = now - lastRequestTime;
if (elapsed < RATE_LIMIT_DELAY_MS) {
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY_MS - elapsed));
}
lastRequestTime = Date.now();
const response = await fetch(url, {
...options,
headers: {
Accept: 'application/json',
'User-Agent': 'OpenChamber/1.0',
...options.headers,
},
});
lastResponse = response;
if (response.status === 429 || response.status >= 500) {
if (attempt < maxAttempts - 1) {
const waitMs = 50 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
}
return response;
}
return lastResponse;
}
/**
* Fetch paginated list of skills from ClawdHub
* @param {Object} options
* @param {string} [options.cursor] - Pagination cursor from previous response
* @returns {Promise<{ items: Array, nextCursor?: string }>}
*/
export async function fetchClawdHubSkills({ cursor } = {}) {
const url = cursor
? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}`
: `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`;
const response = await rateLimitedFetch(url);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`ClawdHub API error (${response.status}): ${text || response.statusText}`);
}
const data = await response.json();
const nextCursor =
(typeof data.nextCursor === 'string' && data.nextCursor) ||
(typeof data.next_cursor === 'string' && data.next_cursor) ||
(typeof data.next === 'string' && data.next) ||
(typeof data.cursor === 'string' && data.cursor) ||
null;
return {
items: data.items || [],
nextCursor,
};
}
/**
* Download a skill package as a ZIP buffer
* @param {string} slug - Skill slug/identifier
* @param {string} version - Specific version string
* @returns {Promise<ArrayBuffer>} - ZIP file contents
*/
export async function downloadClawdHubSkill(slug, version) {
const versionParam = typeof version === 'string' && version !== 'latest'
? `&version=${encodeURIComponent(version)}`
: '&tag=latest';
const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}${versionParam}`;
const response = await rateLimitedFetch(url, {
headers: {
Accept: 'application/zip',
},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`ClawdHub download error (${response.status}): ${text || response.statusText}`);
}
return response.arrayBuffer();
}
/**
* Get skill metadata without version details
* @param {string} slug - Skill slug/identifier
* @returns {Promise<Object>}
*/
export async function fetchClawdHubSkillInfo(slug) {
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`;
const response = await rateLimitedFetch(url);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`ClawdHub skill error (${response.status}): ${text || response.statusText}`);
}
return response.json();
}
@@ -1,238 +0,0 @@
/**
* ClawdHub skill installation
*
* Downloads skills from ClawdHub as ZIP files and extracts them
* to the appropriate skill directory.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import AdmZip from 'adm-zip';
import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js';
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
function normalizeUserSkillDir(userSkillDir) {
if (!userSkillDir) return null;
const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
if (userSkillDir === legacySkillDir) {
if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
return pluralSkillDir;
}
return userSkillDir;
}
function validateSkillName(skillName) {
if (typeof skillName !== 'string') return false;
if (skillName.length < 1 || skillName.length > 64) return false;
return SKILL_NAME_PATTERN.test(skillName);
}
async function safeRm(dir) {
try {
await fs.promises.rm(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
async function ensureDir(dirPath) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
const source = targetSource === 'agents' ? 'agents' : 'opencode';
if (scope === 'user') {
if (source === 'agents') {
return path.join(os.homedir(), '.agents', 'skills', skillName);
}
return path.join(userSkillDir, skillName);
}
if (!workingDirectory) {
throw new Error('workingDirectory is required for project installs');
}
if (source === 'agents') {
return path.join(workingDirectory, '.agents', 'skills', skillName);
}
return path.join(workingDirectory, '.opencode', 'skills', skillName);
}
/**
* Install skills from ClawdHub registry
* @param {Object} options
* @param {string} options.scope - 'user' or 'project'
* @param {string} [options.targetSource] - 'opencode' or 'agents'
* @param {string} [options.workingDirectory] - Required for project scope
* @param {string} options.userSkillDir - User skills directory
* @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } }
* @param {string} [options.conflictPolicy] - 'prompt', 'skipAll', or 'overwriteAll'
* @param {Object} [options.conflictDecisions] - Per-skill conflict decisions
* @returns {Promise<{ ok: boolean, installed?: Array, skipped?: Array, error?: Object }>}
*/
export async function installSkillsFromClawdHub({
scope,
targetSource,
workingDirectory,
userSkillDir,
selections,
conflictPolicy,
conflictDecisions,
} = {}) {
if (scope !== 'user' && scope !== 'project') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
}
if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
}
if (!userSkillDir) {
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
}
const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir);
if (normalizedUserSkillDir) {
userSkillDir = normalizedUserSkillDir;
}
if (scope === 'project' && !workingDirectory) {
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
}
const requestedSkills = Array.isArray(selections) ? selections : [];
if (requestedSkills.length === 0) {
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
}
// Build installation plans
const skillPlans = requestedSkills.map((sel) => {
const slug = sel.clawdhub?.slug || sel.skillDir;
const version = sel.clawdhub?.version || 'latest';
return {
slug,
version,
installable: validateSkillName(slug),
};
});
// Check for conflicts before downloading
const conflicts = [];
for (const plan of skillPlans) {
if (!plan.installable) {
continue;
}
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug });
if (fs.existsSync(targetDir)) {
const decision = conflictDecisions?.[plan.slug];
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
if (!decision && !hasAutoPolicy) {
conflicts.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
}
}
}
if (conflicts.length > 0) {
return {
ok: false,
error: {
kind: 'conflicts',
message: 'Some skills already exist in the selected scope',
conflicts,
},
};
}
const installed = [];
const skipped = [];
for (const plan of skillPlans) {
if (!plan.installable) {
skipped.push({ skillName: plan.slug, reason: 'Invalid skill name' });
continue;
}
try {
// Resolve 'latest' version if needed
let resolvedVersion = plan.version;
if (resolvedVersion === 'latest') {
try {
const info = await fetchClawdHubSkillInfo(plan.slug);
const latest = info.skill?.tags?.latest || info.latestVersion?.version || null;
if (latest) {
resolvedVersion = latest;
}
} catch {
// ignore
}
if (resolvedVersion === 'latest') {
skipped.push({ skillName: plan.slug, reason: 'Unable to resolve latest version' });
continue;
}
}
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug });
const exists = fs.existsSync(targetDir);
// Determine conflict resolution
let decision = conflictDecisions?.[plan.slug] || null;
if (!decision) {
if (exists && conflictPolicy === 'skipAll') decision = 'skip';
if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
if (!exists) decision = 'overwrite'; // No conflict, proceed
}
if (exists && decision === 'skip') {
skipped.push({ skillName: plan.slug, reason: 'Already installed (skipped)' });
continue;
}
if (exists && decision === 'overwrite') {
await safeRm(targetDir);
}
// Download the skill ZIP
const zipBuffer = await downloadClawdHubSkill(plan.slug, resolvedVersion);
// Extract to a temp directory first for validation
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${plan.slug}-`));
try {
const zip = new AdmZip(Buffer.from(zipBuffer));
zip.extractAllTo(tempDir, true);
// Verify SKILL.md exists
const skillMdPath = path.join(tempDir, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
skipped.push({ skillName: plan.slug, reason: 'SKILL.md not found in downloaded package' });
continue;
}
// Move to target directory
await ensureDir(path.dirname(targetDir));
await fs.promises.rename(tempDir, targetDir);
installed.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
} catch (extractError) {
await safeRm(tempDir);
throw extractError;
}
} catch (error) {
console.error(`Failed to install ClawdHub skill "${plan.slug}":`, error);
skipped.push({
skillName: plan.slug,
reason: error instanceof Error ? error.message : 'Failed to download or extract skill',
});
}
}
return { ok: true, installed, skipped };
}
@@ -1,100 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import AdmZip from 'adm-zip';
// Mock the ClawdHub network client so no real HTTP happens. The download
// function is what feeds the ZIP buffer into adm-zip inside install.js.
vi.mock('./api.js', () => ({
downloadClawdHubSkill: vi.fn(),
fetchClawdHubSkillInfo: vi.fn(),
}));
const { downloadClawdHubSkill } = await import('./api.js');
const { installSkillsFromClawdHub } = await import('./install.js');
/**
* Build a real ZIP archive with adm-zip (the dependency under test).
* Returns the raw Buffer, mirroring what downloadClawdHubSkill resolves to.
*/
function buildSkillZip(entries) {
const zip = new AdmZip();
for (const [entryName, content] of Object.entries(entries)) {
zip.addFile(entryName, Buffer.from(content, 'utf8'));
}
return zip.toBuffer();
}
describe('installSkillsFromClawdHub (adm-zip extraction path)', () => {
let userSkillDir;
beforeEach(async () => {
// Keep the target dir under os.tmpdir() so the temp->target rename in
// install.js stays on one filesystem (avoids EXDEV cross-device errors).
userSkillDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'clawdhub-test-skills-'));
vi.clearAllMocks();
});
afterEach(async () => {
await fs.promises.rm(userSkillDir, { recursive: true, force: true }).catch(() => {});
});
it('extracts a real ZIP (incl. nested subdirectories) into the target skill dir', async () => {
const skillMd = 'name: demo-skill\ndescription: adm-zip extraction regression guard\n';
const nested = 'nested file content for subdirectory extraction check\n';
downloadClawdHubSkill.mockResolvedValue(
buildSkillZip({ 'SKILL.md': skillMd, 'nested/data.txt': nested }),
);
const result = await installSkillsFromClawdHub({
scope: 'user',
targetSource: 'opencode',
userSkillDir,
// Non-'latest' version avoids the fetchClawdHubSkillInfo resolve branch.
selections: [{ clawdhub: { slug: 'demo-skill', version: '1.0.0' } }],
});
expect(result.ok).toBe(true);
expect(result.installed).toEqual([
{ skillName: 'demo-skill', scope: 'user', source: 'opencode' },
]);
expect(result.skipped).toEqual([]);
// downloadClawdHubSkill received the resolved (non-latest) version.
expect(downloadClawdHubSkill).toHaveBeenCalledWith('demo-skill', '1.0.0');
// adm-zip actually wrote the files, preserving the nested subdirectory.
const targetDir = path.join(userSkillDir, 'demo-skill');
const skillMdPath = path.join(targetDir, 'SKILL.md');
const nestedPath = path.join(targetDir, 'nested', 'data.txt');
expect(fs.existsSync(skillMdPath)).toBe(true);
expect(fs.existsSync(nestedPath)).toBe(true);
expect(fs.readFileSync(skillMdPath, 'utf8')).toBe(skillMd);
expect(fs.readFileSync(nestedPath, 'utf8')).toBe(nested);
});
it('skips a package whose extracted contents lack SKILL.md', async () => {
// Valid ZIP, but no SKILL.md at the root -> install.js must skip it and
// must NOT create the target dir. This exercises the extractAllTo path
// followed by the post-extraction validation.
downloadClawdHubSkill.mockResolvedValue(
buildSkillZip({ 'README.md': 'no skill manifest here\n' }),
);
const result = await installSkillsFromClawdHub({
scope: 'user',
targetSource: 'opencode',
userSkillDir,
selections: [{ clawdhub: { slug: 'broken-skill', version: '1.0.0' } }],
});
expect(result.ok).toBe(true);
expect(result.installed).toEqual([]);
expect(result.skipped).toEqual([
{ skillName: 'broken-skill', reason: 'SKILL.md not found in downloaded package' },
]);
expect(fs.existsSync(path.join(userSkillDir, 'broken-skill'))).toBe(false);
});
});
@@ -1,61 +0,0 @@
/**
* ClawdHub skill scanning
*
* Fetches all available skills from the ClawdHub registry
* and transforms them into SkillsCatalogItem format.
*/
import { fetchClawdHubSkills } from './api.js';
const CLAWDHUB_PAGE_LIMIT = 25;
const mapClawdHubItem = (item) => {
const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0';
return {
sourceId: 'clawdhub',
repoSource: 'clawdhub:registry',
repoSubpath: null,
gitIdentityId: null,
skillDir: item.slug,
skillName: item.slug,
frontmatterName: item.displayName || item.slug,
description: item.summary || null,
installable: true,
warnings: [],
// ClawdHub-specific metadata
clawdhub: {
slug: item.slug,
version: latestVersion,
displayName: item.displayName,
owner: item.owner?.handle || null,
downloads: item.stats?.downloads || 0,
stars: item.stats?.stars || 0,
versionsCount: item.stats?.versions || 1,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
},
};
};
/**
* Scan a single ClawdHub page (cursor-based)
* @returns {Promise<{ ok: boolean, items?: Array, nextCursor?: string | null, error?: Object }>}
*/
export async function scanClawdHubPage({ cursor } = {}) {
try {
const { items, nextCursor } = await fetchClawdHubSkills({ cursor });
const mapped = (items || []).map(mapClawdHubItem).slice(0, CLAWDHUB_PAGE_LIMIT);
mapped.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
return { ok: true, items: mapped, nextCursor: nextCursor || null };
} catch (error) {
console.error('ClawdHub page scan error:', error);
return {
ok: false,
error: {
kind: 'networkError',
message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
},
};
}
}
@@ -8,11 +8,27 @@ const CURATED_SKILLS_SOURCES = [
sourceType: 'github',
},
{
id: 'clawdhub',
label: 'ClawHub',
description: 'Community skill registry with vector search',
source: 'clawdhub:registry',
sourceType: 'clawdhub',
id: 'openai',
label: 'OpenAI',
description: "OpenAI's curated skills",
source: 'openai/skills',
defaultSubpath: 'skills/.curated',
sourceType: 'github',
},
{
id: 'cursor',
label: 'Cursor',
description: "Cursor's plugin skills",
source: 'cursor/plugins',
defaultSubpath: 'pstack/skills',
sourceType: 'github',
},
{
id: 'mattpocock',
label: 'Matt Pocock',
description: 'Matt Pocock skills collection',
source: 'mattpocock/skills',
sourceType: 'github',
},
];
@@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest';
import { getCuratedSkillsSources } from './curated-sources.js';
describe('getCuratedSkillsSources', () => {
it('labels the ClawHub curated source as ClawHub', () => {
const clawhub = getCuratedSkillsSources().find((source) => source.id === 'clawdhub');
expect(clawhub).toBeDefined();
expect(clawhub.label).toBe('ClawHub');
it('includes the Anthropic curated source', () => {
const anthropic = getCuratedSkillsSources().find((source) => source.id === 'anthropic');
expect(anthropic).toBeDefined();
expect(anthropic.label).toBe('Anthropic');
});
});
@@ -0,0 +1,52 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
const resolveDataDir = () => (process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber'));
const readJsonFile = (filePath) => {
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
};
/**
* Read a persisted cache object from the OpenChamber data directory.
* Returns null when the file is missing, unreadable, or malformed.
*/
export const readDiskCache = (fileName) => {
try {
return readJsonFile(path.join(resolveDataDir(), fileName));
} catch {
return null;
}
};
/**
* Persist a cache object to the OpenChamber data directory with an atomic
* temp-file rename. Failures are ignored: the in-memory cache stays
* authoritative and the next successful write retries persistence.
*/
export const writeDiskCache = (fileName, data) => {
const filePath = path.join(resolveDataDir(), fileName);
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(tempPath, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tempPath, filePath);
return true;
} catch {
try {
fs.unlinkSync(tempPath);
} catch {
// ignore
}
return false;
}
};
@@ -0,0 +1,139 @@
import { readDiskCache, writeDiskCache } from './disk-cache.js';
const GITHUB_API_BASE = 'https://api.github.com';
const CACHE_TTL_MS = 3 * 60 * 60 * 1000;
const FAILURE_CACHE_TTL_MS = 5 * 60 * 1000;
// Keep well under the catalog route's client request deadline so optional
// metadata enrichment can never abort catalog loading.
const FETCH_TIMEOUT_MS = 1500;
const DISK_CACHE_FILE = 'skills-github-meta.json';
const metaCache = new Map();
const inFlight = new Map();
let diskLoaded = false;
let diskWriteTimer = null;
const loadDiskEntries = () => {
if (diskLoaded) {
return;
}
diskLoaded = true;
const persisted = readDiskCache(DISK_CACHE_FILE);
if (!persisted) {
return;
}
const now = Date.now();
for (const [repo, entry] of Object.entries(persisted)) {
if (
entry
&& typeof entry === 'object'
&& typeof entry.expiresAt === 'number'
&& entry.expiresAt > now
&& entry.value
&& typeof entry.value === 'object'
) {
metaCache.set(repo, entry);
}
}
};
const scheduleDiskWrite = () => {
if (diskWriteTimer) {
return;
}
diskWriteTimer = setTimeout(() => {
diskWriteTimer = null;
const now = Date.now();
const persisted = {};
for (const [repo, entry] of metaCache.entries()) {
if (entry.expiresAt > now) {
persisted[repo] = entry;
}
}
writeDiskCache(DISK_CACHE_FILE, persisted);
}, 1000);
if (typeof diskWriteTimer.unref === 'function') {
diskWriteTimer.unref();
}
};
const parseMeta = (payload) => {
if (!payload || typeof payload !== 'object') {
return null;
}
const pushedAt = payload.pushed_at;
return {
stars: Number.isFinite(payload.stargazers_count) ? payload.stargazers_count : null,
repoUpdatedAt: typeof pushedAt === 'string' && pushedAt ? pushedAt : null,
};
};
const fetchRepoMeta = async (normalizedRepo) => {
loadDiskEntries();
const cached = metaCache.get(normalizedRepo);
if (cached && Date.now() < cached.expiresAt) {
return cached.value;
}
const existing = inFlight.get(normalizedRepo);
if (existing) {
return existing;
}
const run = (async () => {
try {
const response = await fetch(`${GITHUB_API_BASE}/repos/${normalizedRepo}`, {
headers: { Accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
// Cache failures briefly so repeated catalog loads do not re-hit a
// rate-limited or failing API for the same repository.
metaCache.set(normalizedRepo, {
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS,
value: { stars: null, repoUpdatedAt: null },
});
scheduleDiskWrite();
return null;
}
const value = parseMeta(await response.json());
if (value) {
metaCache.set(normalizedRepo, { expiresAt: Date.now() + CACHE_TTL_MS, value });
scheduleDiskWrite();
}
return value;
} catch {
metaCache.set(normalizedRepo, {
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS,
value: { stars: null, repoUpdatedAt: null },
});
scheduleDiskWrite();
return null;
} finally {
inFlight.delete(normalizedRepo);
}
})();
inFlight.set(normalizedRepo, run);
return run;
};
/**
* Fetch GitHub repository metadata (stars, last push) for a list of
* `owner/repo` strings. Best-effort: failed lookups resolve to null and
* never block the catalog response.
*/
export async function fetchGitHubRepoMetas(normalizedRepos) {
const unique = [...new Set(normalizedRepos.filter(Boolean))];
const entries = await Promise.all(unique.map(async (repo) => [repo, await fetchRepoMeta(repo)]));
return Object.fromEntries(entries);
}
/** For tests only: clear the in-memory repository metadata cache. */
export function clearGitHubMetaCache() {
metaCache.clear();
inFlight.clear();
diskLoaded = true;
}
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { clearGitHubMetaCache, fetchGitHubRepoMetas } from './github-meta.js';
const originalFetch = globalThis.fetch;
let tempDataDir;
beforeEach(() => {
tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'github-meta-test-'));
process.env.OPENCHAMBER_DATA_DIR = tempDataDir;
});
afterEach(() => {
delete process.env.OPENCHAMBER_DATA_DIR;
globalThis.fetch = originalFetch;
clearGitHubMetaCache();
vi.restoreAllMocks();
fs.rmSync(tempDataDir, { recursive: true, force: true });
});
describe('fetchGitHubRepoMetas', () => {
it('returns stars and pushed_at from the GitHub API', async () => {
const fetchMock = vi.fn(async () => new Response(
JSON.stringify({ stargazers_count: 42, pushed_at: '2026-08-01T00:00:00Z' }),
{ status: 200 },
));
globalThis.fetch = fetchMock;
const metas = await fetchGitHubRepoMetas(['anthropics/skills']);
expect(metas).toEqual({
'anthropics/skills': { stars: 42, repoUpdatedAt: '2026-08-01T00:00:00Z' },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('resolves failed lookups to null without throwing', async () => {
globalThis.fetch = vi.fn(async () => new Response('rate limited', { status: 403 }));
const metas = await fetchGitHubRepoMetas(['anthropics/skills']);
expect(metas).toEqual({ 'anthropics/skills': null });
});
it('caches failed lookups briefly to avoid repeat hits', async () => {
const fetchMock = vi.fn(async () => new Response('rate limited', { status: 403 }));
globalThis.fetch = fetchMock;
await fetchGitHubRepoMetas(['anthropics/skills']);
const second = await fetchGitHubRepoMetas(['anthropics/skills']);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second).toEqual({ 'anthropics/skills': { stars: null, repoUpdatedAt: null } });
});
it('deduplicates repositories', async () => {
const fetchMock = vi.fn(async () => new Response(
JSON.stringify({ stargazers_count: 1, pushed_at: null }),
{ status: 200 },
));
globalThis.fetch = fetchMock;
const metas = await fetchGitHubRepoMetas(['a/b', 'a/b', null]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(metas['a/b']).toEqual({ stars: 1, repoUpdatedAt: null });
});
});
@@ -1,5 +1,4 @@
const GITHUB_HOST = 'github.com';
const CLAWDHUB_SOURCE_PREFIX = 'clawdhub:';
function normalizeGitOwnerRepo(owner, repo) {
@@ -86,7 +85,3 @@ export function parseSkillRepoSource(input, options = {}) {
return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } };
}
export function isClawdHubSource(input) {
return typeof input === 'string' && input.trim().toLowerCase().startsWith(CLAWDHUB_SOURCE_PREFIX);
}
@@ -15,6 +15,15 @@ other runtime API.
## Files
- `index.js` — orchestration: `generateSmallModelText()` / `describeSmallModel()`.
- `runtime-providers.js` — provider state that exists only inside the running
OpenCode process. A plugin registers its provider from the `config` hook and
supplies the credential from its `auth` loader, so neither reaches
`opencode.json` nor `auth.json`; `GET /provider` is the only place they
become visible. The module caches one snapshot (30s TTL, shared in-flight
request) and answers `null` — never an empty provider list — when OpenCode is
unreachable, so a momentary outage cannot retract providers. It is wired once
from `server/index.js` and reset on OpenCode restart, which reloads plugins
and can move their ports and keys.
- `resolve.js` — model selection, mirroring OpenCode's `getSmallModel` chain:
0. OpenChamber's own settings override (Settings → Sessions → Small Model):
when `smallModelUseDefault` is `false`, `smallModelOverride`
@@ -100,9 +109,17 @@ other runtime API.
- Everything else: OpenAI-compatible `/chat/completions` against the
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
endpoint, or (3) the provider's `api` field from the models.dev catalog.
Configured API keys honor OpenCode's `{env:NAME}` and `{file:path}`
substitutions; file contents and resolved credentials remain server-side.
endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the
provider's `api` field from the models.dev catalog. The credential follows
the same shape: config `options.apiKey`, then the runtime credential, then
the auth.json entry. Configured API keys honor OpenCode's `{env:NAME}` and
`{file:path}` substitutions; file contents and resolved credentials remain
server-side.
- The runtime credential is refused for providers listed in
`OWN_CREDENTIAL_HANDLING`. Their branches need the stored entry rather than
a bearer token: the clearest case is the ChatGPT-plan `openai` login, whose
runtime `options.apiKey` is an OAuth access token that `api.openai.com`
answers with 401.
- `[small-model:diagnostic]` logs record provider/model, input character
counts, output budget, thinking toggle, HTTP/finish status, and
content/reasoning lengths without logging prompts, response text, or
@@ -110,11 +127,59 @@ other runtime API.
`[session-goal:diagnostic]` structural verdict metadata.
- `catalog.js` — models.dev catalog via the shared in-process cache
(`../opencode/models-metadata.js`, also serving
`/api/openchamber/models-metadata`).
`/api/openchamber/models-metadata`).
- `routes.js``GET /api/small-model` (resolution preview) and
`POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?,
model?, directory? }` → `{ text, providerID, modelID, source }`).
## Which providers the pickers may offer
`listAuthenticatedProviders()` answers one question for the Small Model and
Changes Walkthrough pickers alike: which providers can this module actually
call. One rule decides it, applied the same way to every provider — **a
credential we are allowed to use, and an endpoint to send it to.** The
auth.json scan as before, plus the credential and endpoint OpenCode resolved
for a plugin provider.
**opencode zen is excluded without a real login.** When the user has no zen
credential, OpenCode substitutes the sentinel `options.apiKey = "public"` and
trims its catalog to the free models. Those run on OpenCode's own subsidised
infrastructure and are meant to be reached through OpenCode, so the sentinel is
never accepted as a credential — see `ZEN_ANONYMOUS_API_KEY`.
### Why there is no capability probe
A plugin may implement its whole integration inside `options.fetch`
rewriting the path, signing the request, translating the payload — and OpenCode
cannot serialise a function. Such a provider advertises an ordinary base URL
that answers nothing we know how to ask, and no reported field distinguishes it
from a plain one.
Asking the endpoint (`GET /models`) does identify that case correctly. It was
measured against all 166 providers carrying an `api` URL in the models.dev
catalog, and it also denies six of them — `cloudflare-workers-ai`,
`infomaniak`, `iflowcn`, `inference`, `kuae-cloud-coding-plan`,
`thinkingmachines` — which work fine and simply have no `/models` route. At a
3.6% false-negative rate on providers known to work, the probe removes more
working models from the picker than broken ones, and a provider that silently
vanishes explains nothing while one that fails on use says why.
So availability stops at credential and endpoint, and the protocol verdict is
left to the call. A provider whose protocol lives in a plugin's `fetch` stays
selectable and fails when used — which is what it did before this resolution
existed.
Claude Code is refused unconditionally. A plugin can publish an
OpenAI-compatible endpoint for it, but that endpoint is a façade over the
Claude Agent SDK, which spawns the Claude Code CLI per request and spends the
user's Claude subscription rate limit. Paying that for a session title or a
summary is the wrong trade, so an available endpoint does not lift the
refusal — the cost is the reason, not the transport.
The result is served as `authenticatedProviders` on `GET /api/small-model`.
The field name predates the runtime resolution; it now means "callable", which
is a superset of "has an auth.json entry".
## Registration
Mounted lazily from `feature-routes-runtime.js` (same pattern as quota): the
@@ -125,9 +190,12 @@ module is imported on first request, not at server startup.
- OpenCode's free models (`opencode/big-pickle`, `*-free`) work without a
token only through OpenCode's own server — direct calls are rejected, and
piggybacking on their subsidized infra is out of bounds by design. Every
resolution step therefore requires a usable auth entry for the provider:
resolution step therefore requires a credential we are allowed to use:
a session on an unauthenticated `opencode` provider falls through to the
global scan (or a clean 404 on a vanilla setup with no logins).
global scan (or a clean 404 on a vanilla setup with no logins). The runtime
snapshot does not weaken this — OpenCode reports the sentinel
`apiKey: "public"` for that state, and this module refuses to read it as a
credential.
- Anthropic OAuth (Claude Pro/Max) entries are not supported — OpenCode itself
keeps those outside `auth.json` in this generation; only `type: api` keys
+46 -12
View File
@@ -5,6 +5,7 @@ import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
import { readConfig, readConfigLayers } from '../opencode/shared.js';
import { getCatalogProvider } from './catalog.js';
import { getAuthEntryForProvider } from './resolve.js';
import { getRuntimeProvider } from './runtime-providers.js';
// Direct, non-streaming text generation against the provider APIs, replicating
// how OpenCode authenticates each of them (see the plugin auth loaders in the
@@ -566,23 +567,52 @@ const readProviderConfig = (workingDirectory, providerID) => {
// Dispatch
// ---------------------------------------------------------------------------
/**
* Providers reached through a dedicated wire format below: a token exchange,
* an OAuth refresh, or a non-bearer header. OpenCode's runtime
* `options.apiKey` is not the value those branches need the ChatGPT-plan
* `openai` login is the clearest case, where the runtime key is an OAuth
* access token that api.openai.com answers with 401 so the runtime
* credential never stands in for them, and the runtime listing skips them
* because the auth.json scan already covers them.
*/
export const DEDICATED_WIRE_FORMAT_PROVIDERS = new Set(['github-copilot', 'copilot', 'openai', 'anthropic', 'google']);
/**
* The runtime credential shaped as an auth entry, or `null` when the provider
* owns its credential handling or OpenCode reports nothing usable.
*/
const runtimeCredential = (providerID, runtime) => (
!DEDICATED_WIRE_FORMAT_PROVIDERS.has(providerID) && runtime?.apiKey
? { type: 'api', key: runtime.apiKey }
: null
);
/**
* Same credential resolution the request path uses: config
* `provider.<id>.options.apiKey` wins, then the auth.json entry.
* `provider.<id>.options.apiKey` wins, then the runtime credential OpenCode
* resolved for a plugin provider, then the auth.json entry.
* Callers that need to refuse before spending a request (walkthrough readiness)
* must use this rather than inventing a second rule.
*/
export function resolveProviderLogin({ auth, workingDirectory, providerID }) {
export async function resolveProviderLogin({ auth, workingDirectory, providerID }) {
const providerConfig = readProviderConfig(workingDirectory, providerID);
return providerConfig?.auth || getAuthEntryForProvider(auth, providerID) || null;
return providerConfig?.auth
|| runtimeCredential(providerID, await getRuntimeProvider(providerID))
|| getAuthEntryForProvider(auth, providerID)
|| null;
}
export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
const providerConfig = readProviderConfig(workingDirectory, providerID);
// Match OpenCode's resolveSDK precedence:
// config provider.<id>.options.apiKey wins; the auth.json entry is only a fallback.
const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID);
const runtimeProvider = await getRuntimeProvider(providerID);
// Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
// wins, then what OpenCode itself resolved at runtime (the only place a
// plugin's credential exists), and the auth.json entry last.
const entry = providerConfig?.auth
|| runtimeCredential(providerID, runtimeProvider)
|| getAuthEntryForProvider(auth, providerID);
if (!entry) {
// Structured so the walkthrough (and any other caller) can show a blocker
// instead of a raw 500 banner with this developer-oriented sentence.
@@ -685,9 +715,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
// Everything else: OpenAI-compatible chat completions against the catalog's
// base URL for that provider (openai itself included). When a custom provider
// is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
// fall back to its baseURL from the OpenCode provider config. The openai
// provider also respects provider.openai.options.baseURL — OpenCode itself
// uses the same config for all providers including openai.
// fall back to its baseURL from the OpenCode provider config, then to the
// endpoint OpenCode resolved at runtime — which for a plugin provider is the
// only place it exists, and for several of them is a local proxy the plugin
// itself runs. The openai provider also respects
// provider.openai.options.baseURL — OpenCode itself uses the same config for
// all providers including openai.
const provider = getCatalogProvider(catalog, providerID);
const providerConfigUrl = providerConfig?.baseURL;
const defaultOpenaiUrl = 'https://api.openai.com/v1';
@@ -695,9 +728,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
? providerConfigUrl
: providerID === 'openai'
? defaultOpenaiUrl
: typeof provider?.api === 'string' && provider.api
? provider.api
: null;
: runtimeProvider?.baseURL
?? (typeof provider?.api === 'string' && provider.api
? provider.api
: null);
if (!baseURL) {
throw new Error(`Provider "${providerID}" has no known API base URL`);
}
@@ -12,8 +12,11 @@ vi.mock('../opencode/shared.js', () => ({
readConfigLayers: vi.fn(),
}));
vi.mock('./runtime-providers.js', () => ({ getRuntimeProvider: vi.fn(async () => null) }));
const { callSmallModel } = await import('./call.js');
const { readConfig, readConfigLayers } = await import('../opencode/shared.js');
const { getRuntimeProvider } = await import('./runtime-providers.js');
// Minimal catalog fragment used by the catalog-based base URL resolution case.
const CATALOG = {
@@ -55,6 +58,9 @@ describe('callSmallModel — custom provider config', () => {
globalThis.fetch = fetchMock;
readConfig.mockReset();
readConfigLayers.mockReset();
// Default: OpenCode knows nothing, so resolution stays file-based.
getRuntimeProvider.mockReset();
getRuntimeProvider.mockResolvedValue(null);
});
afterEach(() => {
@@ -341,6 +347,79 @@ describe('callSmallModel — custom provider config', () => {
prompt: 'hi',
})).rejects.toThrow('Provider "custom" has no known API base URL');
});
// A plugin registers its provider inside the running OpenCode process, so
// neither the config nor auth.json knows anything about it. This is the
// case that used to fail with "has no known API base URL" (#2666).
it('uses the endpoint and credential OpenCode resolved for a plugin provider', async () => {
readConfig.mockReturnValue({});
getRuntimeProvider.mockResolvedValue({
id: 'llmapi',
apiKey: 'plugin-key',
baseURL: 'https://api.llmapi.ai/v1',
anonymousZen: false,
});
const fetchMock = vi.fn(async () => ok('done'));
vi.stubGlobal('fetch', fetchMock);
await callSmallModel({
auth: {},
catalog: {},
workingDirectory: '/proj',
providerID: 'llmapi',
modelID: 'claude-opus-4-8',
prompt: 'hi',
});
const { url, init } = lastCall(fetchMock);
expect(url).toBe('https://api.llmapi.ai/v1/chat/completions');
expect(init.headers.Authorization).toBe('Bearer plugin-key');
});
it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
readConfig.mockReturnValue({});
// OpenCode reports an OAuth access token as `options.apiKey` for openai;
// api.openai.com answers it with 401, so it must not stand in for the
// codex path.
getRuntimeProvider.mockResolvedValue({
id: 'openai',
apiKey: 'oauth-access-token',
baseURL: null,
anonymousZen: false,
});
await expect(callSmallModel({
auth: {},
catalog: {},
workingDirectory: '/proj',
providerID: 'openai',
modelID: 'gpt-5.4-mini',
prompt: 'hi',
})).rejects.toMatchObject({ code: 'no-provider-login' });
});
it('prefers an explicit config baseURL over the runtime endpoint', async () => {
readConfig.mockReturnValue({ provider: { custom: { options: { baseURL: 'https://configured.example/v1' } } } });
getRuntimeProvider.mockResolvedValue({
id: 'custom',
apiKey: 'runtime-key',
baseURL: 'https://runtime.example/v1',
anonymousZen: false,
});
const fetchMock = vi.fn(async () => ok('done'));
vi.stubGlobal('fetch', fetchMock);
await callSmallModel({
auth: { custom: { type: 'api', key: 'auth-key' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'custom',
modelID: 'm',
prompt: 'hi',
});
expect(lastCall(fetchMock).url).toBe('https://configured.example/v1/chat/completions');
});
});
describe('config-supplied key does not leak', () => {
+51 -8
View File
@@ -5,7 +5,16 @@ import { readAuthFile } from '../opencode/auth.js';
import { readConfigLayers } from '../opencode/shared.js';
import { getModelCatalog } from './catalog.js';
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
import { callSmallModel, resolveProviderLogin } from './call.js';
import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
import { getRuntimeProviderSnapshot } from './runtime-providers.js';
// Never a small model, whatever the transport looks like. A plugin can publish
// an OpenAI-compatible endpoint for Claude Code, but it is a façade over the
// Claude Agent SDK, which spawns the Claude Code CLI per request and spends
// the user's Claude subscription rate limit. Paying that for a session title
// or a summary is the wrong trade, so the refusal is unconditional rather than
// conditional on an endpoint existing.
const CLAUDE_CODE_PROVIDER = 'claude-code';
const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR
@@ -120,7 +129,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
);
}
if (resolved.providerID === 'claude-code') {
if (resolved.providerID === CLAUDE_CODE_PROVIDER) {
throw Object.assign(
new Error('Claude Code cannot be used for background small-model actions. Choose another Small Model in Settings → Sessions.'),
{ statusCode: 422, code: 'small-model-provider-unsupported' },
@@ -180,28 +189,62 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
}
/**
* Provider ids with a usable OpenCode login the set the small model can
* actually call. Used by the settings override picker to hide providers that
* would only ever fail (e.g. opencode free models without a token).
* Provider ids the small model can actually call an auth.json login, or a
* credential and endpoint the running OpenCode resolved for a plugin. Used by
* the Small Model and Changes Walkthrough pickers to hide providers that would
* only ever fail (e.g. opencode free models without a token).
*/
export function listAuthenticatedProviders() {
export async function listAuthenticatedProviders() {
try {
const auth = readAuthFile();
const ids = new Set(
Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])),
);
ids.delete('claude-code');
// The catalog id is github-copilot while legacy auth entries may sit
// under the copilot alias.
if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) {
ids.add('github-copilot');
}
// Kept separate so a runtime lookup that goes wrong costs the providers it
// would have added, never the logins already established from disk.
try {
for (const providerID of await listRuntimeCallableProviders()) ids.add(providerID);
} catch {
// The auth.json set below stands on its own.
}
ids.delete(CLAUDE_CODE_PROVIDER);
return Array.from(ids);
} catch {
return [];
}
}
/**
* Providers that only the running OpenCode knows about plugin-registered
* ones, and any whose endpoint is resolved at startup.
*
* The test is the same one applied to an auth.json login: a credential we may
* use and somewhere to send it. Whether the endpoint answers the protocol we
* speak is not knowable from any field OpenCode reports, and guessing it wrong
* removes a working model from the picker with nothing to explain it.
*/
async function listRuntimeCallableProviders() {
const snapshot = await getRuntimeProviderSnapshot();
if (!snapshot) return [];
const ids = [];
for (const id of snapshot.connected) {
const provider = snapshot.providers.get(id);
// No credential we may use — including the zen sentinel, whose free models
// belong to OpenCode's own server.
if (!provider?.apiKey || !provider.baseURL) continue;
// Reached through a dedicated wire format and already covered by the
// auth.json scan above.
if (DEDICATED_WIRE_FORMAT_PROVIDERS.has(id)) continue;
ids.push(id);
}
return ids;
}
/**
* Reports which model would be used, without calling it.
*
@@ -262,7 +305,7 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe
// Settings/config/request overrides can name a provider with no usable login.
// Report that here so readiness can refuse before the user pays for a 401.
const hasLogin = Boolean(resolveProviderLogin({
const hasLogin = Boolean(await resolveProviderLogin({
auth,
workingDirectory: directory,
providerID: resolved.providerID,
@@ -19,15 +19,20 @@ vi.mock('./catalog.js', () => ({
getCatalogProvider: vi.fn(),
}));
vi.mock('./call.js', () => ({
DEDICATED_WIRE_FORMAT_PROVIDERS: new Set(['github-copilot', 'copilot', 'openai', 'anthropic', 'google']),
callSmallModel: vi.fn(),
resolveProviderLogin: vi.fn(({ auth, providerID }) => {
resolveProviderLogin: vi.fn(async ({ auth, providerID }) => {
const entry = auth?.[providerID];
return entry && typeof entry === 'object' ? entry : null;
}),
}));
vi.mock('./runtime-providers.js', () => ({
getRuntimeProviderSnapshot: vi.fn(async () => null),
}));
const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders } = await import('./index.js');
const { readAuthFile } = await import('../opencode/auth.js');
const { getRuntimeProviderSnapshot } = await import('./runtime-providers.js');
const { readConfigLayers } = await import('../opencode/shared.js');
const { getModelCatalog } = await import('./catalog.js');
const { callSmallModel } = await import('./call.js');
@@ -44,6 +49,7 @@ describe('unsupported small-model providers', () => {
readConfigLayers.mockReturnValue({ mergedConfig: {} });
getModelCatalog.mockResolvedValue({});
callSmallModel.mockReset();
getRuntimeProviderSnapshot.mockResolvedValue(null);
});
it('rejects Claude Code with an actionable error before transport dispatch', async () => {
@@ -57,8 +63,72 @@ describe('unsupported small-model providers', () => {
expect(callSmallModel).not.toHaveBeenCalled();
});
it('does not offer Claude Code in the Small Model picker', () => {
expect(listAuthenticatedProviders()).not.toContain('claude-code');
it('does not offer Claude Code in the Small Model picker', async () => {
expect(await listAuthenticatedProviders()).not.toContain('claude-code');
});
// A plugin can publish an OpenAI-compatible endpoint for Claude Code, but it
// is a façade over the Claude Agent SDK: every call spawns the CLI and
// spends the user's Claude subscription. The refusal is about that cost, so
// an available endpoint must not lift it.
it('still refuses Claude Code when a plugin publishes an HTTP endpoint for it', async () => {
getRuntimeProviderSnapshot.mockResolvedValue({
providers: new Map([['claude-code', { id: 'claude-code', apiKey: 'plugin-key', baseURL: 'http://127.0.0.1:60668/v1', anonymousZen: false }]]),
connected: new Set(['claude-code']),
});
await expect(generateSmallModelText({
prompt: 'summarize this',
model: 'claude-code/haiku',
})).rejects.toMatchObject({ code: 'small-model-provider-unsupported' });
expect(await listAuthenticatedProviders()).not.toContain('claude-code');
getRuntimeProviderSnapshot.mockResolvedValue(null);
});
});
describe('provider availability for the model pickers', () => {
beforeEach(() => {
readAuthFile.mockReturnValue({ openai: { type: 'api', key: 'sk-test' } });
readConfigLayers.mockReturnValue({ mergedConfig: {} });
getModelCatalog.mockResolvedValue({});
getRuntimeProviderSnapshot.mockResolvedValue(null);
});
const snapshot = (providers, connected) => ({
providers: new Map(providers.map((provider) => [provider.id, provider])),
connected: new Set(connected ?? providers.map((provider) => provider.id)),
});
it('offers a plugin provider that OpenCode resolved at runtime', async () => {
getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
{ id: 'llmapi', apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1', anonymousZen: false },
]));
expect(await listAuthenticatedProviders()).toEqual(expect.arrayContaining(['openai', 'llmapi']));
});
it('hides a provider with no endpoint to send a request to', async () => {
getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
{ id: 'endpointless', apiKey: 'plugin-key', baseURL: null, anonymousZen: false },
]));
expect(await listAuthenticatedProviders()).not.toContain('endpointless');
});
it('never offers opencode zen without a real login', async () => {
// The zen sentinel is not a credential, so the snapshot carries no apiKey.
getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
{ id: 'opencode', apiKey: null, baseURL: 'https://opencode.ai/zen/v1', anonymousZen: true },
]));
expect(await listAuthenticatedProviders()).not.toContain('opencode');
});
it('keeps the auth.json providers when OpenCode cannot be reached', async () => {
getRuntimeProviderSnapshot.mockResolvedValue(null);
expect(await listAuthenticatedProviders()).toContain('openai');
});
});
@@ -10,7 +10,7 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) {
res.json({
available: Boolean(resolved),
model: resolved,
authenticatedProviders: listAuthenticatedProviders(),
authenticatedProviders: await listAuthenticatedProviders(),
});
} catch (error) {
console.error('Failed to resolve small model:', error);
@@ -0,0 +1,156 @@
// Provider state that exists only inside the running OpenCode process.
//
// A plugin registers its provider from the `config` hook and supplies the
// credential from its `auth` loader, both at startup. Neither ends up in
// `opencode.json` or `auth.json`, so a server that only reads files sees
// nothing — which is why plugin-backed models used to fail here with
// "has no known API base URL" while working fine in chat (#2666).
//
// `GET /provider` is where that state becomes visible. It reports, per
// provider, the resolved `options.baseURL` and `options.apiKey`, and per model
// the wire adapter (`api.npm`) and endpoint (`api.url`).
//
// What it does NOT report is `options.fetch`. OpenCode strips functions from
// the response, and a plugin is free to put its whole protocol in there:
// rewriting the path, signing the request, translating the payload. Such a
// provider advertises a perfectly ordinary base URL that answers nothing we
// know how to ask, and no field distinguishes the two.
//
// Asking the endpoint (`GET /models`) looked like the way to tell them apart,
// and it does answer correctly for that case — but measured against the 166
// providers in the models.dev catalog it also denies six that work fine and
// simply have no `/models` route. A provider that vanishes from the picker
// explains nothing; one that fails on use says why. So this module reports
// what it knows and leaves the verdict to the call itself.
const SNAPSHOT_TTL_MS = 30_000;
const SNAPSHOT_TIMEOUT_MS = 5_000;
// opencode zen hands out this sentinel instead of a key when the user has no
// zen login, and trims its catalog to the free models. Those run on OpenCode's
// own subsidised infrastructure and are meant to be reached through OpenCode,
// not by us. Treating the sentinel as a credential would do exactly that, so
// it is never accepted as one.
export const ZEN_ANONYMOUS_API_KEY = 'public';
let connection = null;
let snapshot = null;
let snapshotAt = 0;
let inflight = null;
/**
* Wires this module to the running OpenCode instance. Called once at server
* startup; pass `null` to detach. Until it is wired every lookup answers
* "nothing known", which leaves the file-based resolution unchanged.
*/
export function configureOpenCodeRuntimeProviders(next) {
connection = next ?? null;
resetOpenCodeRuntimeProviders();
}
/**
* Drops every cached answer. OpenCode restarts reload plugins, which can
* change ports, keys and the provider list itself.
*/
export function resetOpenCodeRuntimeProviders() {
snapshot = null;
snapshotAt = 0;
inflight = null;
}
/**
* The boundary. Everything the `/provider` payload claims is checked here, so
* the rest of this module and its callers work with settled values:
* a credential we may use, an endpoint, and whether the provider is the
* anonymous zen case.
*
* The credential deliberately prefers `options.apiKey` over the `key` field:
* for a plugin provider the former is what its auth loader produced and what
* OpenCode itself sends, while `key` only carries env/auth.json values this
* server can already read from disk.
*/
function parseProviderListing(payload) {
const providers = new Map();
const connected = new Set();
if (!payload || typeof payload !== 'object') return { providers, connected };
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
const record = (value) => (value && typeof value === 'object' ? value : {});
const endpoint = (value) => text(value)?.replace(/\/+$/, '') ?? null;
for (const raw of Array.isArray(payload.all) ? payload.all : []) {
const id = text(record(raw).id);
if (!id) continue;
const options = record(record(raw).options);
const firstModel = record(Object.values(record(record(raw).models))[0]);
const declaredKey = text(options.apiKey);
providers.set(id, {
id,
source: text(record(raw).source),
apiKey: declaredKey === ZEN_ANONYMOUS_API_KEY ? null : (declaredKey ?? text(record(raw).key)),
baseURL: endpoint(options.baseURL) ?? endpoint(record(firstModel.api).url),
// True only for the zen-without-login case: a provider that is present
// and usable through OpenCode, but that we must not call ourselves.
anonymousZen: declaredKey === ZEN_ANONYMOUS_API_KEY,
});
}
// Providers OpenCode considers usable right now. A provider can be present
// in `all` (it is in the catalog) without any credential behind it.
for (const raw of Array.isArray(payload.connected) ? payload.connected : []) {
const id = text(raw);
if (id) connected.add(id);
}
return { providers, connected };
}
const fetchSnapshot = async () => {
const response = await fetch(connection.buildOpenCodeUrl('/provider', ''), {
headers: { Accept: 'application/json', ...connection.getOpenCodeAuthHeaders() },
signal: AbortSignal.timeout(SNAPSHOT_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`OpenCode provider listing failed with ${response.status}`);
}
return parseProviderListing(await response.json());
};
/**
* The current runtime provider snapshot, or `null` when OpenCode cannot be
* reached.
*
* `null` means "unknown", never "no providers": callers must fall back to
* their file-based resolution rather than treat an unreachable OpenCode as an
* empty provider list.
*/
export async function getRuntimeProviderSnapshot() {
if (!connection) return null;
if (snapshot && Date.now() - snapshotAt < SNAPSHOT_TTL_MS) return snapshot;
if (!inflight) {
inflight = fetchSnapshot().finally(() => {
inflight = null;
});
}
try {
snapshot = await inflight;
snapshotAt = Date.now();
return snapshot;
} catch {
// Keep serving the previous snapshot when there is one: a momentarily
// unreachable OpenCode should not retract providers that were resolving a
// second ago.
return snapshot;
}
}
/**
* Runtime credential and endpoint for one provider, or `null` when OpenCode
* knows nothing about it.
*/
export async function getRuntimeProvider(providerID) {
const current = await getRuntimeProviderSnapshot();
return current?.providers.get(providerID) ?? null;
}
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ZEN_ANONYMOUS_API_KEY,
configureOpenCodeRuntimeProviders,
getRuntimeProvider,
getRuntimeProviderSnapshot,
resetOpenCodeRuntimeProviders,
} from './runtime-providers.js';
const providerPayload = (overrides = {}) => ({
all: [
{
id: 'llmapi',
source: 'config',
options: { apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1/' },
models: { 'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } } },
},
{
id: 'opencode',
source: 'custom',
options: { apiKey: ZEN_ANONYMOUS_API_KEY },
models: { 'free-model': { api: { id: 'free-model', url: 'https://opencode.ai/zen/v1', npm: '@ai-sdk/openai-compatible' } } },
},
{
id: 'zai-coding-plan',
source: 'api',
key: 'auth-json-key',
options: {},
models: { 'glm-5': { api: { id: 'glm-5', url: 'https://api.z.ai/api/coding/paas/v4', npm: '@ai-sdk/openai-compatible' } } },
},
],
connected: ['llmapi', 'opencode', 'zai-coding-plan'],
...overrides,
});
describe('OpenCode runtime provider snapshot', () => {
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn(async () => new Response(JSON.stringify(providerPayload()), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
configureOpenCodeRuntimeProviders({
buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
});
});
afterEach(() => {
configureOpenCodeRuntimeProviders(null);
resetOpenCodeRuntimeProviders();
vi.unstubAllGlobals();
});
it('reports the credential and endpoint a plugin registered at runtime', async () => {
const provider = await getRuntimeProvider('llmapi');
expect(provider).toMatchObject({ apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1' });
expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:4096/provider');
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Basic test' });
});
it('refuses the zen sentinel as a credential', async () => {
const provider = await getRuntimeProvider('opencode');
expect(provider.apiKey).toBeNull();
expect(provider.anonymousZen).toBe(true);
// The endpoint is still reported; only the credential is withheld.
expect(provider.baseURL).toBe('https://opencode.ai/zen/v1');
});
it('falls back to the model endpoint when the provider carries no baseURL', async () => {
expect((await getRuntimeProvider('zai-coding-plan')).baseURL).toBe('https://api.z.ai/api/coding/paas/v4');
});
it('serves one snapshot to concurrent callers instead of refetching', async () => {
await Promise.all([getRuntimeProvider('llmapi'), getRuntimeProvider('opencode'), getRuntimeProvider('zai-coding-plan')]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('answers "unknown" rather than "no providers" when OpenCode is unreachable', async () => {
resetOpenCodeRuntimeProviders();
fetchMock.mockRejectedValue(new Error('connection refused'));
expect(await getRuntimeProviderSnapshot()).toBeNull();
});
it('keeps the previous snapshot when a later refresh fails', async () => {
await getRuntimeProviderSnapshot();
fetchMock.mockRejectedValue(new Error('connection refused'));
// Past the snapshot TTL, so the next read genuinely attempts a refresh.
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 60_000);
const refreshed = await getRuntimeProviderSnapshot();
vi.useRealTimers();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(refreshed.providers.has('llmapi')).toBe(true);
});
it('stays on file-based resolution until it is configured', async () => {
configureOpenCodeRuntimeProviders(null);
expect(await getRuntimeProvider('llmapi')).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
});