Merge branch 'main' into reproduce/issue-1720

Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
Mayuresh K
2026-08-05 10:27:32 +01:00
committed by GitHub
1402 changed files with 157643 additions and 52975 deletions
@@ -10,7 +10,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring).
- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open.
- `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
- `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
- `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
@@ -26,8 +26,12 @@ This module provides OpenCode server integration utilities for the web server ru
- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
- `packages/web/server/lib/opencode/upgrade-capability.js`: authoritative upgrade ownership policy for the active OpenCode runtime. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode.
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
- `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
- `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
@@ -53,8 +57,14 @@ This module provides OpenCode server integration utilities for the web server ru
- `AUTH_FILE`: Auth file path constant.
- `OPENCODE_DATA_DIR`: OpenCode data directory path constant.
## Public exports (providers.js)
- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
## Public exports (shared.js)
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path.
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
- `ensureDirs()`: Creates required OpenCode directories.
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
@@ -74,10 +84,11 @@ This module provides OpenCode server integration utilities for the web server ru
- `GET /api/config/settings`
- `PUT /api/config/settings`
- `GET /api/config/opencode-resolution`
- `POST /api/opencode/upgrade` (proxies OpenCode upgrade, then restarts managed OpenCode so the new binary is active)
- `GET /api/opencode/upgrade-status`
- `POST /api/opencode/upgrade` (enforces the active runtime's upgrade capability, serializes supported OpenCode upgrades, then restarts managed OpenCode so the new binary is active)
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
- `POST /api/opencode/directory`
- `GET /api/provider/:providerId/source`
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers via `scope`; secrets stay in auth via the OpenCode auth API)
- `DELETE /api/provider/:providerId/auth`
- Owns lazy auth library loading for provider auth checks/removal.
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
@@ -87,6 +98,7 @@ This module provides OpenCode server integration utilities for the web server ru
- Returned API:
- `processOpenCodeSsePayload(payload)`
- `getSessionActivitySnapshot()`
- `getActiveSessionCount()`
- `getSessionStateSnapshot()`
- `getSessionAttentionSnapshot()`
- `getSessionState(sessionId)`
@@ -97,6 +109,8 @@ This module provides OpenCode server integration utilities for the web server ru
- `resetAllSessionActivityToIdle()`
- `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.
- Returned API:
@@ -110,8 +124,22 @@ This module provides OpenCode server integration utilities for the web server ru
- `waitForPortRelease(port, timeoutMs, hostname?)`
- `killProcessOnPort(port)`
Managed OpenCode launch also merges the environment returned by the agent-tool
runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
be replaced by injected values. External OpenCode processes receive no
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
path (#2588).
Set `OPENCHAMBER_STARTUP_PERF=1` to emit bounded startup phase records for server listen, managed OpenCode preparation/readiness, and proxy readiness holds. Every OpenCode bootstrap emits one terminal `opencode.bootstrap.ready` or `opencode.bootstrap.error` event, including reused and external server paths. Records contain controlled phase/outcome/route labels and timing values only; they never contain request URLs, runtime keys, directories, session IDs, credentials, or content.
macOS `say` voice enumeration starts concurrently with server composition. The server listener and managed OpenCode startup do not wait for it; `/api/tts/say/status` awaits the same authoritative capability promise when queried before enumeration completes.
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.
## 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.
- Returned API:
- `applyLoginShellEnvSnapshot()`
- `getLoginShellEnvSnapshot()`
@@ -123,7 +151,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `resolveWslExecutablePath()`
- `buildWslExecArgs(execArgs, distroOverride?)`
- `isExecutable(filePath)`
- `searchPathFor(binaryName)`
- `searchPathFor(binaryName, searchPath?)`: resolves an executable from the supplied PATH value, defaulting to the process PATH.
- `clearResolvedOpenCodeBinary()`
## Public exports (env-config.js)
@@ -166,6 +194,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `readSettingsFromDiskMigrated()`
- `writeSettingsToDisk(settings)`
- `persistSettings(changes)`
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
## Public exports (settings-helpers.js)
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -306,6 +335,10 @@ This module provides OpenCode server integration utilities for the web server ru
- Returned API:
- `run(options)`
The pipeline binds the OpenChamber listener and publishes its active port
before starting managed OpenCode. The managed custom tool therefore receives
an authoritative loopback callback URL even when OpenChamber binds port `0`.
## Public exports (openchamber-routes.js)
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
- `GET /api/openchamber/update-check`
@@ -327,14 +360,23 @@ This module provides OpenCode server integration utilities for the web server ru
## Public exports (skill-routes.js)
- `registerSkillRoutes(app, dependencies)`: registers skills-related routes:
- Skills config CRUD and metadata under `/api/config/skills*`
- Skill rename via `PATCH /api/config/skills/:name` with `{ renameTo }` (directory rename preserves `SKILL.md` body and supporting files; restricted to managed skill roots under `.opencode/skills|skill`, `.claude/skills`, and `.agents/skills`)
- Skill list responses include authoritative `renamable` derived from the same managed-root policy used by rename
- Skills catalog listing/source pagination, scan, and install routes
- Supporting skill file read/write/delete routes
- Directory resolution prefers an explicit request directory, then soft-falls
back to the active project / `lastDirectory` so repository-local
`.agents/skills` and `.opencode/skills` remain discoverable when the client
omits `directory`. Requests without any project still list user-scoped skills.
## Public exports (proxy.js)
- `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware.
- Owns:
- SSE forwarders: `GET /api/global/event`, `GET /api/event`
- Downstream heartbeats keep clients and intermediaries alive, while a separate upstream-only stall watchdog closes the downstream response when OpenCode stops producing bytes so clients reconnect instead of trusting synthetic heartbeats indefinitely. Each watchdog reset uses the current load-aware timeout, matching the shared event transport.
- Session message forwarder: `POST /api/session/:sessionId/message`
- Interactive OAuth forwarder: `POST /api/provider/:providerID/oauth/callback`
- Upstream blocks inside this call for the whole browser sign-in (device-code polling or a loopback redirect), so it is exempt from the ordinary request deadline and uses a 15-minute proxy timeout instead of `LONG_REQUEST_TIMEOUT_MS`. All other `/api/provider/*` routes, including `oauth/authorize`, keep the ordinary deadline.
- Generic `/api/*` forwarding with hop-by-hop header filtering
- Windows `/session` merge fallback path behavior
- OpenCode readiness gate for proxied `/api` requests
+17 -70
View File
@@ -215,70 +215,12 @@ function getAgentPermissionSource(agentName, workingDirectory, lookupCache = nul
return { source: null, scope: null, path: null };
}
function mergePermissionWithNonWildcards(newPermission, permissionSource, agentName) {
if (!permissionSource.source || !permissionSource.path) {
return newPermission;
}
let existingPermission = null;
if (permissionSource.source === 'md') {
const { frontmatter } = parseMdFile(permissionSource.path);
existingPermission = frontmatter.permission;
} else if (permissionSource.source === 'json') {
const config = readConfigFile(permissionSource.path);
existingPermission = config?.agent?.[agentName]?.permission;
}
if (!existingPermission || typeof existingPermission === 'string') {
return newPermission;
}
function applyAgentPermission(target, newPermission) {
if (newPermission == null) {
return null;
delete target.permission;
} else {
target.permission = newPermission;
}
if (typeof newPermission === 'string') {
return newPermission;
}
const nonWildcardPatterns = {};
for (const [permKey, permValue] of Object.entries(existingPermission)) {
if (permKey === '*') continue;
if (typeof permValue === 'object' && permValue !== null && !Array.isArray(permValue)) {
const nonWildcards = {};
for (const [pattern, action] of Object.entries(permValue)) {
if (pattern !== '*') {
nonWildcards[pattern] = action;
}
}
if (Object.keys(nonWildcards).length > 0) {
nonWildcardPatterns[permKey] = nonWildcards;
}
}
}
if (Object.keys(nonWildcardPatterns).length === 0) {
return newPermission;
}
const merged = { ...newPermission };
for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) {
const newValue = merged[permKey];
if (typeof newValue === 'string') {
merged[permKey] = { '*': newValue, ...patterns };
} else if (typeof newValue === 'object' && newValue !== null) {
merged[permKey] = { ...patterns, ...newValue };
} else {
const existingValue = existingPermission[permKey];
if (typeof existingValue === 'object' && existingValue !== null) {
const wildcard = existingValue['*'];
merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns;
}
}
}
return merged;
}
function getAgentSources(agentName, workingDirectory, lookupCache = createAgentLookupCache()) {
@@ -451,6 +393,9 @@ function updateAgent(agentName, updates, workingDirectory) {
const creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates)) {
// Skip undefined values — they would overwrite existing frontmatter fields with nothing
if (value === undefined) continue;
if (field === 'prompt') {
if (value === null) {
if (mdExists || creatingNewMd) {
@@ -517,15 +462,17 @@ function updateAgent(agentName, updates, workingDirectory) {
if (field === 'permission') {
const permissionSource = getAgentPermissionSource(agentName, workingDirectory, lookupCache);
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName);
// The client edits the complete source permission map; persist it verbatim.
// (The old non-wildcard re-merge resurrected rules the user deleted.)
const newPermission = value && typeof value === 'object' && Object.keys(value).length === 0 ? null : value;
if (permissionSource.source === 'md') {
if (mdData && permissionSource.path === targetPath) {
mdData.frontmatter.permission = newPermission;
applyAgentPermission(mdData.frontmatter, newPermission);
mdModified = true;
} else {
const existingMdData = parseMdFile(permissionSource.path);
existingMdData.frontmatter.permission = newPermission;
applyAgentPermission(existingMdData.frontmatter, newPermission);
writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body);
console.log(`Updated permission in .md file: ${permissionSource.path}`);
}
@@ -533,30 +480,30 @@ function updateAgent(agentName, updates, workingDirectory) {
if (permissionSource.path === (jsonTarget.path || CONFIG_FILE)) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].permission = newPermission;
applyAgentPermission(config.agent[agentName], newPermission);
jsonModified = true;
} else {
const existingConfig = readConfigFile(permissionSource.path);
if (!existingConfig.agent) existingConfig.agent = {};
if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {};
existingConfig.agent[agentName].permission = newPermission;
applyAgentPermission(existingConfig.agent[agentName], newPermission);
writeConfig(existingConfig, permissionSource.path);
console.log(`Updated permission in JSON: ${permissionSource.path}`);
}
} else {
if (mdExists && mdData) {
mdData.frontmatter.permission = newPermission;
applyAgentPermission(mdData.frontmatter, newPermission);
mdModified = true;
} else if (hasJsonFields) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].permission = newPermission;
applyAgentPermission(config.agent[agentName], newPermission);
jsonModified = true;
} else {
const writeTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
if (!writeTarget.config.agent) writeTarget.config.agent = {};
if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {};
writeTarget.config.agent[agentName].permission = newPermission;
applyAgentPermission(writeTarget.config.agent[agentName], newPermission);
writeConfig(writeTarget.config, writeTarget.path);
console.log(`Created permission in JSON: ${writeTarget.path}`);
}
+19
View File
@@ -7,6 +7,7 @@ export const createBootstrapRuntime = (dependencies) => {
registerTtsRoutes,
registerNotificationRoutes,
registerOpenChamberRoutes,
registerAgentToolRoutes = () => {},
express,
} = dependencies;
@@ -22,6 +23,13 @@ export const createBootstrapRuntime = (dependencies) => {
uiPassword,
tunnelAuthController,
remoteClientAuthRuntime,
clientPairingRuntime,
getRelayPairingCandidate,
reconcileRelay,
getPairingTransports,
getDirectCandidateUrls,
getServerId,
getServerLabel,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
sayTTSCapability,
@@ -52,6 +60,7 @@ export const createBootstrapRuntime = (dependencies) => {
fetchFreeZenModels,
getCachedZenModels,
setAutoAcceptSession,
agentToolRuntime,
} = options;
const uiAuthController = createUiAuth({
@@ -71,17 +80,27 @@ export const createBootstrapRuntime = (dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
getServerId,
tunnelAuthController,
uiAuthController,
});
registerCommonRequestMiddleware(app, { express, verboseRequestLogs });
registerAgentToolRoutes(app, { express, agentToolRuntime });
registerAuthAndAccessRoutes(app, {
express,
tunnelAuthController,
uiAuthController,
remoteClientAuthRuntime,
clientPairingRuntime,
getRelayPairingCandidate,
reconcileRelay,
getPairingTransports,
getDirectCandidateUrls,
getServerId,
getServerLabel,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
});
+324 -8
View File
@@ -67,10 +67,29 @@ export const registerServerStatusRoutes = (app, dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
// Stable server identity (hash of the public signing key — not a secret).
// Exposed on /health and /api/version so a client can verify that a
// learned/probed address belongs to the expected server BEFORE sending its
// bearer token there. Optional: older wiring omits it.
getServerId = async () => null,
tunnelAuthController = null,
uiAuthController = null,
} = dependencies;
// The identity is immutable for the process lifetime; resolve once, and never
// let an identity failure break health reporting.
let cachedServerId = null;
const resolveServerId = async () => {
if (cachedServerId) return cachedServerId;
try {
const value = await getServerId();
cachedServerId = typeof value === 'string' && value.trim() ? value.trim() : null;
} catch {
cachedServerId = null;
}
return cachedServerId;
};
const allocateLoopbackPort = async () => {
const net = await import('node:net');
return await new Promise((resolve, reject) => {
@@ -213,24 +232,28 @@ export const registerServerStatusRoutes = (app, dependencies) => {
}
};
app.get('/health', (_req, res) => {
app.get('/health', async (_req, res) => {
const serverId = await resolveServerId();
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
openchamberVersion,
runtime: runtimeName,
compatibility,
...(serverId ? { serverId } : {}),
...getHealthSnapshot(),
});
});
app.get('/api/version', (_req, res) => {
app.get('/api/version', async (_req, res) => {
const serverId = await resolveServerId();
res.json({
status: 'ok',
openchamberVersion,
runtime: runtimeName,
startedAt: serverStartedAt,
compatibility,
...(serverId ? { serverId } : {}),
});
});
@@ -358,9 +381,32 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
tunnelAuthController,
uiAuthController,
remoteClientAuthRuntime,
clientPairingRuntime,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
// Returns the relay pairing candidate ({ type:'relay', relayUrl, serverId,
// hostEncPubJwk, priority }) when the host relay is enabled, else null.
// Injected lazily because the relay service is constructed after these routes.
getRelayPairingCandidate = async () => null,
// Re-evaluate the relay lifecycle after pairing/device changes.
reconcileRelay = async () => {},
// Returns { local, lan, relayAvailable } — the direct transport URLs the
// server can actually be reached on (LAN derived from the server bind, not
// the UI origin), for the create-device dialog.
getPairingTransports = () => ({ local: null, lan: null, relayAvailable: true }),
// Returns ALL direct LAN URLs the server is currently reachable on (client-
// reached address first, then interface scan) for the candidates-refresh
// endpoint. Empty when the server is loopback-only.
getDirectCandidateUrls = () => [],
// Stable server identity for client-side verification of learned addresses.
getServerId = async () => null,
// Display name a paired device shows for THIS server (issuing machine's
// hostname), distinct from the per-device pairing label typed by the operator.
getServerLabel = () => 'OpenChamber',
} = dependencies;
const PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS = 10;
const pairingRedeemAttempts = new Map();
const runWithUiAuth = async (req, res, next, handler, options = {}) => {
try {
@@ -440,6 +486,112 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return clients.find((client) => client.id === clientId) || null;
};
const requestOrigin = (req) => {
const forwardedProto = typeof req.headers?.['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim()
: '';
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
const host = typeof req.headers?.host === 'string' ? req.headers.host.trim() : '';
if (!host) return null;
return `${protocol}://${host}`;
};
const requestIp = (req) => {
// Do not use req.ip here: Express rewrites it from X-Forwarded-For when
// trust proxy is enabled, and redeem is unauthenticated before this limit.
return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
};
const pairingIdFromRequest = (req) => {
const raw = typeof req.body?.pairingId === 'string' ? req.body.pairingId.trim() : '';
return raw || 'missing';
};
const checkPairingRedeemRateLimit = (req) => {
const now = Date.now();
const key = `${requestIp(req)}:${pairingIdFromRequest(req)}`;
for (const [entryKey, entry] of pairingRedeemAttempts.entries()) {
if (!entry || now - entry.firstAttemptAt >= PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) {
pairingRedeemAttempts.delete(entryKey);
}
}
const entry = pairingRedeemAttempts.get(key);
if (!entry) {
pairingRedeemAttempts.set(key, { count: 1, firstAttemptAt: now });
return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - 1, reset: Math.ceil((now + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000) };
}
const reset = Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000);
if (entry.count >= PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS) {
return {
allowed: false,
remaining: 0,
reset,
retryAfter: Math.max(1, Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS - now) / 1000)),
};
}
entry.count += 1;
return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - entry.count, reset };
};
const clearPairingRedeemRateLimit = (req) => {
pairingRedeemAttempts.delete(`${requestIp(req)}:${pairingIdFromRequest(req)}`);
};
const normalizeCandidateUrl = (value) => {
if (typeof value !== 'string' || !value.trim()) return null;
try {
const parsed = new URL(value.trim());
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
parsed.hash = '';
parsed.search = '';
return parsed.toString().replace(/\/+$/, '');
} catch {
return null;
}
};
// `preferredServerUrl` is the caller-supplied externally reachable URL (the
// desktop UI reaches its own server over loopback, so the request origin is not
// scannable — it passes the LAN URL instead). Falls back to the request origin
// for remote callers where the Host header IS the reachable address.
//
// `includeRelay` is the per-link transport choice from the create-link dialog:
// true → add the relay candidate, enabling the relay host on demand;
// false → direct only, never relay;
// undefined → legacy: advertise relay only if it is already enabled.
// `includeDirect === false` produces a relay-only link (no direct candidate).
const pairingServerCandidates = async (req, { preferredServerUrl, includeRelay, includeDirect = true } = {}) => {
const candidates = [];
if (includeDirect) {
const direct = normalizeCandidateUrl(preferredServerUrl) || requestOrigin(req);
if (direct) {
let type = 'lan';
try {
const parsed = new URL(direct);
type = parsed.protocol === 'https:' ? 'tunnel' : 'lan';
} catch {
}
candidates.push({ type, url: direct, priority: 10 });
}
}
// The client races candidates and falls back to relay only if the direct URL
// is unreachable (relay carries a higher priority number).
if (includeRelay !== false) {
try {
const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: includeRelay === true });
if (relayCandidate) candidates.push(relayCandidate);
} catch {
// A relay enable/status failure must not break direct pairing.
}
}
return candidates;
};
const sendPairingRedeemError = (res, error) => {
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 400;
res.status(statusCode).json({ error: 'Invalid or expired pairing session' });
};
const requireApiAuth = async (req, res, next) => {
// Preview proxy requests carry a target-scoped capability token that the
// preview proxy validates against the registered target id/TTL. Let those
@@ -588,7 +740,12 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
await runWithClientManagementAuth(req, res, next, async (authContext) => {
if (authContext.type === 'client') {
const client = await clientRecordFromAuthContext(authContext);
return res.json({ clients: client ? [client] : [] });
// The desktop shell's local client is the trusted operator of this
// server; it manages devices just like a browser UI session. Every
// other client token is scoped to its own record.
if (client?.clientKind !== 'desktop-local') {
return res.json({ clients: client ? [client] : [] });
}
}
const clients = await remoteClientAuthRuntime.listClients();
res.json({ clients });
@@ -610,24 +767,178 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
await runWithClientManagementAuth(req, res, next, async (authContext) => {
if (authContext.type === 'client') {
const clientId = clientIdFromAuthContext(authContext);
if (!clientId || clientId !== req.params?.id) {
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
const actingClient = await clientRecordFromAuthContext(authContext);
// The desktop shell's local client manages every device; other client
// tokens may only revoke themselves.
if (actingClient?.clientKind !== 'desktop-local') {
const clientId = clientIdFromAuthContext(authContext);
if (!clientId || clientId !== req.params?.id) {
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
}
}
}
const result = await remoteClientAuthRuntime.revokeClient(req.params?.id);
if (!result.revoked) {
return res.status(404).json({ revoked: false, error: 'Client not found' });
}
void reconcileRelay();
res.json(result);
});
});
app.delete('/api/client-auth/clients', async (req, res, next) => {
await runWithUiAuth(req, res, next, async () => {
await runWithClientManagementAuth(req, res, next, async (authContext) => {
if (authContext.type === 'client') {
const actingClient = await clientRecordFromAuthContext(authContext);
// Purging revoked devices is a whole-server management action; only the
// trusted desktop shell client (or a UI session) may do it.
if (actingClient?.clientKind !== 'desktop-local') {
return res.status(403).json({ purged: 0, error: 'Client tokens cannot purge revoked devices' });
}
}
const result = await remoteClientAuthRuntime.purgeRevokedClients();
void reconcileRelay();
res.json(result);
}, { sessionOnly: true });
});
});
app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async (authContext) => {
const candidates = await pairingServerCandidates(req, {
preferredServerUrl: req.body?.serverUrl,
includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined,
includeDirect: req.body?.includeDirect !== false,
});
const usesRelay = candidates.some((candidate) => candidate.type === 'relay');
const result = await clientPairingRuntime.createPairingSession({
label: req.body?.label,
allowedClientKinds: req.body?.allowedClientKinds,
createdByClientId: clientIdFromAuthContext(authContext),
usesRelay,
});
void reconcileRelay();
res.setHeader('Cache-Control', 'no-store');
res.status(201).json({
...result,
server: { label: getServerLabel(), candidates },
});
});
});
// Current reachable transports for an ALREADY-PAIRED device. Pairing-payload
// candidates are a snapshot: when DHCP hands this machine a new address, the
// device's saved LAN candidate goes stale and it is stuck on the relay forever.
// A client that connected over any live transport calls this to learn the
// server's present LAN URLs (plus the relay candidate when enabled) and update
// its saved candidate set. `serverId` lets the client bind the response — and
// later /health probes of the learned addresses — to this server's identity
// before trusting them with its bearer token.
// Auth: UI session or client bearer; never the short-lived URL token.
app.get('/api/client-auth/connection/candidates', async (req, res, next) => {
await runWithClientManagementAuth(req, res, next, async () => {
const candidates = [];
const directUrls = (() => {
try {
const urls = getDirectCandidateUrls(req);
return Array.isArray(urls) ? urls : [];
} catch {
return [];
}
})();
for (const url of directUrls) {
const normalized = normalizeCandidateUrl(url);
if (normalized) candidates.push({ type: 'lan', url: normalized, priority: 10 });
}
try {
const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: false });
if (relayCandidate) candidates.push(relayCandidate);
} catch {
// Relay status failure must not break the direct-candidate refresh.
}
let serverId = null;
try {
const value = await getServerId();
serverId = typeof value === 'string' && value.trim() ? value.trim() : null;
} catch {
serverId = null;
}
res.setHeader('Cache-Control', 'no-store');
res.json({ label: getServerLabel(), ...(serverId ? { serverId } : {}), candidates });
});
});
// Direct transports the server can be reached on (for the create-device dialog).
app.get('/api/client-auth/pairing/transports', async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async () => {
res.setHeader('Cache-Control', 'no-store');
res.json(getPairingTransports(req));
});
});
// Pending pairing sessions (link created, device not yet connected) for the
// "pending devices" list. Secrets are never included.
app.get('/api/client-auth/pairing/sessions', async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async () => {
const pending = await clientPairingRuntime.listPendingSessions();
res.setHeader('Cache-Control', 'no-store');
res.json({ pending });
});
});
app.delete('/api/client-auth/pairing/sessions/:id', async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async () => {
const result = await clientPairingRuntime.cancelPairingSession(req.params?.id);
if (!result.cancelled) {
return res.status(404).json({ cancelled: false, error: 'Pairing session not found' });
}
void reconcileRelay();
res.json(result);
});
});
app.post('/api/client-auth/pairing/redeem', express.json({ limit: '64kb' }), async (req, res, next) => {
try {
const rateLimit = checkPairingRedeemRateLimit(req);
res.setHeader('X-RateLimit-Limit', PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS);
res.setHeader('X-RateLimit-Remaining', rateLimit.remaining);
res.setHeader('X-RateLimit-Reset', rateLimit.reset);
if (!rateLimit.allowed) {
res.setHeader('Retry-After', rateLimit.retryAfter);
return res.status(429).json({ error: 'Invalid or expired pairing session' });
}
const result = await clientPairingRuntime.redeemPairingSession({
pairingId: req.body?.pairingId,
secret: req.body?.secret,
clientLabel: req.body?.clientLabel,
clientKind: req.body?.clientKind,
deviceName: req.body?.deviceName,
devicePlatform: req.body?.devicePlatform,
deviceModel: req.body?.deviceModel,
appVersion: req.body?.appVersion,
dedupeKey: req.body?.dedupeKey,
});
clearPairingRedeemRateLimit(req);
// The session became a device: relay demand may have moved from the pending
// session to the paired device (or a non-relay redeem may drop it).
void reconcileRelay();
res.setHeader('Cache-Control', 'no-store');
res.json({
ok: true,
server: {
label: getServerLabel(),
url: requestOrigin(req),
fingerprint: result.pairing?.fingerprint || null,
},
client: result.client,
clientToken: result.token,
});
} catch (error) {
if (error?.message === 'Invalid or expired pairing session') {
sendPairingRedeemError(res, error);
return;
}
next(error);
}
});
app.get('/connect', async (req, res) => {
@@ -758,7 +1069,12 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/opencode') ||
req.path.startsWith('/api/push') ||
req.path.startsWith('/api/notifications') ||
req.path.startsWith('/api/permission-auto-accept') ||
req.path.startsWith('/api/provider') ||
req.path.startsWith('/api/session-folders') ||
req.path.startsWith('/api/small-model') ||
req.path.startsWith('/api/walkthrough') ||
req.path.startsWith('/api/goals') ||
req.path.startsWith('/api/text') ||
req.path.startsWith('/api/voice') ||
req.path.startsWith('/api/tts') ||
@@ -1,9 +1,14 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createTunnelAuth } from './tunnel-auth.js';
import { registerAuthAndAccessRoutes, registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js';
describe('core-routes', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should call gracefulShutdown with exitProcess: true on /api/system/shutdown', async () => {
const app = express();
let shutdownOpts = null;
@@ -122,6 +127,37 @@ describe('core-routes', () => {
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
});
it('should parse JSON bodies for custom provider upsert routes', async () => {
const app = express();
registerCommonRequestMiddleware(app, { express });
app.put('/api/provider', (req, res) => {
res.json({ body: req.body });
});
const response = await request(app)
.put('/api/provider')
.send({
providerID: 'campus-llm',
config: {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { fast: { name: 'Fast' } },
},
})
.expect(200);
expect(response.body).toEqual({
body: {
providerID: 'campus-llm',
config: {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { fast: { name: 'Fast' } },
},
},
});
});
it('should require API auth before probing loopback preview URLs', async () => {
const app = express();
const originalFetch = globalThis.fetch;
@@ -225,6 +261,206 @@ describe('core-routes', () => {
}
});
const createPairingRouteApp = (overrides = {}) => {
const app = express();
const dependencies = {
express,
tunnelAuthController: {
classifyRequestScope: () => 'local',
requireTunnelSession: vi.fn(),
getTunnelSessionFromRequest: vi.fn(),
clearTunnelSessionCookie: vi.fn(),
exchangeBootstrapToken: vi.fn(),
},
uiAuthController: {
resolveAuthContext: vi.fn(async () => ({ type: 'session', token: 'session-token' })),
requireAuth: vi.fn((_req, _res, next) => next()),
requireSessionAuth: vi.fn((_req, _res, next) => next()),
handleSessionStatus: vi.fn(),
handleSessionCreate: vi.fn(),
handleUrlAuthToken: vi.fn(),
handlePasskeyStatus: vi.fn(),
handlePasskeyAuthenticationOptions: vi.fn(),
handlePasskeyAuthenticationVerify: vi.fn(),
handlePasskeyRegistrationOptions: vi.fn(),
handlePasskeyRegistrationVerify: vi.fn(),
handlePasskeyList: vi.fn(),
handlePasskeyRevoke: vi.fn(),
handleResetAuth: vi.fn(),
},
remoteClientAuthRuntime: {
listClients: vi.fn(async () => []),
createClient: vi.fn(),
revokeClient: vi.fn(),
purgeRevokedClients: vi.fn(),
},
clientPairingRuntime: {
createPairingSession: vi.fn(async () => ({ pairing: { id: 'pair_1', secret: 'secret', expiresAt: '2099-01-01T00:00:00.000Z', fingerprint: 'ABCD-1234' } })),
cancelPairingSession: vi.fn(async () => ({ cancelled: true })),
redeemPairingSession: vi.fn(async () => ({
pairing: { fingerprint: 'ABCD-1234' },
client: { id: 'client-1', label: 'Phone', authMethod: 'pairing' },
token: 'oc_client_token',
})),
},
readSettingsFromDiskMigrated: vi.fn(async () => ({})),
normalizeTunnelSessionTtlMs: vi.fn(),
...overrides,
};
registerAuthAndAccessRoutes(app, dependencies);
return { app, dependencies };
};
it('creates pairing sessions behind owner auth and returns no-store payload data', async () => {
const { app, dependencies } = createPairingRouteApp();
const response = await request(app)
.post('/api/client-auth/pairing/sessions')
.set('Host', 'runtime.example')
.send({ label: 'Pair phone', allowedClientKinds: ['mobile'] })
.expect(201);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.body.pairing).toMatchObject({ id: 'pair_1', secret: 'secret' });
expect(response.body.server.candidates).toEqual([{ type: 'lan', url: 'http://runtime.example', priority: 10 }]);
expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith({
label: 'Pair phone',
allowedClientKinds: ['mobile'],
createdByClientId: null,
usesRelay: false,
});
});
it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => {
const { app } = createPairingRouteApp();
const response = await request(app)
.post('/api/client-auth/pairing/sessions')
.set('Host', 'runtime.example')
.send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' })
.expect(201);
expect(response.body.server.candidates).toEqual([
{ type: 'lan', url: 'http://192.168.1.20:2606', priority: 10 },
]);
});
it('folds in a relay candidate when the host relay is enabled', async () => {
const relayCandidate = {
type: 'relay',
relayUrl: 'wss://relay.example/ws',
serverId: 'srv_1',
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'bbb' },
priority: 30,
};
const { app } = createPairingRouteApp({ getRelayPairingCandidate: vi.fn(async () => relayCandidate) });
const response = await request(app)
.post('/api/client-auth/pairing/sessions')
.set('Host', 'runtime.example')
.send({ label: 'Pair phone' })
.expect(201);
expect(response.body.server.candidates).toEqual([
{ type: 'lan', url: 'http://runtime.example', priority: 10 },
relayCandidate,
]);
});
it('still returns the direct candidate when the relay candidate lookup throws', async () => {
const { app } = createPairingRouteApp({
getRelayPairingCandidate: vi.fn(async () => { throw new Error('relay status read failed'); }),
});
const response = await request(app)
.post('/api/client-auth/pairing/sessions')
.set('Host', 'runtime.example')
.send({ label: 'Pair phone' })
.expect(201);
expect(response.body.server.candidates).toEqual([{ type: 'lan', url: 'http://runtime.example', priority: 10 }]);
});
it('requires owner auth before creating or cancelling pairing sessions', async () => {
const { app, dependencies } = createPairingRouteApp({
uiAuthController: {
resolveAuthContext: vi.fn(async () => null),
requireAuth: vi.fn((_req, res) => res.status(401).json({ error: 'Unauthorized' })),
requireSessionAuth: vi.fn((_req, res) => res.status(401).json({ error: 'Unauthorized' })),
},
});
await request(app).post('/api/client-auth/pairing/sessions').send({}).expect(401);
await request(app).delete('/api/client-auth/pairing/sessions/pair_1').expect(401);
expect(dependencies.clientPairingRuntime.createPairingSession).not.toHaveBeenCalled();
expect(dependencies.clientPairingRuntime.cancelPairingSession).not.toHaveBeenCalled();
});
it('redeems pairing sessions with no-store response and generic errors', async () => {
const { app, dependencies } = createPairingRouteApp();
const response = await request(app)
.post('/api/client-auth/pairing/redeem')
.set('Host', 'runtime.example')
.send({ pairingId: 'pair_1', secret: 'secret', clientKind: 'mobile', deviceName: 'Phone' })
.expect(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.body).toMatchObject({
ok: true,
server: { label: 'OpenChamber', url: 'http://runtime.example', fingerprint: 'ABCD-1234' },
client: { id: 'client-1', authMethod: 'pairing' },
clientToken: 'oc_client_token',
});
expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledWith(expect.objectContaining({
pairingId: 'pair_1',
secret: 'secret',
clientKind: 'mobile',
deviceName: 'Phone',
}));
dependencies.clientPairingRuntime.redeemPairingSession.mockRejectedValueOnce(new Error('Invalid or expired pairing session'));
await request(app)
.post('/api/client-auth/pairing/redeem')
.send({ pairingId: 'pair_2', secret: 'wrong' })
.expect(400, { error: 'Invalid or expired pairing session' });
});
it('rate limits pairing redeem attempts by socket address and pairingId, then resets after the window', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const { app, dependencies } = createPairingRouteApp();
app.set('trust proxy', true);
dependencies.clientPairingRuntime.redeemPairingSession.mockRejectedValue(new Error('Invalid or expired pairing session'));
// The X-Forwarded-For headers below are deliberate spoof attempts: the rate
// limiter buckets by socket address (not forwarded headers), so rotating the
// header must NOT reset the counter or evade the lockout.
for (let index = 0; index < 10; index += 1) {
await request(app)
.post('/api/client-auth/pairing/redeem')
.set('X-Forwarded-For', `203.0.113.${index}`)
.send({ pairingId: 'pair_rate', secret: `wrong-${index}` })
.expect(400, { error: 'Invalid or expired pairing session' });
}
const locked = await request(app)
.post('/api/client-auth/pairing/redeem')
.set('X-Forwarded-For', '203.0.113.10')
.send({ pairingId: 'pair_rate', secret: 'wrong-locked' })
.expect(429, { error: 'Invalid or expired pairing session' });
expect(locked.headers['retry-after']).toBe('300');
expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledTimes(10);
vi.setSystemTime(new Date('2026-01-01T00:05:01Z'));
await request(app)
.post('/api/client-auth/pairing/redeem')
.set('X-Forwarded-For', '203.0.113.10')
.send({ pairingId: 'pair_rate', secret: 'wrong-after-reset' })
.expect(400, { error: 'Invalid or expired pairing session' });
expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledTimes(11);
});
it('should let preview proxy credentials reach preview proxy validation', async () => {
const app = express();
const requireAuth = vi.fn((_req, res) => res.status(401).type('text/plain').send('Authentication required'));
@@ -364,11 +600,59 @@ describe('client auth routes', () => {
const listedAfterPurge = await request(app).get('/api/client-auth/clients');
expect(listedAfterPurge.body.clients).toHaveLength(0);
expect(dependencies.testHooks.requireSessionAuth).toHaveBeenCalled();
expect(dependencies.testHooks.requireAuth).not.toHaveBeenCalled();
});
it('allows client credentials to list and revoke only the authenticated client', async () => {
it('reports current connection candidates with server identity for paired devices', async () => {
const app = express();
const relayCandidate = {
type: 'relay',
relayUrl: 'wss://relay.example/ws',
serverId: 'server-abc',
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
priority: 30,
};
const dependencies = {
...createDependencies({ resolveAuthContext: async () => ({ type: 'client', clientId: 'client-1' }) }),
getDirectCandidateUrls: () => ['http://192.168.1.20:3000', 'http://10.0.0.5:3000', 'not-a-url'],
getRelayPairingCandidate: async () => relayCandidate,
getServerId: async () => 'server-abc',
getServerLabel: () => 'my-host',
};
registerAuthAndAccessRoutes(app, dependencies);
const response = await request(app).get('/api/client-auth/connection/candidates');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.body.serverId).toBe('server-abc');
expect(response.body.label).toBe('my-host');
expect(response.body.candidates).toEqual([
{ type: 'lan', url: 'http://192.168.1.20:3000', priority: 10 },
{ type: 'lan', url: 'http://10.0.0.5:3000', priority: 10 },
relayCandidate,
]);
});
it('omits serverId and relay candidate when unavailable and survives failures', async () => {
const app = express();
const dependencies = {
...createDependencies(),
getDirectCandidateUrls: () => {
throw new Error('scan failed');
},
getRelayPairingCandidate: async () => {
throw new Error('relay status failed');
},
getServerId: async () => null,
};
registerAuthAndAccessRoutes(app, dependencies);
const response = await request(app).get('/api/client-auth/connection/candidates');
expect(response.status).toBe(200);
expect(response.body).not.toHaveProperty('serverId');
expect(response.body.candidates).toEqual([]);
});
it('scopes non-desktop client credentials to list and revoke only themselves', async () => {
const app = express();
let authContext = { type: 'session' };
const dependencies = createDependencies({
@@ -383,20 +667,57 @@ describe('client auth routes', () => {
.post('/api/client-auth/clients')
.send({ label: 'Other device' });
authContext = { type: 'client', clientId: current.body.client.id, client: current.body.client };
// A regular (non-desktop-local) client token only sees and manages itself.
authContext = { type: 'client', clientId: other.body.client.id, client: other.body.client };
const listed = await request(app).get('/api/client-auth/clients');
expect(listed.status).toBe(200);
expect(listed.body.clients).toEqual([current.body.client]);
expect(listed.body.clients).toEqual([other.body.client]);
const denied = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`);
const denied = await request(app).delete(`/api/client-auth/clients/${current.body.client.id}`);
expect(denied.status).toBe(403);
expect(denied.body.revoked).toBe(false);
const revoked = await request(app).delete(`/api/client-auth/clients/${current.body.client.id}`);
const deniedPurge = await request(app).delete('/api/client-auth/clients');
expect(deniedPurge.status).toBe(403);
const revoked = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`);
expect(revoked.status).toBe(200);
expect(revoked.body.revoked).toBe(true);
expect(revoked.body.client.id).toBe(current.body.client.id);
expect(revoked.body.client.id).toBe(other.body.client.id);
});
it('lets the local desktop client list and revoke every device', async () => {
const app = express();
let authContext = { type: 'session' };
const dependencies = createDependencies({
resolveAuthContext: async () => authContext,
});
registerAuthAndAccessRoutes(app, dependencies);
const desktop = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
const other = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Other device' });
// The trusted desktop shell client manages all devices like a UI session.
authContext = { type: 'client', clientId: desktop.body.client.id, client: desktop.body.client };
const listed = await request(app).get('/api/client-auth/clients');
expect(listed.status).toBe(200);
const listedIds = listed.body.clients.map((client) => client.id).sort();
expect(listedIds).toEqual([desktop.body.client.id, other.body.client.id].sort());
const revoked = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`);
expect(revoked.status).toBe(200);
expect(revoked.body.revoked).toBe(true);
expect(revoked.body.client.id).toBe(other.body.client.id);
const purged = await request(app).delete('/api/client-auth/clients');
expect(purged.status).toBe(200);
expect(purged.body.purged).toBe(1);
});
it('allows only the local desktop client token to create remote client tokens', async () => {
@@ -440,4 +761,34 @@ describe('client auth routes', () => {
expect(dependencies.testHooks.requireSessionAuth).toHaveBeenCalledTimes(2);
expect(dependencies.testHooks.requireAuth).not.toHaveBeenCalled();
});
it('treats private LAN hosts as local even when a tunnel is active', async () => {
const app = express();
const dependencies = createDependencies();
const tunnelAuthController = createTunnelAuth();
tunnelAuthController.setActiveTunnel({ tunnelId: 'tunnel-1', publicUrl: 'https://tunnel.example.com' });
dependencies.tunnelAuthController = tunnelAuthController;
dependencies.uiAuthController.handlePasskeyStatus = vi.fn((_req, res) => {
res.json({ enabled: true, hasPasskeys: true, passkeyCount: 1, rpID: 'example.com' });
});
registerAuthAndAccessRoutes(app, dependencies);
await request(app)
.get('/auth/passkey/status')
.set('Host', '192.168.1.5:57123')
.expect(200, { enabled: true, hasPasskeys: true, passkeyCount: 1, rpID: 'example.com' });
expect(dependencies.uiAuthController.handlePasskeyStatus).toHaveBeenCalledTimes(1);
});
it('does not trust a private Host header from a public socket peer', () => {
const tunnelAuthController = createTunnelAuth();
tunnelAuthController.setActiveTunnel({ tunnelId: 'tunnel-1', publicUrl: 'https://tunnel.example.com' });
expect(tunnelAuthController.classifyRequestScope({
headers: { host: '192.168.1.5:57123' },
socket: { remoteAddress: '203.0.113.10' },
})).toBe('unknown-public');
});
});
+129 -14
View File
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js';
import { mergePathValues } from './path-utils.js';
const SHELL_PROBE_TIMEOUT_MS = 5_000;
@@ -13,6 +14,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
readSettingsFromDiskMigrated,
} = deps;
const runSpawnSync = typeof deps.spawnSync === 'function' ? deps.spawnSync : spawnSync;
const resolveHomeDir = typeof deps.homedir === 'function' ? deps.homedir : () => os.homedir();
const parseNullSeparatedEnvSnapshot = (raw) => {
if (typeof raw !== 'string' || raw.length === 0) {
@@ -88,14 +90,13 @@ export const createOpenCodeEnvRuntime = (deps) => {
return isExecutable(trimmed) ? trimmed : null;
};
const searchPathFor = (binaryName) => {
const searchPathFor = (binaryName, searchPath = process.env.PATH || '') => {
const trimmed = typeof binaryName === 'string' ? binaryName.trim() : '';
if (!trimmed) {
return null;
}
const current = process.env.PATH || '';
const parts = current.split(path.delimiter).filter(Boolean);
const parts = searchPath.split(path.delimiter).filter(Boolean);
const candidateNames = [];
if (process.platform === 'win32' && !path.extname(trimmed)) {
@@ -230,12 +231,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
};
const applyLoginShellEnvSnapshot = () => {
// Always clear AppImage ARGV0, even when no login-shell snapshot is available.
// Otherwise a leaked process.env.ARGV0 survives into later child spawns (#2588).
clearAppImageArgv0FromProcessEnv();
const snapshot = getLoginShellEnvSnapshot();
if (!snapshot) {
return;
}
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'ARGV0']);
for (const [key, value] of Object.entries(snapshot)) {
if (skipKeys.has(key)) {
continue;
@@ -263,6 +268,72 @@ export const createOpenCodeEnvRuntime = (deps) => {
return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed);
};
const isWindowsOpenCodeDesktopAppPath = (candidate) => {
if (process.platform !== 'win32' || typeof candidate !== 'string') {
return false;
}
const normalized = path.resolve(candidate).toLowerCase();
const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim()
? path.resolve(process.env.LOCALAPPDATA).toLowerCase()
: '';
if (!localAppData || !normalized.startsWith(`${localAppData}${path.sep}`)) {
return false;
}
return normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`);
};
const bundledOpenCodeCliCandidates = () => {
const names = process.platform === 'win32' ? ['opencode.exe'] : ['opencode'];
const roots = [
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR,
typeof process.resourcesPath === 'string' ? path.join(process.resourcesPath, 'opencode-cli') : null,
]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
const candidates = [];
for (const root of roots) {
for (const name of names) {
candidates.push(path.join(root, name));
}
}
return candidates;
};
const resolveBundledOpenCodeCliPath = () => {
for (const candidate of bundledOpenCodeCliCandidates()) {
if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) {
return candidate;
}
}
return null;
};
const canonicalExecutablePath = (candidate) => {
if (typeof candidate !== 'string' || !candidate.trim()) return null;
try {
return fs.realpathSync.native(candidate.trim());
} catch {
return path.resolve(candidate.trim());
}
};
const isBundledOpenCodeCliPath = (candidate) => {
const canonicalCandidate = canonicalExecutablePath(candidate);
if (!canonicalCandidate) return false;
return bundledOpenCodeCliCandidates().some((bundledCandidate) => (
canonicalExecutablePath(bundledCandidate) === canonicalCandidate
));
};
const bundledOpenCodeCliFallback = () => {
const bundled = resolveBundledOpenCodeCliPath();
if (!bundled) return null;
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'bundled';
return bundled;
};
const clearWslOpencodeResolution = () => {
state.useWslForOpencode = false;
state.resolvedWslBinary = null;
@@ -270,6 +341,19 @@ export const createOpenCodeEnvRuntime = (deps) => {
state.resolvedWslDistro = null;
};
// Strip a single wrapping quote pair (Windows "Copy as path" and quoted
// shell snippets) — literal quotes are never part of a real path and break
// every executable check.
const stripWrappingQuotes = (value) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
const resolveOpencodeCliPath = () => {
const explicit = [
process.env.OPENCODE_BINARY,
@@ -277,17 +361,20 @@ export const createOpenCodeEnvRuntime = (deps) => {
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.map(stripWrappingQuotes)
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'env';
return candidate;
}
}
const bundled = bundledOpenCodeCliFallback();
if (bundled) return bundled;
const resolvedFromPath = searchPathFor('opencode');
if (resolvedFromPath) {
clearWslOpencodeResolution();
@@ -295,7 +382,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
return resolvedFromPath;
}
const home = os.homedir();
const home = resolveHomeDir();
const unixFallbacks = [
path.join(home, '.opencode', 'bin', 'opencode'),
path.join(home, '.bun', 'bin', 'opencode'),
@@ -303,6 +390,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
path.join(home, 'bin', 'opencode'),
'/opt/homebrew/bin/opencode',
'/usr/local/bin/opencode',
'/home/linuxbrew/.linuxbrew/bin/opencode',
'/usr/bin/opencode',
'/bin/opencode',
];
@@ -313,16 +401,21 @@ export const createOpenCodeEnvRuntime = (deps) => {
const localAppData = process.env.LOCALAPPDATA || '';
const programData = process.env.ProgramData || 'C:\\ProgramData';
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
return [
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
path.join(appData, 'npm', 'opencode.cmd'),
// System-wide Node installer keeps the global npm prefix here
// (npm i -g opencode-ai → opencode.cmd shim).
path.join(programFiles, 'nodejs', 'opencode.cmd'),
path.join(userProfile, 'scoop', 'shims', 'opencode.exe'),
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
].filter(Boolean);
})();
@@ -347,7 +440,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
const found = lines.find((line) => isExecutable(line) && !isWindowsOpenCodeDesktopAppPath(line));
if (found) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'where';
@@ -653,8 +746,15 @@ export const createOpenCodeEnvRuntime = (deps) => {
};
const getWindowsNativeOpencodePackageNames = () => {
// TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
// FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
// prepare-opencode-cli.mjs bundles x64-baseline instead; match that here so
// the runtime resolver looks for the same x64-baseline package. Restore the
// arm64 branch below when the upstream issue is resolved.
if (process.arch === 'arm64') {
return ['opencode-windows-arm64'];
// --- ORIGINAL (restore when ARM64 is fixed) ---
// return ['opencode-windows-arm64'];
return ['opencode-windows-x64-baseline', 'opencode-windows-x64'];
}
if (process.arch === 'x64') {
// Prefer the baseline build when bypassing package-manager wrappers so the
@@ -843,6 +943,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
}
}
// Final fallback: never hand a raw .cmd/.bat to spawn(shell:false) — cmd
// shims need cmd.exe, and unquoted space-containing paths break there.
if (WINDOWS_BATCH_EXTENSIONS.has(ext)) {
return {
binary: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', 'call', fallbackBinary],
wrapperType: 'cmd-wrapper',
};
}
return { binary: fallbackBinary, args: [], wrapperType: null };
};
@@ -898,16 +1008,20 @@ export const createOpenCodeEnvRuntime = (deps) => {
if (process.platform !== 'darwin' || typeof candidate !== 'string') {
return false;
}
return /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
return /\/OpenCode(?: Dev| Beta)?\.app\/Contents\/MacOS\/(?:OpenCode(?: Dev| Beta)?|opencode-cli)$/i.test(candidate);
};
const isKnownOpenCodeDesktopAppPath = (candidate) => isMacOpenCodeAppBundlePath(candidate)
|| isWindowsOpenCodeDesktopAppPath(candidate);
const createConfiguredOpencodeBinaryError = (raw, normalized) => {
const configured = typeof raw === 'string' ? raw.trim() : '';
const candidate = typeof normalized === 'string' && normalized.trim().length > 0 ? normalized.trim() : configured;
const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set settings.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.';
const error = (() => {
if (isMacOpenCodeAppBundlePath(candidate) || isMacOpenCodeAppBundlePath(configured)) {
return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${candidate}. ${messageSuffix}`);
if (isKnownOpenCodeDesktopAppPath(candidate) || isKnownOpenCodeDesktopAppPath(configured)) {
const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle';
return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${candidate}. ${messageSuffix}`);
}
try {
@@ -1004,7 +1118,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
return null;
}
if (normalized && isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) {
if (normalized && isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) {
clearWslOpencodeResolution();
process.env.OPENCODE_BINARY = normalized;
prependToPath(path.dirname(normalized));
@@ -1156,6 +1270,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
applyOpencodeBinaryFromSettings,
getLoginShellEnvSnapshot,
resolveOpencodeCliPath,
isBundledOpenCodeCliPath,
resolveManagedOpenCodeLaunchSpec,
isExecutable,
searchPathFor,
@@ -7,7 +7,10 @@ import { createOpenCodeEnvRuntime } from './env-runtime.js';
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
const originalComSpec = process.env.ComSpec;
const originalPath = process.env.PATH;
const originalLocalAppData = process.env.LOCALAPPDATA;
const originalSystemRoot = process.env.SystemRoot;
const originalBundledOpencodeCliDir = process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
const originalResourcesPath = process.resourcesPath;
const originalWslBinary = process.env.WSL_BINARY;
const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
const originalPlatform = process.platform;
@@ -59,6 +62,23 @@ afterEach(() => {
delete process.env.SystemRoot;
}
if (typeof originalLocalAppData === 'string') {
process.env.LOCALAPPDATA = originalLocalAppData;
} else {
delete process.env.LOCALAPPDATA;
}
if (typeof originalBundledOpencodeCliDir === 'string') {
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = originalBundledOpencodeCliDir;
} else {
delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
}
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: originalResourcesPath,
});
if (typeof originalWslBinary === 'string') {
process.env.WSL_BINARY = originalWslBinary;
} else {
@@ -91,12 +111,63 @@ const createRuntime = (settings, options = {}) => {
normalizeDirectoryPath: (value) => value,
readSettingsFromDiskMigrated: async () => settings,
spawnSync: options.spawnSync,
homedir: options.homedir,
});
return { runtime, state };
};
describe('OpenCode env runtime', () => {
it('searches an explicit PATH without mutating the process environment', () => {
const defaultDir = createTempDir('openchamber-default-path-');
const explicitDir = createTempDir('openchamber-explicit-path-');
const binary = path.join(explicitDir, process.platform === 'win32' ? 'custom-shell.exe' : 'custom-shell');
fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') fs.chmodSync(binary, 0o755);
process.env.PATH = defaultDir;
const { runtime } = createRuntime({});
expect(runtime.searchPathFor('custom-shell', explicitDir)).toBe(binary);
expect(process.env.PATH).toBe(defaultDir);
});
it('clears AppImage ARGV0 when applying a login-shell env snapshot', () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER;
const { runtime, state } = createRuntime({});
state.cachedLoginShellEnvSnapshot = {
PATH: '/usr/bin',
ARGV0: '/leaked/from/shell.AppImage',
OPENCHAMBER_ARGV0_TEST_MARKER: '1',
};
try {
runtime.applyLoginShellEnvSnapshot();
expect(process.env.ARGV0).toBeUndefined();
expect(process.env.OPENCHAMBER_ARGV0_TEST_MARKER).toBe('1');
} finally {
delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER;
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
}
});
it('clears AppImage ARGV0 even when no login-shell snapshot is available', () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
const { runtime, state } = createRuntime({});
state.cachedLoginShellEnvSnapshot = null;
try {
runtime.applyLoginShellEnvSnapshot();
expect(process.env.ARGV0).toBeUndefined();
} finally {
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
}
});
it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => {
const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' });
@@ -129,6 +200,83 @@ describe('OpenCode env runtime', () => {
expect(state.resolvedOpencodeBinarySource).toBe('settings');
});
it('prefers the bundled CLI over a user-installed OpenCode from PATH', () => {
const bundledDir = createTempDir('openchamber-bundled-opencode-');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
const pathDir = createTempDir('openchamber-path-opencode-');
const pathBinary = path.join(pathDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
fs.writeFileSync(pathBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
fs.chmodSync(pathBinary, 0o755);
}
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
process.env.PATH = pathDir;
delete process.env.OPENCODE_BINARY;
const { runtime, state } = createRuntime({});
expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary);
expect(state.resolvedOpencodeBinarySource).toBe('bundled');
});
it('recognizes the bundled CLI by canonical path', () => {
const bundledDir = createTempDir('openchamber-bundled-opencode-');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') fs.chmodSync(bundledBinary, 0o755);
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
const { runtime } = createRuntime({});
expect(runtime.isBundledOpenCodeCliPath(bundledBinary)).toBe(true);
expect(runtime.isBundledOpenCodeCliPath(path.join(bundledDir, 'other'))).toBe(false);
});
it('keeps explicit OpenCode binary ahead of bundled CLI', () => {
const bundledDir = createTempDir('openchamber-bundled-opencode-');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
const explicitDir = createTempDir('openchamber-explicit-opencode-');
const explicitBinary = path.join(explicitDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
fs.writeFileSync(explicitBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
fs.chmodSync(explicitBinary, 0o755);
}
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
process.env.OPENCODE_BINARY = explicitBinary;
const { runtime, state } = createRuntime({});
expect(runtime.resolveOpencodeCliPath()).toBe(explicitBinary);
expect(state.resolvedOpencodeBinarySource).toBe('env');
});
it('resolves the bundled OpenCode CLI from Electron resourcesPath', () => {
const resourcesPath = createTempDir('openchamber-resources-');
const bundledDir = path.join(resourcesPath, 'opencode-cli');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
}
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: resourcesPath,
});
process.env.PATH = createTempDir('openchamber-empty-path-');
delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
delete process.env.OPENCODE_BINARY;
const emptyHome = createTempDir('openchamber-empty-home-');
const { runtime, state } = createRuntime({}, {
spawnSync: () => ({ status: 1, stdout: '', stderr: '' }),
homedir: () => emptyHome,
});
expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary);
expect(state.resolvedOpencodeBinarySource).toBe('bundled');
});
itIf(process.platform === 'darwin')('rejects known macOS OpenCode app bundle executable paths', async () => {
const { runtime } = createRuntime({ opencodeBinary: '/Applications/OpenCode.app/Contents/MacOS/OpenCode' });
@@ -138,6 +286,58 @@ describe('OpenCode env runtime', () => {
});
});
it('rejects known Windows OpenCode desktop app install paths', async () => {
setPlatform('win32');
const localAppData = createTempDir('openchamber-localappdata-');
const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe');
fs.mkdirSync(path.dirname(desktopBinary), { recursive: true });
fs.writeFileSync(desktopBinary, '');
process.env.LOCALAPPDATA = localAppData;
const { runtime } = createRuntime({ opencodeBinary: desktopBinary });
await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).rejects.toMatchObject({
code: 'OPENCODE_BINARY_INVALID',
message: expect.stringContaining('Windows desktop app install'),
});
});
it('does not auto-detect the Windows OpenCode desktop app as a CLI', () => {
setPlatform('win32');
const localAppData = createTempDir('openchamber-localappdata-');
const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe');
fs.mkdirSync(path.dirname(desktopBinary), { recursive: true });
fs.writeFileSync(desktopBinary, '');
process.env.LOCALAPPDATA = localAppData;
process.env.PATH = createTempDir('openchamber-empty-path-');
process.env.SystemRoot = createTempDir('openchamber-empty-systemroot-');
delete process.env.OPENCODE_BINARY;
const { runtime } = createRuntime({}, {
spawnSync: () => ({ status: 1, stdout: '', stderr: '' }),
});
expect(runtime.resolveOpencodeCliPath()).toBeNull();
});
it('skips Windows OpenCode desktop app entries returned by where.exe', () => {
setPlatform('win32');
const localAppData = createTempDir('openchamber-localappdata-');
const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe');
const cliBinary = path.join(createTempDir('openchamber-cli-'), 'opencode.exe');
fs.mkdirSync(path.dirname(desktopBinary), { recursive: true });
fs.writeFileSync(desktopBinary, '');
fs.writeFileSync(cliBinary, '');
process.env.LOCALAPPDATA = localAppData;
process.env.PATH = createTempDir('openchamber-empty-path-');
process.env.SystemRoot = createTempDir('openchamber-empty-systemroot-');
delete process.env.OPENCODE_BINARY;
const { runtime, state } = createRuntime({}, {
spawnSync: () => ({ status: 0, stdout: `${desktopBinary}\r\n${cliBinary}\r\n`, stderr: '' }),
});
expect(runtime.resolveOpencodeCliPath()).toBe(cliBinary);
expect(state.resolvedOpencodeBinarySource).toBe('where');
});
it('rejects WSL settings in strict mode', async () => {
setPlatform('win32');
const dir = createTempDir('openchamber-no-wsl-');
@@ -1,19 +1,25 @@
import { registerFsRoutes } from '../fs/routes.js';
import { registerQuotaRoutes } from '../quota/routes.js';
import { registerSmallModelRoutes } from '../small-model/routes.js';
import { registerWalkthroughRoutes } from '../walkthrough/routes.js';
import { registerSessionGoalRoutes } from '../session-goal/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
import { registerConfigEntityRoutes } from './config-entity-routes.js';
import { registerSettingsUtilityRoutes } from './core-routes.js';
import { registerProjectIconRoutes } from './project-icon-routes.js';
import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js';
import { registerOpenChamberSessionRoutes } from '../openchamber-sessions/routes.js';
import { registerOpenChamberControlRoutes } from '../openchamber-control/routes.js';
import { registerSkillRoutes } from './skill-routes.js';
import { registerPluginRoutes } from './plugin-routes.js';
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
import { registerOpenCodeRoutes } from './routes.js';
import { getProviderSources, removeProviderConfig } from './providers.js';
import { getProviderSources, removeProviderConfig, upsertProviderConfig } from './providers.js';
import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js';
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
@@ -32,7 +38,7 @@ import {
decodePluginId,
} from './plugins.js';
import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js';
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.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';
@@ -54,6 +60,26 @@ export const createFeatureRoutesRuntime = (dependencies) => {
return quotaProviders;
};
let smallModelService = null;
const getSmallModelService = async () => {
if (!smallModelService) {
smallModelService = await import('../small-model/index.js');
}
return smallModelService;
};
let walkthroughService = null;
const getWalkthroughService = async () => {
if (!walkthroughService) {
const [service, pullRequest] = await Promise.all([
import('../walkthrough/index.js'),
import('../walkthrough/pull-request.js'),
]);
walkthroughService = { ...service, getPullRequestDiff: pullRequest.getPullRequestDiff };
}
return walkthroughService;
};
const registerRoutes = async (app, routeDependencies) => {
const {
crypto,
@@ -73,6 +99,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
readCustomThemesFromDisk,
refreshOpenCodeAfterConfigChange,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
@@ -86,8 +113,14 @@ export const createFeatureRoutesRuntime = (dependencies) => {
buildAugmentedPath,
projectConfigRuntime,
scheduledTasksRuntime,
scheduledTaskService,
openChamberSessionService,
openChamberControlService,
waitForOpenCodeReady,
getOpenChamberEventClients,
writeSseEvent,
emitSessionCreatedEvent,
permissionAutoAcceptRuntime,
} = routeDependencies;
registerSettingsUtilityRoutes(app, {
@@ -96,10 +129,13 @@ export const createFeatureRoutesRuntime = (dependencies) => {
clientReloadDelayMs,
});
registerPermissionAutoAcceptRoutes(app, permissionAutoAcceptRuntime);
registerOpenCodeRoutes(app, {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
@@ -109,6 +145,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
refreshOpenCodeAfterConfigChange,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -132,10 +169,24 @@ export const createFeatureRoutesRuntime = (dependencies) => {
sanitizeProjects,
projectConfigRuntime,
scheduledTasksRuntime,
scheduledTaskService,
getOpenChamberEventClients,
writeSseEvent,
});
registerOpenChamberSessionRoutes(app, {
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
emitSessionCreatedEvent,
sessionService: openChamberSessionService,
});
registerOpenChamberControlRoutes(app, { controlService: openChamberControlService });
registerConfigEntityRoutes(app, {
resolveProjectDirectory,
resolveOptionalProjectDirectory,
@@ -206,6 +257,8 @@ export const createFeatureRoutesRuntime = (dependencies) => {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -226,6 +279,9 @@ export const createFeatureRoutesRuntime = (dependencies) => {
});
registerQuotaRoutes(app, { getQuotaProviders });
registerSmallModelRoutes(app, { getSmallModelService });
registerWalkthroughRoutes(app, { getWalkthroughService });
registerSessionGoalRoutes(app);
registerGitHubRoutes(app);
registerGitRoutes(app);
registerMagicPromptRoutes(app, {
+141 -17
View File
@@ -1,6 +1,8 @@
import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import { stripAppImageArgv0Leak } from '../inherited-env.js';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
import { recordStartupPerformance } from './startup-performance.js';
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
@@ -15,6 +17,10 @@ const HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES = parsePositiveInt(
const HEALTH_CHECK_INTERVAL_OVERRIDE_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_INTERVAL_MS, 0);
const HEALTH_CHECK_RESULT_CACHE_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_CACHE_MS, 750);
const OPENCODE_HEALTH_PATH = '/global/health';
// Last-used directory plus the three most recently opened projects — deeper
// 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;
export const createOpenCodeLifecycleRuntime = (deps) => {
const {
@@ -38,7 +44,11 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
buildAugmentedPath,
buildManagedOpenCodePath,
getManagedOpenCodeShellEnvSnapshot,
getManagedOpenCodeEnv = async () => ({}),
getActiveSessionCount = () => 0,
reapManagedOrphanedProcesses = reapOrphanedProcesses,
getWarmupDirectories = async () => [],
now = Date.now,
} = deps;
const killProcessOnPort = (port) => {
@@ -60,7 +70,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
}
};
const hasChildProcessExited = (child) => !child || child.exitCode !== null || child.signalCode !== null;
const hasChildProcessExited = (child) => !child
|| (child.exitCode !== null && child.exitCode !== undefined)
|| (child.signalCode !== null && child.signalCode !== undefined);
const isManagedOpenCodeProcessAlive = () => {
const child = state.openCodeProcess;
@@ -239,6 +251,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const sourceBinary = binary;
let args = ['serve', '--hostname', hostname, '--port', String(port)];
let launchWrapperType = null;
@@ -262,6 +275,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0;
state.lastOpenCodeLaunchDiagnostics = {
launchedAt: new Date().toISOString(),
sourceBinary,
binary,
args,
cwd,
@@ -354,6 +368,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
return {
url,
pid: child.pid || null,
get exitCode() {
return child.exitCode;
},
get signalCode() {
return child.signalCode;
},
async close() {
await closeManagedOpenCodeChild(child);
},
@@ -462,7 +482,10 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const startOpenCodeOnce = async () => {
const startOpenCodeOnce = async (attempt) => {
const attemptStartedAt = performance.now();
let phaseStartedAt = attemptStartedAt;
recordStartupPerformance('opencode.attempt.start', { attempt });
const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0;
const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME);
console.log(
@@ -473,15 +496,29 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
await applyOpencodeBinaryFromSettings({ strict: true });
ensureOpencodeCliEnv();
recordStartupPerformance('opencode.binary.ready', {
attempt,
durationMs: performance.now() - phaseStartedAt,
totalDurationMs: performance.now() - attemptStartedAt,
});
phaseStartedAt = performance.now();
const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true });
const envPath = typeof buildManagedOpenCodePath === 'function'
? buildManagedOpenCodePath()
: typeof buildAugmentedPath === 'function'
? buildAugmentedPath()
: process.env.PATH;
let envPath = process.env.PATH;
if (typeof buildManagedOpenCodePath === 'function') {
envPath = buildManagedOpenCodePath();
} else if (typeof buildAugmentedPath === 'function') {
envPath = buildAugmentedPath();
}
const shellEnv = typeof getManagedOpenCodeShellEnvSnapshot === 'function'
? getManagedOpenCodeShellEnvSnapshot() || {}
: {};
const managedOpenCodeEnv = await getManagedOpenCodeEnv();
recordStartupPerformance('opencode.environment.ready', {
attempt,
durationMs: performance.now() - phaseStartedAt,
totalDurationMs: performance.now() - attemptStartedAt,
});
phaseStartedAt = performance.now();
try {
const serverInstance = await createManagedOpenCodeServerProcess({
@@ -490,17 +527,24 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
timeout: 30000,
cwd: state.openCodeWorkingDirectory,
shellEnvKeysCount: Object.keys(shellEnv).length,
env: {
env: stripAppImageArgv0Leak({
...shellEnv,
...process.env,
...managedOpenCodeEnv,
PATH: envPath,
OPENCODE_SERVER_PASSWORD: openCodePassword,
},
}),
});
if (!serverInstance || !serverInstance.url) {
throw new Error('OpenCode server started but URL is missing');
}
recordStartupPerformance('opencode.process.ready', {
attempt,
durationMs: performance.now() - phaseStartedAt,
totalDurationMs: performance.now() - attemptStartedAt,
});
phaseStartedAt = performance.now();
const url = new URL(serverInstance.url);
const port = parseInt(url.port, 10);
@@ -514,6 +558,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
recordStartupPerformance('opencode.health.ready', {
attempt,
durationMs: performance.now() - phaseStartedAt,
totalDurationMs: performance.now() - attemptStartedAt,
outcome: 'ready',
});
return serverInstance;
}
@@ -527,6 +578,11 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
state.lastOpenCodeError = message;
state.openCodePort = null;
syncToHmrState();
recordStartupPerformance('opencode.attempt.error', {
attempt,
totalDurationMs: performance.now() - attemptStartedAt,
outcome: 'error',
});
console.error(`Failed to start OpenCode: ${message}`);
throw error;
}
@@ -536,7 +592,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
let lastError = null;
for (let attempt = 1; attempt <= START_OPEN_CODE_MAX_ATTEMPTS; attempt += 1) {
try {
return await startOpenCodeOnce();
return await startOpenCodeOnce(attempt);
} catch (error) {
lastError = error;
if (error?.code === 'OPENCODE_BINARY_INVALID') {
@@ -785,12 +841,20 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
};
const bootstrapOpenCodeAtStartup = async () => {
const bootstrapStartedAt = performance.now();
let bootstrapError = null;
recordStartupPerformance('opencode.bootstrap.start');
try {
// Before doing anything, reap any OpenCode process WE spawned in a prior
// run that was orphaned by a crash/hard-exit. Verified + scoped to our own
// pids, so it never touches a live instance's or the user's own server.
try {
const { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) });
const orphanReapStartedAt = performance.now();
const { reaped } = await reapManagedOrphanedProcesses({ log: (msg) => console.log(msg) });
recordStartupPerformance('opencode.orphan-reap.ready', {
durationMs: performance.now() - orphanReapStartedAt,
totalDurationMs: performance.now() - bootstrapStartedAt,
});
if (reaped > 0) console.log(`[lifecycle] startup reaped ${reaped} orphaned OpenCode process(es)`);
} catch (error) {
console.warn('[lifecycle] orphan reap failed:', error?.message ?? error);
@@ -844,13 +908,64 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
try {
await waitForOpenCodeReady();
} catch (error) {
bootstrapError = error;
console.error(`OpenCode readiness check failed: ${error.message}`);
}
} catch (error) {
bootstrapError = error;
console.error(`Failed to start OpenCode: ${error.message}`);
console.log('Continuing without OpenCode integration...');
state.lastOpenCodeError = error.message;
}
recordStartupPerformance(
bootstrapError ? 'opencode.bootstrap.error' : 'opencode.bootstrap.ready',
{
totalDurationMs: performance.now() - bootstrapStartedAt,
outcome: bootstrapError ? 'error' : 'ready',
},
);
if (!bootstrapError) {
void warmOpenCodeDirectories();
}
};
// OpenCode initializes each project directory lazily on its first
// directory-scoped request, and that initialization takes seconds on large
// session stores. Without warming, the user's first session open pays it
// interactively (the chat waits on the message fetch until the directory
// finishes initializing). Warm the most recently used directories right
// after readiness so the work overlaps UI startup instead. Sequential and
// best-effort: a failed or slow directory never blocks the others for long,
// and a restart invalidates the pass via the port/readiness guard.
const warmOpenCodeDirectories = async () => {
let directories = [];
try {
directories = await getWarmupDirectories();
} catch {
return;
}
if (!Array.isArray(directories) || directories.length === 0) return;
const warmedPort = state.openCodePort;
for (const directory of directories.slice(0, WARMUP_DIRECTORY_LIMIT)) {
if (typeof directory !== 'string' || !directory) continue;
if (!state.isOpenCodeReady || state.openCodePort !== warmedPort) return;
let timeout = null;
try {
const controller = new AbortController();
timeout = setTimeout(() => controller.abort(), WARMUP_REQUEST_TIMEOUT_MS);
const url = `${buildOpenCodeUrl('/session/status', '')}?directory=${encodeURIComponent(directory)}`;
await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
signal: controller.signal,
});
} catch {
// Best-effort — the directory stays lazy and the UI's own request warms it.
} finally {
if (timeout) clearTimeout(timeout);
}
}
};
/**
@@ -867,18 +982,21 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
const STALE_BUSY_GRACE_MS = 2 * 60 * 1000;
let lastUnhealthyWithBusySessionsAt = 0;
let consecutiveHealthFailures = 0;
let lastCountedHealthFailureAt = 0;
let healthProbePromise = null;
let healthCheckCyclePromise = null;
let lastHealthProbeResult = null;
let healthFailureCountIntervalMs = 15_000;
const resetHealthFailureState = () => {
consecutiveHealthFailures = 0;
lastUnhealthyWithBusySessionsAt = 0;
lastCountedHealthFailureAt = 0;
};
const probeOpenCodeHealth = async () => {
const now = Date.now();
if (lastHealthProbeResult && now - lastHealthProbeResult.at < HEALTH_CHECK_RESULT_CACHE_MS) {
const checkedAt = now();
if (lastHealthProbeResult && checkedAt - lastHealthProbeResult.at < HEALTH_CHECK_RESULT_CACHE_MS) {
return lastHealthProbeResult.healthy;
}
@@ -888,7 +1006,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
healthProbePromise = isOpenCodeProcessHealthy()
.then((healthy) => {
lastHealthProbeResult = { at: Date.now(), healthy };
lastHealthProbeResult = { at: now(), healthy };
return healthy;
})
.finally(() => {
@@ -905,13 +1023,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
return false;
}
const now = Date.now();
const checkedAt = now();
if (!lastUnhealthyWithBusySessionsAt) {
lastUnhealthyWithBusySessionsAt = now;
lastUnhealthyWithBusySessionsAt = checkedAt;
return true;
}
if (now - lastUnhealthyWithBusySessionsAt >= STALE_BUSY_GRACE_MS) {
if (checkedAt - lastUnhealthyWithBusySessionsAt >= STALE_BUSY_GRACE_MS) {
console.warn(
`[lifecycle] OpenCode unhealthy with ${activeCount} busy session(s) for > 2 min — forcing restart`
);
@@ -936,6 +1054,11 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
await restartOpenCode();
return;
}
const checkedAt = now();
if (lastCountedHealthFailureAt && checkedAt - lastCountedHealthFailureAt < healthFailureCountIntervalMs) {
return;
}
lastCountedHealthFailureAt = checkedAt;
consecutiveHealthFailures += 1;
console.warn(
`[lifecycle] ${source} health check failed (${consecutiveHealthFailures}/${HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES})`
@@ -970,6 +1093,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
}
const effectiveIntervalMs = HEALTH_CHECK_INTERVAL_OVERRIDE_MS || healthCheckIntervalMs;
healthFailureCountIntervalMs = effectiveIntervalMs;
state.healthCheckInterval = setInterval(async () => {
try {
@@ -2,19 +2,26 @@ import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
const spawnMock = vi.fn();
const recordStartupPerformanceMock = vi.fn();
vi.mock('node:child_process', () => ({
spawn: spawnMock,
spawnSync: vi.fn(),
}));
vi.mock('./startup-performance.js', () => ({
recordStartupPerformance: recordStartupPerformanceMock,
}));
const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js');
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
const originalPath = process.env.PATH;
const originalFetch = globalThis.fetch;
afterEach(() => {
spawnMock.mockReset();
recordStartupPerformanceMock.mockReset();
globalThis.fetch = originalFetch;
if (typeof originalOpencodeBinary === 'string') {
process.env.OPENCODE_BINARY = originalOpencodeBinary;
} else {
@@ -43,7 +50,7 @@ const createMockChild = () => {
return child;
};
const createRuntime = (overrides = {}) => {
const createRuntime = (overrides = {}, stateOverrides = {}) => {
const state = {
openCodeWorkingDirectory: '/tmp/project',
openCodeProcess: null,
@@ -65,6 +72,7 @@ const createRuntime = (overrides = {}) => {
resolvedWslBinary: null,
resolvedWslOpencodePath: null,
resolvedWslDistro: null,
...stateOverrides,
};
return createOpenCodeLifecycleRuntime({
@@ -105,6 +113,179 @@ const createRuntime = (overrides = {}) => {
};
describe('OpenCode lifecycle', () => {
it('records an authoritative ready terminal event for external startup', async () => {
globalThis.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ healthy: true }),
}));
const runtime = createRuntime({
env: {
ENV_CONFIGURED_OPENCODE_PORT: 45678,
ENV_CONFIGURED_OPENCODE_HOST: null,
ENV_EFFECTIVE_PORT: 45678,
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
ENV_SKIP_OPENCODE_START: true,
},
reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })),
});
await runtime.bootstrapOpenCodeAtStartup();
expect(recordStartupPerformanceMock).toHaveBeenCalledWith('opencode.bootstrap.ready', {
totalDurationMs: expect.any(Number),
outcome: 'ready',
});
expect(recordStartupPerformanceMock).not.toHaveBeenCalledWith(
'opencode.bootstrap.error',
expect.anything(),
);
const terminalEvents = recordStartupPerformanceMock.mock.calls.filter(([phase]) => (
phase === 'opencode.bootstrap.ready' || phase === 'opencode.bootstrap.error'
));
expect(terminalEvents).toHaveLength(1);
});
it('warms recently used directories after a successful bootstrap', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ healthy: true }),
}));
globalThis.fetch = fetchMock;
const runtime = createRuntime({
env: {
ENV_CONFIGURED_OPENCODE_PORT: 45678,
ENV_CONFIGURED_OPENCODE_HOST: null,
ENV_EFFECTIVE_PORT: 45678,
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
ENV_SKIP_OPENCODE_START: true,
},
reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })),
getWarmupDirectories: vi.fn(async () => ['/tmp/worktree-a', '/tmp/project-b']),
});
await runtime.bootstrapOpenCodeAtStartup();
await new Promise((resolve) => setTimeout(resolve, 0));
const warmupUrls = fetchMock.mock.calls
.map(([url]) => String(url))
.filter((url) => url.includes('/session/status'));
expect(warmupUrls).toEqual([
'http://127.0.0.1:45678/session/status?directory=%2Ftmp%2Fworktree-a',
'http://127.0.0.1:45678/session/status?directory=%2Ftmp%2Fproject-b',
]);
});
it('records an authoritative error terminal event when bootstrap fails', async () => {
const runtime = createRuntime({
syncFromHmrState: vi.fn(() => {
throw new Error('bootstrap failed');
}),
reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })),
});
await runtime.bootstrapOpenCodeAtStartup();
expect(recordStartupPerformanceMock).toHaveBeenCalledWith('opencode.bootstrap.error', {
totalDurationMs: expect.any(Number),
outcome: 'error',
});
expect(recordStartupPerformanceMock).not.toHaveBeenCalledWith(
'opencode.bootstrap.ready',
expect.anything(),
);
const terminalEvents = recordStartupPerformanceMock.mock.calls.filter(([phase]) => (
phase === 'opencode.bootstrap.ready' || phase === 'opencode.bootstrap.error'
));
expect(terminalEvents).toHaveLength(1);
});
it('does not count rapid transport-triggered checks as independent health failures', async () => {
const close = vi.fn(async () => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
let now = 1;
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
const runtime = createRuntime({ now: () => now }, {
openCodePort: 45678,
openCodeProcess: {
pid: null,
exitCode: null,
signalCode: null,
close,
},
isOpenCodeReady: true,
});
for (let attempt = 0; attempt < 25; attempt += 1) {
await runtime.triggerHealthCheck();
}
expect(close).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
now += 15_000;
await runtime.triggerHealthCheck();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenLastCalledWith(expect.stringContaining('(2/20)'));
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(() => {});
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
const runtime = createRuntime({}, {
openCodePort: 45678,
openCodeProcess: {
pid: process.pid,
close,
},
isOpenCodeReady: true,
});
await runtime.triggerHealthCheck();
expect(close).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('(1/20)'));
warn.mockRestore();
});
it('restarts an exited managed process without waiting for the failure interval', async () => {
const close = vi.fn(async () => {});
const replacement = createMockChild();
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return replacement;
});
const runtime = createRuntime({}, {
openCodePort: 45678,
openCodeProcess: {
pid: null,
exitCode: 1,
signalCode: null,
close,
},
});
await runtime.triggerHealthCheck();
expect(close).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('launches managed OpenCode with the managed PATH', async () => {
delete process.env.OPENCODE_BINARY;
const child = createMockChild();
@@ -124,6 +305,71 @@ describe('OpenCode lifecycle', () => {
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
expect(options.env.SHELL_ONLY).toBe('yes');
expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
expect(server.exitCode).toBeNull();
expect(server.signalCode).toBeNull();
await server.close();
expect(server.signalCode).toBe('SIGTERM');
});
it('strips AppImage ARGV0 from managed OpenCode launch env', async () => {
delete process.env.OPENCODE_BINARY;
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
const child = createMockChild();
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return child;
});
try {
const runtime = createRuntime({
getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({
PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin',
ARGV0: '/leaked/from/shell/snapshot.AppImage',
SHELL_ONLY: 'yes',
})),
});
const server = await runtime.startOpenCode();
const [, , options] = spawnMock.mock.calls[0];
expect(options.env).not.toHaveProperty('ARGV0');
expect(options.env.SHELL_ONLY).toBe('yes');
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
await server.close();
} finally {
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
}
});
it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => {
const child = createMockChild();
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return child;
});
const getManagedOpenCodeEnv = vi.fn(async () => ({
OPENCODE_CONFIG_CONTENT: '{"plugin":["file:///tool.js"]}',
OPENCHAMBER_AGENT_TOOL_TOKEN: 'ephemeral',
PATH: '/untrusted/path',
OPENCODE_SERVER_PASSWORD: 'untrusted-password',
}));
const runtime = createRuntime({ getManagedOpenCodeEnv });
const server = await runtime.startOpenCode();
const [, , options] = spawnMock.mock.calls[0];
expect(getManagedOpenCodeEnv).toHaveBeenCalledOnce();
expect(options.env.OPENCODE_CONFIG_CONTENT).toBe('{"plugin":["file:///tool.js"]}');
expect(options.env.OPENCHAMBER_AGENT_TOOL_TOKEN).toBe('ephemeral');
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
await server.close();
});
@@ -0,0 +1,61 @@
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
const DEFAULT_TTL_MS = 10 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 8000;
// Shared in-process cache of the models.dev catalog. Used by the
// /api/openchamber/models-metadata route and the small-model resolver so the
// server fetches the catalog once, not per consumer.
let cachedMetadata = null;
let cachedAt = 0;
let inflight = null;
const fetchCatalog = async (url, timeoutMs) => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
if (!metadata || typeof metadata !== 'object') {
throw new Error('models.dev returned an unexpected payload');
}
return metadata;
};
/**
* Returns the models.dev catalog, serving the in-memory copy while fresh.
* On fetch failure a stale cached copy is returned when available; otherwise
* the error propagates.
*/
export async function getModelsMetadata({
url = MODELS_DEV_API_URL,
ttlMs = DEFAULT_TTL_MS,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = {}) {
const now = Date.now();
if (cachedMetadata && now - cachedAt < ttlMs) {
return { metadata: cachedMetadata, fromCache: true };
}
if (!inflight) {
inflight = fetchCatalog(url, timeoutMs).finally(() => {
inflight = null;
});
}
try {
const metadata = await inflight;
cachedMetadata = metadata;
cachedAt = Date.now();
return { metadata, fromCache: false };
} catch (error) {
if (cachedMetadata) {
return { metadata: cachedMetadata, fromCache: true, stale: true };
}
throw error;
}
}
export { MODELS_DEV_API_URL };
@@ -2,8 +2,21 @@ export const createOpenCodeNetworkRuntime = (deps) => {
const {
state,
getOpenCodeAuthHeaders,
configuredOpenCodeHostname = '127.0.0.1',
} = deps;
const resolveConnectHostname = () => {
const raw = typeof configuredOpenCodeHostname === 'string' ? configuredOpenCodeHostname.trim() : '';
const hostname = raw || '127.0.0.1';
if (hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]') {
return '127.0.0.1';
}
if (hostname.startsWith('[') && hostname.endsWith(']')) {
return hostname;
}
return hostname.includes(':') ? `[${hostname}]` : hostname;
};
const normalizeApiPrefix = (prefix) => {
if (!prefix) {
return '';
@@ -77,7 +90,7 @@ export const createOpenCodeNetworkRuntime = (deps) => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
const fullPath = `${prefix}${normalizedPath}`;
const base = state.openCodeBaseUrl ?? `http://localhost:${state.openCodePort}`;
const base = state.openCodeBaseUrl ?? `http://${resolveConnectHostname()}:${state.openCodePort}`;
return `${base}${fullPath}`;
};
@@ -2,36 +2,56 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { createOpenCodeNetworkRuntime } from './network-runtime.js';
const createRuntime = () => createOpenCodeNetworkRuntime({
const originalFetch = globalThis.fetch;
const createRuntime = (overrides = {}) => createOpenCodeNetworkRuntime({
state: {
openCodePort: 4096,
openCodeBaseUrl: null,
openCodeApiPrefix: '',
openCodeApiPrefixDetected: false,
openCodeApiDetectionTimer: null,
...overrides.state,
},
getOpenCodeAuthHeaders: () => ({}),
configuredOpenCodeHostname: overrides.configuredOpenCodeHostname,
});
describe('OpenCode network runtime', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
it('clears the probe abort timer when readiness fetch rejects', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
vi.stubGlobal('fetch', vi.fn(async () => {
it('returns false when readiness fetch rejects', async () => {
globalThis.fetch = vi.fn(async () => {
throw new Error('offline');
}));
});
const runtime = createRuntime();
const readyPromise = runtime.waitForReady('http://127.0.0.1:4096', 1);
await vi.advanceTimersByTimeAsync(100);
await expect(readyPromise).resolves.toBe(false);
});
expect(vi.getTimerCount()).toBe(0);
it('builds managed OpenCode URLs against IPv4 loopback by default', () => {
const runtime = createRuntime();
expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://127.0.0.1:4096/provider');
});
it('keeps external OpenCode base URLs authoritative', () => {
const runtime = createRuntime({
state: { openCodeBaseUrl: 'http://remote.example:4096' },
});
expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://remote.example:4096/provider');
});
it('normalizes wildcard and IPv6 OpenCode bind hosts for local connects', () => {
expect(createRuntime({ configuredOpenCodeHostname: '0.0.0.0' }).buildOpenCodeUrl('/provider'))
.toBe('http://127.0.0.1:4096/provider');
expect(createRuntime({ configuredOpenCodeHostname: '::1' }).buildOpenCodeUrl('/provider'))
.toBe('http://[::1]:4096/provider');
});
});
@@ -13,9 +13,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
getCachedZenModels,
} = dependencies;
let cachedModelsMetadata = null;
let cachedModelsMetadataTimestamp = 0;
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const { checkForUpdates } = await import('../package-manager.js');
@@ -42,6 +39,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
arch: parseString(req.query.arch),
instanceMode: parseString(req.query.instanceMode),
currentVersion: parseString(req.query.currentVersion),
installId: parseString(req.query.installId),
reportUsage: parseReportUsage(parseString(req.query.reportUsage)),
});
res.json(updateInfo);
@@ -254,48 +252,18 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
});
app.get('/api/openchamber/models-metadata', async (_req, res) => {
const now = Date.now();
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) {
res.setHeader('Cache-Control', 'public, max-age=60');
return res.json(cachedModelsMetadata);
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const response = await fetch(modelsDevApiUrl, {
signal: controller?.signal,
headers: {
Accept: 'application/json'
}
const { getModelsMetadata } = await import('./models-metadata.js');
const { metadata, fromCache, stale } = await getModelsMetadata({
url: modelsDevApiUrl,
ttlMs: modelsMetadataCacheTtl,
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
cachedModelsMetadata = metadata;
cachedModelsMetadataTimestamp = Date.now();
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Cache-Control', fromCache && !stale ? 'public, max-age=60' : 'public, max-age=300');
res.json(metadata);
} catch (error) {
console.warn('Failed to fetch models.dev metadata via server:', error);
if (cachedModelsMetadata) {
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(cachedModelsMetadata);
} else {
const statusCode = error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
} finally {
if (timeout) {
clearTimeout(timeout);
}
const statusCode = error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
});
@@ -6,6 +6,10 @@ import {
writeConfig,
} from './shared.js';
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
function getProviderSources(providerId, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const { userConfig, projectConfig, customConfig, paths } = layers;
@@ -37,6 +41,162 @@ function getProviderSources(providerId, workingDirectory) {
};
}
/**
* Validate a custom OpenAI-compatible provider config payload before persistence.
* Returns { ok: true, value } or { ok: false, error }.
*
* Credentials: either config.env contains a variable name, or hasStoredAuth is true
* (auth.json already has a key typically after auth.set, or when editing).
*/
function validateCustomProviderConfig(providerId, config, options = {}) {
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
}
if (!isPlainObject(config)) {
return { ok: false, error: 'Provider config must be an object' };
}
const name = typeof config.name === 'string' ? config.name.trim() : '';
if (!name) {
return { ok: false, error: 'Provider name is required' };
}
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
if (npm !== OPENAI_COMPATIBLE_NPM) {
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
}
const optionsBlock = isPlainObject(config.options) ? config.options : null;
if (!optionsBlock) {
return { ok: false, error: 'Provider options are required' };
}
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
if (!baseURL) {
return { ok: false, error: 'Base URL is required' };
}
if (!BASE_URL_PATTERN.test(baseURL)) {
return { ok: false, error: 'Base URL must start with http:// or https://' };
}
const models = isPlainObject(config.models) ? config.models : null;
if (!models || Object.keys(models).length === 0) {
return { ok: false, error: 'At least one model is required' };
}
const normalizedModels = {};
for (const [modelId, modelValue] of Object.entries(models)) {
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
if (!trimmedId) {
return { ok: false, error: 'Model id is required' };
}
if (!isPlainObject(modelValue)) {
return { ok: false, error: `Model "${trimmedId}" must be an object` };
}
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
if (!modelName) {
return { ok: false, error: `Model "${trimmedId}" requires a name` };
}
normalizedModels[trimmedId] = { name: modelName };
}
const normalized = {
npm: OPENAI_COMPATIBLE_NPM,
name,
options: {
baseURL,
},
models: normalizedModels,
};
let env = [];
if (Array.isArray(config.env)) {
env = config.env
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim());
if (env.length > 0) {
normalized.env = env;
}
}
const hasStoredAuth = Boolean(options.hasStoredAuth);
if (env.length === 0 && !hasStoredAuth) {
return {
ok: false,
error: 'API key or {env:VAR} credentials are required',
};
}
if (isPlainObject(optionsBlock.headers)) {
const headers = {};
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
if (typeof headerKey !== 'string' || !headerKey.trim()) {
continue;
}
if (typeof headerValue !== 'string' || !headerValue.trim()) {
return { ok: false, error: `Header "${headerKey}" requires a non-empty value` };
}
headers[headerKey.trim()] = headerValue.trim();
}
if (Object.keys(headers).length > 0) {
normalized.options.headers = headers;
}
}
return { ok: true, value: { providerId, config: normalized } };
}
/**
* Persist (create or update) a custom provider block in OpenCode user/project/custom config.
* Does not write secrets API keys remain in auth.json via the OpenCode auth API.
*/
function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user', options = {}) {
const validated = validateCustomProviderConfig(providerId, config, options);
if (!validated.ok) {
const error = new Error(validated.error);
error.statusCode = 400;
throw error;
}
const layers = readConfigLayers(workingDirectory);
let targetPath = layers.paths.userPath;
if (scope === 'project') {
if (!workingDirectory) {
throw new Error('Working directory is required for project scope');
}
targetPath = layers.paths.projectPath || targetPath;
} else if (scope === 'custom') {
if (!layers.paths.customPath) {
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
}
targetPath = layers.paths.customPath;
} else if (scope !== 'user') {
throw new Error('Invalid scope');
}
const targetConfig = getConfigForPath(layers, targetPath);
const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {};
providerConfig[validated.value.providerId] = validated.value.config;
targetConfig.provider = providerConfig;
if (Array.isArray(targetConfig.disabled_providers)) {
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
(entry) => entry !== validated.value.providerId,
);
}
const writePath = targetPath || CONFIG_FILE;
writeConfig(targetConfig, writePath);
return {
providerId: validated.value.providerId,
path: writePath,
config: validated.value.config,
};
}
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
if (!providerId || typeof providerId !== 'string') {
throw new Error('Provider ID is required');
@@ -93,4 +253,6 @@ function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
export {
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
validateCustomProviderConfig,
};
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
upsertProviderConfig,
validateCustomProviderConfig,
getProviderSources,
removeProviderConfig,
} from './providers.js';
let projectDir;
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
describe('custom provider config persistence', () => {
beforeEach(() => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-provider-'));
});
afterEach(() => {
fs.rmSync(projectDir, { recursive: true, force: true });
});
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
expect(validateCustomProviderConfig('Bad Id', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok).toBe(false);
expect(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'ftp://api.example.com' },
models: { m: { name: 'M' } },
}).error).toContain('http://');
expect(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: {},
}).ok).toBe(false);
});
test('validateCustomProviderConfig rejects missing credentials', () => {
expect(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok).toBe(false);
expect(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}, { hasStoredAuth: true }).ok).toBe(true);
expect(validateCustomProviderConfig('ok', {
name: 'X',
env: ['MY_KEY'],
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok).toBe(true);
});
test('upsertProviderConfig writes and round-trips project config', () => {
const result = upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: {
'fast-model': { name: 'Fast' },
},
env: ['CAMPUS_KEY'],
}, projectDir, 'project');
expect(result.providerId).toBe('campus-llm');
expect(fs.existsSync(result.path)).toBe(true);
expect(result.path.startsWith(projectDir)).toBe(true);
const written = readJson(result.path);
expect(written.provider['campus-llm']).toEqual({
npm: '@ai-sdk/openai-compatible',
name: 'Campus LLM',
env: ['CAMPUS_KEY'],
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: {
'fast-model': { name: 'Fast' },
},
});
const sources = getProviderSources('campus-llm', projectDir);
expect(sources.sources.project.exists).toBe(true);
expect(sources.sources.project.path).toBe(result.path);
});
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
const configPath = path.join(projectDir, 'opencode.json');
writeJson(configPath, {
provider: {
'campus-llm': {
npm: '@ai-sdk/openai-compatible',
name: 'Old',
options: { baseURL: 'https://old.example.edu/v1' },
models: { a: { name: 'A' } },
},
},
disabled_providers: ['campus-llm', 'other'],
});
upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { b: { name: 'B' } },
env: ['CAMPUS_KEY'],
}, projectDir, 'project');
const written = readJson(configPath);
expect(written.provider['campus-llm'].name).toBe('Campus LLM');
expect(written.provider['campus-llm'].models).toEqual({ b: { name: 'B' } });
expect(written.disabled_providers).toEqual(['other']);
});
test('upsert then remove restores absence', () => {
upsertProviderConfig('temp-provider', {
name: 'Temp',
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
env: ['TEMP_KEY'],
}, projectDir, 'project');
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true);
expect(removeProviderConfig('temp-provider', projectDir, 'project')).toBe(true);
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(false);
});
test('failed validation does not write config', () => {
const configPath = path.join(projectDir, 'opencode.json');
expect(() => upsertProviderConfig('ok', {
name: 'X',
options: { baseURL: 'not-a-url' },
models: { m: { name: 'M' } },
env: ['X'],
}, projectDir, 'project')).toThrow(/Base URL/);
expect(fs.existsSync(configPath)).toBe(false);
});
test('upsert with hasStoredAuth allows config without env', () => {
const result = upsertProviderConfig('keyed-provider', {
name: 'Keyed',
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
expect(result.providerId).toBe('keyed-provider');
expect(result.config.env).toEqual(undefined);
});
test('project-scope edit updates project layer without creating a user entry', () => {
const providerId = `proj-scope-${Date.now()}`;
const configPath = path.join(projectDir, 'opencode.json');
upsertProviderConfig(providerId, {
name: 'Project Scoped',
options: { baseURL: 'https://project.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Project Scoped Updated',
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
models: { m: { name: 'M2' } },
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath);
expect(written.provider[providerId]).toEqual({
npm: '@ai-sdk/openai-compatible',
name: 'Project Scoped Updated',
options: {
baseURL: 'https://project.example.com/v2',
headers: { 'X-Project': '1' },
},
models: { m: { name: 'M2' } },
});
const sources = getProviderSources(providerId, projectDir);
expect(sources.sources.project.exists).toBe(true);
expect(sources.sources.user.exists).toBe(false);
expect(sources.sources.custom.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
expect(userConfig.provider?.[providerId]).toBeUndefined();
expect(userConfig.providers?.[providerId]).toBeUndefined();
}
});
test('custom-scope edit updates custom layer without creating a user entry', () => {
const providerId = `custom-scope-${Date.now()}`;
const customPath = path.join(projectDir, 'custom-opencode.json');
const previousEnv = process.env.OPENCODE_CONFIG;
process.env.OPENCODE_CONFIG = customPath;
try {
upsertProviderConfig(providerId, {
name: 'Custom Scoped',
options: { baseURL: 'https://custom.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'custom', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Custom Scoped Updated',
options: { baseURL: 'https://custom.example.com/v2' },
models: { n: { name: 'N' } },
}, projectDir, 'custom', { hasStoredAuth: true });
const written = readJson(customPath);
expect(written.provider[providerId].name).toBe('Custom Scoped Updated');
expect(written.provider[providerId].options.baseURL).toBe('https://custom.example.com/v2');
const sources = getProviderSources(providerId, projectDir);
expect(sources.sources.custom.exists).toBe(true);
expect(sources.sources.user.exists).toBe(false);
expect(sources.sources.project.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
expect(userConfig.provider?.[providerId]).toBeUndefined();
expect(userConfig.providers?.[providerId]).toBeUndefined();
}
} finally {
if (previousEnv === undefined) {
delete process.env.OPENCODE_CONFIG;
} else {
process.env.OPENCODE_CONFIG = previousEnv;
}
}
});
});
+125 -10
View File
@@ -6,6 +6,10 @@ import {
shouldForwardProxyResponseHeader,
} from '../../proxy-headers.js';
import { createRealpathCache } from '../path-realpath-cache.js';
import { DEFAULT_UPSTREAM_STALL_TIMEOUT_MS } from '../event-stream/upstream-reader.js';
import { recordStartupPerformance } from './startup-performance.js';
const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => {
const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions });
@@ -181,10 +185,14 @@ export const registerOpenCodeProxy = (app, deps) => {
os,
path,
OPEN_CODE_READY_GRACE_MS,
LONG_REQUEST_TIMEOUT_MS,
getRuntime,
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
ensureOpenCodeApiPrefix,
SSE_HEARTBEAT_INTERVAL_MS = DEFAULT_SSE_HEARTBEAT_INTERVAL_MS,
SSE_UPSTREAM_STALL_TIMEOUT_MS = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
getSseUpstreamStallTimeoutMs = () => SSE_UPSTREAM_STALL_TIMEOUT_MS,
} = deps;
if (app.get('opencodeProxyConfigured')) {
@@ -291,11 +299,60 @@ export const registerOpenCodeProxy = (app, deps) => {
return externalBase;
}
if (runtimeState.openCodePort) {
return `http://localhost:${runtimeState.openCodePort}`;
return FALLBACK_PROXY_TARGET;
};
const normalizeProxyTimeout = (value) => {
return Number.isFinite(value) && value > 0 ? value : 4 * 60 * 1000;
};
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
// A provider OAuth callback blocks upstream for as long as the user takes to
// sign in in their browser (device-code polling, or a loopback redirect), so
// it cannot share the ordinary request deadline. Bounded by the shortest
// upstream expiry we know of — GitHub device codes last ~15 minutes.
const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000;
const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/;
const isInteractiveOAuthCallback = (req) =>
req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path);
const isProxyTimeoutError = (error) => {
const code = typeof error?.code === 'string' ? error.code : '';
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
return code === 'ETIMEDOUT'
|| code === 'ESOCKETTIMEDOUT'
|| message.includes('timeout')
|| message.includes('timed out');
};
const sendProxyErrorResponse = (res, statusCode) => {
if (!res || res.headersSent || res.writableEnded || typeof res.status !== 'function') {
return false;
}
res.status(statusCode).json({ error: statusCode === 504 ? 'OpenCode upstream timed out' : 'OpenCode service unavailable' });
return true;
};
const applyProxyResponseDeadline = (req, res, next) => {
if (isInteractiveOAuthCallback(req)) {
return next();
}
return FALLBACK_PROXY_TARGET;
const timeout = setTimeout(() => {
req[PROXY_TIMEOUT_MARKER] = true;
if (sendProxyErrorResponse(res, 504)) {
res.once('finish', () => req.destroy?.());
}
}, PROXY_REQUEST_TIMEOUT_MS);
timeout.unref?.();
const clear = () => clearTimeout(timeout);
res.once('finish', clear);
res.once('close', clear);
next();
};
const forwardSseRequest = async (req, res) => {
@@ -304,6 +361,8 @@ export const registerOpenCodeProxy = (app, deps) => {
let upstream = null;
let reader = null;
let heartbeatTimer = null;
let upstreamStallTimer = null;
let didUpstreamStall = false;
let writeQueue = Promise.resolve(true);
const sseBoundary = createSseBoundaryTracker();
@@ -356,8 +415,6 @@ export const registerOpenCodeProxy = (app, deps) => {
res.socket.setNoDelay(true);
}
const SSE_HEARTBEAT_INTERVAL_MS = 20_000;
const scheduleHeartbeat = () => {
heartbeatTimer = setTimeout(async () => {
if (abortController.signal.aborted || res.writableEnded || res.destroyed) {
@@ -374,6 +431,20 @@ export const registerOpenCodeProxy = (app, deps) => {
}, SSE_HEARTBEAT_INTERVAL_MS);
};
const clearUpstreamStallTimer = () => {
clearTimeout(upstreamStallTimer);
upstreamStallTimer = null;
};
const resetUpstreamStallTimer = () => {
clearUpstreamStallTimer();
upstreamStallTimer = setTimeout(() => {
didUpstreamStall = true;
abortController.abort();
}, getSseUpstreamStallTimeoutMs());
upstreamStallTimer.unref?.();
};
const enqueueSseWrite = (value) => {
writeQueue = writeQueue
.catch(() => false)
@@ -387,6 +458,7 @@ export const registerOpenCodeProxy = (app, deps) => {
};
scheduleHeartbeat();
resetUpstreamStallTimer();
reader = upstream.body.getReader();
while (!abortController.signal.aborted) {
@@ -395,6 +467,7 @@ export const registerOpenCodeProxy = (app, deps) => {
break;
}
if (value && value.length > 0) {
resetUpstreamStallTimer();
sseBoundary.observe(value);
const canContinue = await enqueueSseWrite(value);
if (!canContinue) {
@@ -406,6 +479,10 @@ export const registerOpenCodeProxy = (app, deps) => {
res.end();
} catch (error) {
if (isAbortError(error)) {
if (didUpstreamStall && !res.writableEnded && !res.destroyed) {
await writeQueue.catch(() => false);
res.end();
}
return;
}
console.error('[proxy] OpenCode SSE proxy error:', error?.message ?? error);
@@ -419,6 +496,10 @@ export const registerOpenCodeProxy = (app, deps) => {
clearTimeout(heartbeatTimer);
heartbeatTimer = null;
}
if (upstreamStallTimer) {
clearTimeout(upstreamStallTimer);
upstreamStallTimer = null;
}
req.off('close', closeUpstream);
try {
if (reader) {
@@ -532,6 +613,12 @@ export const registerOpenCodeProxy = (app, deps) => {
!runtimeState.openCodePort
);
};
const classifyReadinessRoute = (requestPath) => {
if (/^\/session\/[^/]+\/message(?:\/|$)/.test(requestPath)) return 'session-messages';
if (requestPath === '/session' || requestPath.startsWith('/session/')) return 'session';
if (requestPath === '/event' || requestPath === '/global/event') return 'events';
return 'other';
};
app.use('/api', async (req, res, next) => {
if (
@@ -551,16 +638,35 @@ export const registerOpenCodeProxy = (app, deps) => {
return next();
}
const holdStartedAt = performance.now();
const routeClass = classifyReadinessRoute(req.path);
const deadline = Date.now() + Math.min(OPEN_CODE_READY_GRACE_MS, READINESS_HOLD_MAX_MS);
while (Date.now() < deadline) {
// Client gave up (closed/aborted) — stop holding.
if (res.writableEnded || req.aborted) return;
if (res.writableEnded || req.aborted) {
recordStartupPerformance('proxy.readiness-hold', {
durationMs: performance.now() - holdStartedAt,
outcome: 'aborted',
routeClass,
});
return;
}
await sleep(READINESS_HOLD_POLL_MS);
if (!isStillWaiting(getRuntime())) {
recordStartupPerformance('proxy.readiness-hold', {
durationMs: performance.now() - holdStartedAt,
outcome: 'ready',
routeClass,
});
return next();
}
}
recordStartupPerformance('proxy.readiness-hold', {
durationMs: performance.now() - holdStartedAt,
outcome: 'timeout',
routeClass,
});
if (!res.headersSent) {
res.status(503).json({
error: 'OpenCode is restarting',
@@ -661,10 +767,12 @@ export const registerOpenCodeProxy = (app, deps) => {
});
// Generic proxy for non-SSE OpenCode API routes.
const apiProxy = createProxyMiddleware({
const createApiProxy = (timeoutMs) => createProxyMiddleware({
target: resolveProxyTarget(),
changeOrigin: true,
pathRewrite: { '^/api': '' },
timeout: timeoutMs,
proxyTimeout: timeoutMs,
// Dynamic target — port can change after restart
router: () => resolveProxyTarget(),
on: {
@@ -700,15 +808,20 @@ export const registerOpenCodeProxy = (app, deps) => {
}
}
},
error: (err, _req, res) => {
error: (err, req, res) => {
console.error('[proxy] OpenCode proxy error:', err.message);
if (res && !res.headersSent && typeof res.status === 'function') {
res.status(503).json({ error: 'OpenCode service unavailable' });
if (req?.[PROXY_TIMEOUT_MARKER]) {
return;
}
const statusCode = isProxyTimeoutError(err) ? 504 : 503;
sendProxyErrorResponse(res, statusCode);
},
},
});
const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS);
const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS);
// Best-effort fallback for stale clients still sending symlink paths.
// Settings and project selection normalize at source; this cached async path
// avoids blocking the proxy hot path on every directory-scoped request.
@@ -724,5 +837,7 @@ export const registerOpenCodeProxy = (app, deps) => {
next();
});
app.use('/api', applyProxyResponseDeadline);
app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy);
app.use('/api', apiProxy);
};
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { registerOpenCodeRoutes } from './routes.js';
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const createApp = (overrides = {}) => {
const app = express();
app.use(express.json());
const dependencies = {
getOpenCodeUpgradeCapability: () => ({
supported: false,
manager: 'openchamber',
reason: 'bundled',
}),
buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
getOpenCodeAuthHeaders: () => ({}),
refreshOpenCodeAfterConfigChange: vi.fn(async () => {}),
...overrides,
};
registerOpenCodeRoutes(app, dependencies);
return { app, dependencies };
};
describe('OpenCode upgrade routes', () => {
it('fails closed without contacting the bundled OpenCode updater', async () => {
globalThis.fetch = vi.fn();
const { app } = createApp();
await request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(409, {
success: false,
code: 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER',
error: 'OpenCode is bundled with OpenChamber Desktop and updates with the app.',
});
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it('reports bundled update ownership through the capability contract', async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ healthy: true, version: '1.18.8' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
const { app } = createApp();
const response = await request(app)
.get('/api/opencode/upgrade-status')
.expect(200);
expect(response.body).toEqual({
available: false,
currentVersion: '1.18.8',
latestVersion: null,
upgrade: {
supported: false,
manager: 'openchamber',
reason: 'bundled',
},
});
});
it('serializes supported upgrades and preserves the in-flight lock', async () => {
let releaseUpgrade;
const upstreamResponse = new Promise((resolve) => {
releaseUpgrade = () => resolve(new Response(JSON.stringify({ success: true, version: '1.18.9' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
});
globalThis.fetch = vi.fn(() => upstreamResponse);
const { app, dependencies } = createApp({
getOpenCodeUpgradeCapability: () => ({
supported: true,
manager: 'opencode',
reason: null,
}),
});
const first = request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(200, {
success: true,
version: '1.18.9',
restarted: true,
})
.then((response) => response);
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
await request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(409, {
success: false,
code: 'OPENCODE_UPGRADE_IN_PROGRESS',
error: 'An OpenCode upgrade is already in progress.',
});
releaseUpgrade();
await first;
expect(dependencies.refreshOpenCodeAfterConfigChange).toHaveBeenCalledTimes(1);
});
});
+154 -26
View File
@@ -8,6 +8,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
@@ -17,6 +18,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
refreshOpenCodeAfterConfigChange,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -41,6 +43,19 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
return trimmed || null;
};
const readOpenCodeCurrentVersion = async () => {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return { ok: false, status: healthResponse.status, error: health?.error || healthResponse.statusText };
}
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
return { ok: true, currentVersion };
};
const parseVersionForComparison = (value) => {
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
const prereleaseIndex = normalized.indexOf('-');
@@ -134,41 +149,84 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
}
});
let openCodeUpgradePromise = null;
app.post('/api/opencode/upgrade', async (req, res) => {
try {
const capability = getOpenCodeUpgradeCapability();
if (!capability.supported) {
return res.status(409).json({
success: false,
code: capability.reason === 'bundled'
? 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER'
: 'OPENCODE_UPGRADE_UNSUPPORTED',
error: capability.reason === 'bundled'
? 'OpenCode is bundled with OpenChamber Desktop and updates with the app.'
: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
});
}
if (openCodeUpgradePromise) {
return res.status(409).json({
success: false,
code: 'OPENCODE_UPGRADE_IN_PROGRESS',
error: 'An OpenCode upgrade is already in progress.',
});
}
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
? req.body.target.trim()
: undefined;
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify(target ? { target } : {}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
return res.status(response.status).json({
success: false,
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
const upgradeOperation = (async () => {
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify(target ? { target } : {}),
});
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
return {
status: response.status,
body: {
success: false,
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
},
};
}
try {
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
} catch (restartError) {
return {
status: 500,
body: {
success: false,
upgraded: true,
error: restartError instanceof Error
? `OpenCode upgraded, but restart failed: ${restartError.message}`
: 'OpenCode upgraded, but restart failed',
},
};
}
return {
status: 200,
body: { ...(payload ?? { success: true }), restarted: true },
};
})();
openCodeUpgradePromise = upgradeOperation;
try {
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
} catch (restartError) {
return res.status(500).json({
success: false,
upgraded: true,
error: restartError instanceof Error
? `OpenCode upgraded, but restart failed: ${restartError.message}`
: 'OpenCode upgraded, but restart failed',
});
const result = await upgradeOperation;
return res.status(result.status).json(result.body);
} finally {
if (openCodeUpgradePromise === upgradeOperation) {
openCodeUpgradePromise = null;
}
}
return res.json({ ...(payload ?? { success: true }), restarted: true });
} catch (error) {
console.error('Failed to upgrade OpenCode:', error);
return res.status(500).json({
@@ -180,6 +238,17 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
app.get('/api/opencode/upgrade-status', async (_req, res) => {
try {
const capability = getOpenCodeUpgradeCapability();
if (!capability.supported) {
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
return res.json({
available: false,
currentVersion: current.ok ? current.currentVersion : null,
latestVersion: null,
upgrade: capability,
});
}
const [healthResponse, latestVersion] = await Promise.all([
fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
@@ -203,6 +272,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
available,
currentVersion,
latestVersion,
upgrade: capability,
});
} catch (error) {
return res.status(500).json({
@@ -374,6 +444,64 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
}
});
app.put('/api/provider', async (req, res) => {
try {
const providerID = typeof req.body?.providerID === 'string'
? req.body.providerID.trim()
: (typeof req.body?.providerId === 'string' ? req.body.providerId.trim() : '');
const config = req.body?.config;
const scope = typeof req.body?.scope === 'string' ? req.body.scope : 'user';
if (!providerID) {
return res.status(400).json({ error: 'Provider ID is required' });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Provider config is required' });
}
if (scope !== 'user' && scope !== 'project' && scope !== 'custom') {
return res.status(400).json({ error: 'Invalid scope' });
}
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
if (scope === 'project' || requestedDirectory) {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({ error: resolved.error || 'Working directory is required' });
}
directory = resolved.directory;
} else {
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
}
}
const { getProviderAuth } = await getAuthLibrary();
const hasStoredAuth = Boolean(getProviderAuth(providerID));
const upsertResult = upsertProviderConfig(providerID, config, directory, scope, { hasStoredAuth });
await refreshOpenCodeAfterConfigChange(`provider ${providerID} upserted (${scope})`);
return res.json({
success: true,
providerId: upsertResult.providerId,
path: upsertResult.path,
config: upsertResult.config,
requiresReload: true,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
const status = typeof error?.statusCode === 'number' ? error.statusCode : 500;
console.error('Failed to upsert provider config:', error);
return res.status(status).json({ error: error.message || 'Failed to save provider config' });
}
});
app.delete('/api/provider/:providerId/auth', async (req, res) => {
try {
const { providerId } = req.params;
@@ -13,6 +13,7 @@ export const createServerUtilsRuntime = (dependencies) => {
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
ensureOpenCodeApiPrefix,
getUpstreamStallTimeoutMs,
getUiNotificationClients,
getOpenCodePort,
setOpenCodePortState,
@@ -212,6 +213,7 @@ export const createServerUtilsRuntime = (dependencies) => {
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
ensureOpenCodeApiPrefix,
getSseUpstreamStallTimeoutMs: getUpstreamStallTimeoutMs,
getUiNotificationClients,
});
};
@@ -1,6 +1,7 @@
const SESSION_COOLDOWN_DURATION_MS = 2000;
const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const SESSION_ACTIVITY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const extractSessionStatusUpdate = (payload) => {
@@ -37,26 +38,12 @@ const extractSessionStatusUpdate = (payload) => {
};
};
const deriveSessionActivityTransitions = (payload) => {
const update = extractSessionStatusUpdate(payload);
if (!update) {
return [];
}
if (update.type === 'busy' || update.type === 'retry') {
return [{ sessionId: update.sessionId, phase: 'busy' }];
}
if (update.type === 'idle') {
return [{ sessionId: update.sessionId, phase: 'cooldown' }];
}
return [];
};
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, broadcastEvent }) => {
const sessionActivityPhases = new Map();
const sessionActivityCooldowns = new Map();
const sessionStates = new Map();
const sessionAttentionStates = new Map();
let activeSessionCount = 0;
const getOrCreateAttentionState = (sessionId) => {
if (!sessionId || typeof sessionId !== 'string') return null;
@@ -90,6 +77,11 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
sessionActivityCooldowns.delete(sessionId);
}
const wasActive = current?.phase === 'busy';
const isActive = phase === 'busy';
if (wasActive !== isActive) {
activeSessionCount = Math.max(0, activeSessionCount + (isActive ? 1 : -1));
}
sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() });
if (phase === 'cooldown') {
@@ -287,11 +279,14 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
return result;
};
const getActiveSessionCount = () => activeSessionCount;
const resetAllSessionActivityToIdle = () => {
for (const timer of sessionActivityCooldowns.values()) {
clearTimeout(timer);
}
sessionActivityCooldowns.clear();
activeSessionCount = 0;
const now = Date.now();
for (const [sessionId] of sessionActivityPhases) {
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now });
@@ -310,26 +305,33 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
sessionAttentionStates.delete(sessionId);
}
}
for (const [sessionId, data] of sessionActivityPhases) {
if (now - data.updatedAt <= SESSION_ACTIVITY_MAX_AGE_MS) continue;
const timer = sessionActivityCooldowns.get(sessionId);
if (timer) clearTimeout(timer);
sessionActivityCooldowns.delete(sessionId);
sessionActivityPhases.delete(sessionId);
if (data.phase === 'busy') activeSessionCount = Math.max(0, activeSessionCount - 1);
}
};
const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS);
const processOpenCodeSsePayload = (payload) => {
const transitions = deriveSessionActivityTransitions(payload);
for (const activity of transitions) {
setSessionActivityPhase(activity.sessionId, activity.phase);
const update = extractSessionStatusUpdate(payload);
if (!update) return;
if (update.type === 'busy' || update.type === 'retry') {
setSessionActivityPhase(update.sessionId, 'busy');
} else if (update.type === 'idle') {
setSessionActivityPhase(update.sessionId, 'cooldown');
}
if (payload && payload.type === 'session.status') {
const update = extractSessionStatusUpdate(payload);
if (update) {
updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, {
attempt: update.attempt,
message: update.message,
next: update.next,
});
}
}
updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, {
attempt: update.attempt,
message: update.message,
next: update.next,
});
};
const dispose = () => {
@@ -338,11 +340,16 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
clearTimeout(timer);
}
sessionActivityCooldowns.clear();
sessionActivityPhases.clear();
sessionStates.clear();
sessionAttentionStates.clear();
activeSessionCount = 0;
};
return {
processOpenCodeSsePayload,
getSessionActivitySnapshot,
getActiveSessionCount,
getSessionStateSnapshot,
getSessionAttentionSnapshot,
getSessionState,
@@ -148,4 +148,84 @@ describe('session runtime', () => {
vi.useRealTimers();
}
});
it('maintains an idempotent active session count', () => {
const runtime = createSessionRuntime({
writeSseEvent() {},
getNotificationClients: () => new Set(),
broadcastEvent() {},
});
runtimes.push(runtime);
const status = (sessionID, type) => runtime.processOpenCodeSsePayload({
type: 'session.status',
properties: { sessionID, status: { type } },
});
expect(runtime.getActiveSessionCount()).toBe(0);
status('session-1', 'busy');
status('session-1', 'busy');
status('session-1', 'retry');
expect(runtime.getActiveSessionCount()).toBe(1);
status('session-2', 'busy');
expect(runtime.getActiveSessionCount()).toBe(2);
status('session-1', 'idle');
expect(runtime.getActiveSessionCount()).toBe(1);
status('session-1', 'idle');
expect(runtime.getActiveSessionCount()).toBe(1);
runtime.resetAllSessionActivityToIdle();
expect(runtime.getActiveSessionCount()).toBe(0);
});
it('restores activity when busy interrupts cooldown without timer underflow', () => {
vi.useFakeTimers();
const runtime = createSessionRuntime({
writeSseEvent() {},
getNotificationClients: () => new Set(),
broadcastEvent() {},
});
const status = (type) => runtime.processOpenCodeSsePayload({
type: 'session.status',
properties: { sessionID: 'session-1', status: { type } },
});
try {
status('busy');
status('idle');
expect(runtime.getActiveSessionCount()).toBe(0);
status('retry');
expect(runtime.getActiveSessionCount()).toBe(1);
vi.advanceTimersByTime(2000);
expect(runtime.getActiveSessionCount()).toBe(1);
expect(runtime.getSessionActivitySnapshot()['session-1']).toEqual({ type: 'busy' });
} finally {
runtime.dispose();
vi.useRealTimers();
}
});
it('releases retained session state when disposed', () => {
const runtime = createSessionRuntime({
writeSseEvent() {},
getNotificationClients: () => new Set(),
broadcastEvent() {},
});
runtimes.push(runtime);
runtime.processOpenCodeSsePayload({
type: 'session.status',
properties: { sessionID: 'session-1', status: { type: 'busy' } },
});
runtime.markUserMessageSent('session-1');
runtime.dispose();
expect(runtime.getActiveSessionCount()).toBe(0);
expect(runtime.getSessionActivitySnapshot()).toEqual({});
expect(runtime.getSessionStateSnapshot()).toEqual({});
expect(runtime.getSessionAttentionSnapshot()).toEqual({});
});
});
@@ -26,6 +26,7 @@ export const createSettingsHelpers = (dependencies) => {
const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128;
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
const TERMINAL_SHELL_VALUES = new Set(['auto', 'bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu']);
const HIDDEN_MODELS_MAX = 1024;
const RECENT_EFFORTS_MAX_KEYS = 128;
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
@@ -181,6 +182,43 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.desktopKeepAwakeEnabled === 'boolean') {
result.desktopKeepAwakeEnabled = candidate.desktopKeepAwakeEnabled;
}
if (typeof candidate.desktopMinimizeToTrayEnabled === 'boolean') {
result.desktopMinimizeToTrayEnabled = candidate.desktopMinimizeToTrayEnabled;
}
if (typeof candidate.desktopMacMenuBarEnabled === 'boolean') {
result.desktopMacMenuBarEnabled = candidate.desktopMacMenuBarEnabled;
}
if (typeof candidate.desktopWindowControlsPosition === 'string') {
const mode = candidate.desktopWindowControlsPosition.trim();
// Legacy "auto" never read OS chrome config; persist as the right default.
if (mode === 'auto' || mode === 'right') {
result.desktopWindowControlsPosition = 'right';
} else if (mode === 'left') {
result.desktopWindowControlsPosition = 'left';
}
}
if (typeof candidate.desktopWindowControlsStyle === 'string') {
const style = candidate.desktopWindowControlsStyle.trim();
if (style === 'classic' || style === 'traffic-lights') {
result.desktopWindowControlsStyle = style;
}
}
if (candidate.permissionAutoAccept && typeof candidate.permissionAutoAccept === 'object' && !Array.isArray(candidate.permissionAutoAccept)) {
const sessions = {};
const sourceSessions = candidate.permissionAutoAccept.sessions;
if (sourceSessions && typeof sourceSessions === 'object' && !Array.isArray(sourceSessions)) {
for (const [sessionId, enabled] of Object.entries(sourceSessions)) {
if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled;
}
}
result.permissionAutoAccept = {
sessions,
revision: Number.isSafeInteger(candidate.permissionAutoAccept.revision)
&& candidate.permissionAutoAccept.revision >= 0
? candidate.permissionAutoAccept.revision
: 0,
};
}
if (typeof candidate.desktopUiPassword === 'string') {
result.desktopUiPassword = candidate.desktopUiPassword.trim();
}
@@ -219,6 +257,15 @@ export const createSettingsHelpers = (dependencies) => {
}
result.draftStarters = starters;
}
if (typeof candidate.draftStartersVisible === 'boolean') {
result.draftStartersVisible = candidate.draftStartersVisible;
}
if (typeof candidate.draftStartersCraftGoalAdded === 'boolean') {
result.draftStartersCraftGoalAdded = candidate.draftStartersCraftGoalAdded;
}
if (typeof candidate.draftStartersScheduleTaskAdded === 'boolean') {
result.draftStartersScheduleTaskAdded = candidate.draftStartersScheduleTaskAdded;
}
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
@@ -245,6 +292,21 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.sessionRecapEnabled === 'boolean') {
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
}
if (typeof candidate.sessionSuggestionEnabled === 'boolean') {
result.sessionSuggestionEnabled = candidate.sessionSuggestionEnabled;
}
if (typeof candidate.sessionGoalEnabled === 'boolean') {
result.sessionGoalEnabled = candidate.sessionGoalEnabled;
}
if (typeof candidate.sessionGoalDefaultBudgetEnabled === 'boolean') {
result.sessionGoalDefaultBudgetEnabled = candidate.sessionGoalDefaultBudgetEnabled;
}
if (typeof candidate.sessionGoalDefaultBudget === 'number' && Number.isFinite(candidate.sessionGoalDefaultBudget) && candidate.sessionGoalDefaultBudget > 0) {
result.sessionGoalDefaultBudget = Math.floor(candidate.sessionGoalDefaultBudget);
}
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
}
@@ -374,6 +436,17 @@ export const createSettingsHelpers = (dependencies) => {
const trimmed = candidate.defaultAgent.trim();
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.smallModelUseDefault === 'boolean') {
result.smallModelUseDefault = candidate.smallModelUseDefault;
}
if (typeof candidate.smallModelOverride === 'string') {
const trimmed = candidate.smallModelOverride.trim();
result.smallModelOverride = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.walkthroughModelOverride === 'string') {
const trimmed = candidate.walkthroughModelOverride.trim();
result.walkthroughModelOverride = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.defaultGitIdentityId === 'string') {
const trimmed = candidate.defaultGitIdentityId.trim();
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
@@ -428,6 +501,12 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') {
result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications;
}
if (typeof candidate.agentControlToolEnabled === 'boolean') {
result.agentControlToolEnabled = candidate.agentControlToolEnabled;
}
if (typeof candidate.optimizeSystemPrompt === 'boolean') {
result.optimizeSystemPrompt = candidate.optimizeSystemPrompt;
}
if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
const version = candidate.openCodeUpdateToastDismissedVersion.trim();
result.openCodeUpdateToastDismissedVersion = version.slice(0, VERSION_STRING_MAX_LENGTH);
@@ -489,9 +568,15 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.stickyUserHeader === 'boolean') {
result.stickyUserHeader = candidate.stickyUserHeader;
}
if (typeof candidate.promptNavigatorEnabled === 'boolean') {
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
}
if (typeof candidate.expandedEditorToolbar === 'boolean') {
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
}
if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
}
if (typeof candidate.showSplitAssistantMessageActions === 'boolean') {
result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions;
}
@@ -501,6 +586,16 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize)));
}
if (typeof candidate.terminalShell === 'string') {
const shell = candidate.terminalShell.trim().toLowerCase();
if (TERMINAL_SHELL_VALUES.has(shell)) result.terminalShell = shell;
}
if (Array.isArray(candidate.terminalLoginShells)) {
result.terminalLoginShells = [...new Set(candidate.terminalLoginShells
.filter((shell) => typeof shell === 'string')
.map((shell) => shell.trim().toLowerCase())
.filter((shell) => TERMINAL_SHELL_VALUES.has(shell)))];
}
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding)));
}
@@ -721,10 +816,18 @@ export const createSettingsHelpers = (dependencies) => {
}
}
if (typeof candidate.dictationEnabled === 'boolean') {
result.dictationEnabled = candidate.dictationEnabled;
}
if (typeof candidate.sttProvider === 'string') {
const provider = candidate.sttProvider.trim();
if (provider === 'browser' || provider === 'server' || provider === 'wasm') {
if (provider === 'local' || provider === 'openai-compatible') {
result.sttProvider = provider;
} else if (provider === 'server') {
// Legacy provider migration: 'server' was the OpenAI-compatible endpoint.
result.sttProvider = 'openai-compatible';
} else if (provider === 'browser' || provider === 'wasm') {
result.sttProvider = 'local';
}
}
if (typeof candidate.sttServerUrl === 'string') {
@@ -739,10 +842,10 @@ export const createSettingsHelpers = (dependencies) => {
result.sttModel = trimmed;
}
}
if (typeof candidate.wasmSttModel === 'string') {
const trimmed = candidate.wasmSttModel.trim();
if (trimmed.length <= 256) {
result.wasmSttModel = trimmed;
if (typeof candidate.sttLocalModel === 'string') {
const trimmed = candidate.sttLocalModel.trim();
if (trimmed.length <= STT_MODEL_MAX_LENGTH) {
result.sttLocalModel = trimmed;
}
}
if (typeof candidate.sttLanguage === 'string') {
@@ -751,15 +854,6 @@ export const createSettingsHelpers = (dependencies) => {
result.sttLanguage = trimmed;
}
}
if (typeof candidate.sttSilenceThresholdDb === 'number' && Number.isFinite(candidate.sttSilenceThresholdDb)) {
result.sttSilenceThresholdDb = Math.max(-100, Math.min(0, candidate.sttSilenceThresholdDb));
}
if (typeof candidate.sttSilenceHoldMs === 'number' && Number.isFinite(candidate.sttSilenceHoldMs)) {
result.sttSilenceHoldMs = Math.max(250, Math.min(10000, Math.round(candidate.sttSilenceHoldMs)));
}
if (typeof candidate.sttTranscribeOnStop === 'boolean') {
result.sttTranscribeOnStop = candidate.sttTranscribeOnStop;
}
return result;
};
@@ -58,6 +58,22 @@ const createTestHelpersWithRealSanitizers = () => {
};
describe('settings helpers', () => {
it('accepts only booleans for draft starter visibility', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: true })).toEqual({ draftStartersVisible: true });
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: false })).toEqual({ draftStartersVisible: false });
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({});
});
it('accepts only booleans for wide chat layout', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: true })).toEqual({ wideChatLayoutEnabled: true });
expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: false })).toEqual({ wideChatLayoutEnabled: false });
expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: 'true' })).toEqual({});
});
it('accepts messageStreamTransport as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -78,6 +94,19 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({});
});
it('sanitizes the persisted terminal shell', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ terminalShell: ' ZSH ' })).toEqual({ terminalShell: 'zsh' });
expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'auto' })).toEqual({ terminalShell: 'auto' });
expect(helpers.sanitizeSettingsUpdate({ terminalShell: '/bin/zsh' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'zsh -c whoami' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [' ZSH ', 'bash', 'zsh', '/bin/fish', 42] })).toEqual({
terminalLoginShells: ['zsh', 'bash'],
});
expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [] })).toEqual({ terminalLoginShells: [] });
});
it('accepts desktopLanAccessEnabled as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -100,6 +129,74 @@ describe('settings helpers', () => {
});
});
it('accepts desktopMinimizeToTrayEnabled as a persisted shared setting', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ desktopMinimizeToTrayEnabled: true })).toEqual({
desktopMinimizeToTrayEnabled: true,
});
expect(helpers.sanitizeSettingsUpdate({ desktopMinimizeToTrayEnabled: false })).toEqual({
desktopMinimizeToTrayEnabled: false,
});
});
it('accepts desktopMacMenuBarEnabled as a persisted shared setting', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ desktopMacMenuBarEnabled: true })).toEqual({
desktopMacMenuBarEnabled: true,
});
expect(helpers.sanitizeSettingsUpdate({ desktopMacMenuBarEnabled: false })).toEqual({
desktopMacMenuBarEnabled: false,
});
expect(helpers.formatSettingsResponse({ desktopMacMenuBarEnabled: false })).toMatchObject({
desktopMacMenuBarEnabled: false,
});
});
it('normalizes desktopWindowControlsPosition and maps legacy auto to right', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'left' })).toEqual({
desktopWindowControlsPosition: 'left',
});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'right' })).toEqual({
desktopWindowControlsPosition: 'right',
});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'auto' })).toEqual({
desktopWindowControlsPosition: 'right',
});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'center' })).toEqual({});
});
it('sanitizes desktopWindowControlsStyle and rejects unknown values', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'classic' })).toEqual({
desktopWindowControlsStyle: 'classic',
});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'traffic-lights' })).toEqual({
desktopWindowControlsStyle: 'traffic-lights',
});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'macos' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'auto' })).toEqual({});
});
it('sanitizes the persisted permission auto-accept policy', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({
permissionAutoAccept: {
sessions: { root: true, child: false, invalid: 'true' },
},
})).toEqual({
permissionAutoAccept: {
sessions: { root: true, child: false },
revision: 0,
},
});
});
it('accepts desktopUiPassword as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -325,6 +422,14 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({});
});
it('persists only boolean system prompt optimization values', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: true })).toEqual({ optimizeSystemPrompt: true });
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: false })).toEqual({ optimizeSystemPrompt: false });
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: 'true' })).toEqual({});
});
it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => {
const helpers = createTestHelpersWithRealSanitizers();
const payload = {
@@ -17,7 +17,15 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
return value;
}
const trimmed = value.trim();
let trimmed = value.trim();
// Paths pasted from Windows "Copy as path" (or quoted shell snippets)
// arrive wrapped in quotes — a literal quote character can never be part
// of a real path, and it breaks every fs.stat/executable check.
if (trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
trimmed = trimmed.slice(1, -1).trim();
}
if (!trimmed) {
return trimmed;
}
@@ -60,13 +68,28 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
return trimmed;
}
const resolved = options.resolveRealpath === false ? trimmed : safeRealpathSync(trimmed);
// Normalize Windows drive letter to uppercase to ensure consistent
// case across all path representations on Windows. NTFS is case-insensitive
// but case-preserving, so a path like "c:\\Users\\..." and "C:\\Users\\..."
// would be stored differently in settings.json across sessions.
const uppercaseDriveLetter = (p) =>
p.replace(/^([a-z]):/, (_, letter) => letter.toUpperCase() + ':');
if (processLike.platform !== 'win32') {
return resolved;
const isWindows = processLike.platform === 'win32';
const caseNormalized = isWindows ? uppercaseDriveLetter(trimmed) : trimmed;
const resolved = options.resolveRealpath === false ? caseNormalized : safeRealpathSync(caseNormalized);
// Re-normalize after realpath — safeRealpathSync may return a
// lowercase drive letter on some Windows environments.
const finalResolved = isWindows && typeof resolved === 'string'
? uppercaseDriveLetter(resolved)
: resolved;
if (!isWindows) {
return finalResolved;
}
return resolved.replace(/\//g, '\\');
return finalResolved.replace(/\//g, '\\');
};
const areStringArraysEqual = (a, b) => {
@@ -131,6 +154,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
: null;
const iconBackground = normalizeIconBackground(candidate.iconBackground);
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
const defaultModel = typeof candidate.defaultModel === 'string' ? candidate.defaultModel.trim() : '';
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
? Number(candidate.lastOpenedAt)
@@ -150,6 +174,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
...(icon ? { icon } : {}),
...(iconBackground ? { iconBackground } : {}),
...(color ? { color } : {}),
...(defaultModel && defaultModel.includes('/') ? { defaultModel } : {}),
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
};
@@ -52,6 +52,27 @@ describe('settings normalization runtime - symlink resolution', () => {
const result = runtime.normalizePathForPersistence('/some/path');
expect(result).toBe('/some/path');
});
it('preserves lowercase colon-prefixed paths on non-Windows platforms', () => {
const runtime = createTestRuntime({ realpathSync: undefined });
expect(runtime.normalizePathForPersistence('c:project')).toBe('c:project');
});
it('uppercases Windows drive letter before and after realpath resolution', () => {
const runtime = createTestRuntime({
processLike: { platform: 'win32', env: {} },
realpathSync: (p) => {
// Simulate safeRealpathSync returning a lowercase drive letter
if (p === 'C:\\Users\\me\\project') return 'c:\\real\\project';
return p;
},
});
const result = runtime.normalizePathForPersistence('c:\\Users\\me\\project');
// Drive letter uppercased on input AND after realpath
expect(result).toBe('C:\\real\\project');
});
});
describe('sanitizeProjects', () => {
@@ -438,6 +438,30 @@ export const createSettingsRuntime = (deps) => {
}
};
// Strict variant for callers that REGENERATE persisted identity when a key is
// absent (relay signing/encryption keys). The lenient reader above maps every
// failure — corrupt JSON, EACCES, transient I/O — to `{}`, which such callers
// cannot distinguish from "first run": they would mint a NEW identity, orphan
// every paired device and push binding, and overwrite the settings file with
// the empty spread. Here only a genuinely missing file means "no settings";
// any other failure (including a non-object payload) throws.
const readSettingsFromDiskStrict = async () => {
let raw;
try {
raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return {};
}
throw error;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
throw new Error('Settings file is malformed (non-object payload)');
}
return parsed;
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isTransientWindowsReplaceError = (error) => {
@@ -478,14 +502,18 @@ export const createSettingsRuntime = (deps) => {
const writeSettingsToDisk = async (settings) => {
try {
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
// Atomic write: Electron main and ssh-manager read this file via plain
// readFile + JSON.parse and silently coerce parse errors to {}. A
// partial read during a non-atomic writeFile would make their next
// read-modify-write wipe the settings file.
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), 'utf8');
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { encoding: 'utf8', mode: 0o600 });
if (process.platform !== 'win32') await fsPromises.chmod(tmp, 0o600);
await replaceFile(tmp, SETTINGS_FILE_PATH);
if (process.platform !== 'win32') await fsPromises.chmod(SETTINGS_FILE_PATH, 0o600);
} catch (error) {
console.warn('Failed to write settings file:', error);
throw error;
@@ -870,6 +898,7 @@ export const createSettingsRuntime = (deps) => {
return {
readSettingsFromDisk,
readSettingsFromDiskStrict,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
persistSettings,
@@ -39,6 +39,18 @@ const createRuntime = async () => {
};
describe('settings runtime', () => {
it.skipIf(process.platform === 'win32')('writes settings with restrictive directory and file permissions', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
await runtime.writeSettingsToDisk({ desktopUiPassword: 'secret' });
expect((await fsPromises.stat(tempRoot)).mode & 0o777).toBe(0o700);
expect((await fsPromises.stat(settingsFilePath)).mode & 0o777).toBe(0o600);
} finally {
await cleanup();
}
});
it('only remaps project plan paths within the migrated storage directory', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
+4 -4
View File
@@ -11,9 +11,6 @@ const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// ============== SCOPE TYPE CONSTANTS ==============
@@ -121,7 +118,10 @@ function getConfigPaths(workingDirectory) {
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
};
}
@@ -8,6 +8,9 @@ export const createGracefulShutdownRuntime = (dependencies) => {
syncToHmrState,
openCodeWatcherRuntime,
sessionRuntime,
sessionAssistRuntime,
sessionGoalRuntime,
contextObligatoryRuntime,
scheduledTasksRuntime,
getHealthCheckInterval,
clearHealthCheckInterval,
@@ -41,6 +44,9 @@ export const createGracefulShutdownRuntime = (dependencies) => {
openCodeWatcherRuntime.stop();
sessionRuntime.dispose();
sessionAssistRuntime?.stop?.();
sessionGoalRuntime?.stop?.();
contextObligatoryRuntime?.stop?.();
scheduledTasksRuntime?.stop?.();
const healthCheckInterval = getHealthCheckInterval();
@@ -21,6 +21,8 @@ export const registerSkillRoutes = (app, dependencies) => {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -200,9 +202,33 @@ export const registerSkillRoutes = (app, dependencies) => {
return null;
};
// Prefer an explicit request directory, then soft-fallback to the active
// project / lastDirectory so repository-local skills stay visible when the
// client omits `directory` (create already used resolveProjectDirectory).
const resolveSkillsDirectory = async (req) => {
const optional = await resolveOptionalProjectDirectory(req);
if (optional.error) {
return optional;
}
if (optional.directory) {
return optional;
}
try {
const fallback = await resolveProjectDirectory(req);
if (fallback.directory) {
return { directory: fallback.directory, error: null };
}
} catch {
// ignore — listing user-scoped skills without a project is valid
}
return { directory: null, error: null };
};
app.get('/api/config/skills', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -212,9 +238,15 @@ export const registerSkillRoutes = (app, dependencies) => {
const enrichedSkills = skills.map((skill) => {
const sources = getSkillSources(skill.name, directory, skill);
const skillPath = typeof skill.path === 'string' ? skill.path : null;
return {
...skill,
sources
sources,
renamable: Boolean(
skillPath
&& skillPath !== '<built-in>'
&& isManagedSkillPath(skillPath, directory)
),
};
});
@@ -257,7 +289,7 @@ export const registerSkillRoutes = (app, dependencies) => {
app.get('/api/config/skills/catalog/source', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } });
}
@@ -518,7 +550,7 @@ export const registerSkillRoutes = (app, dependencies) => {
app.get('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -546,7 +578,7 @@ export const registerSkillRoutes = (app, dependencies) => {
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -579,7 +611,7 @@ export const registerSkillRoutes = (app, dependencies) => {
const { scope, source: skillSource, ...config } = req.body;
const { directory, error } = scope === SKILL_SCOPE.PROJECT
? await resolveProjectDirectory(req)
: await resolveOptionalProjectDirectory(req);
: await resolveSkillsDirectory(req);
if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) {
return res.status(400).json({ error: error || 'Project skill creation requires a directory' });
}
@@ -606,11 +638,27 @@ export const registerSkillRoutes = (app, dependencies) => {
try {
const skillName = req.params.name;
const updates = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
if (typeof updates?.renameTo === 'string') {
const newName = updates.renameTo.trim();
console.log(`[Server] Renaming skill: ${skillName} -> ${newName}`);
console.log('[Server] Working directory:', directory);
renameSkill(skillName, newName, directory);
await refreshOpenCodeAfterConfigChange('skill rename');
return res.json({
success: true,
name: newName,
requiresReload: true,
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
}
console.log(`[Server] Updating skill: ${skillName}`);
console.log('[Server] Working directory:', directory);
@@ -637,7 +685,7 @@ export const registerSkillRoutes = (app, dependencies) => {
return res.status(400).json({ error: 'Invalid file path' });
}
const { content } = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -671,7 +719,7 @@ export const registerSkillRoutes = (app, dependencies) => {
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -701,7 +749,7 @@ export const registerSkillRoutes = (app, dependencies) => {
app.delete('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -0,0 +1,219 @@
import { afterEach, describe, expect, it } from 'vitest';
import express from 'express';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { registerSkillRoutes } from './skill-routes.js';
import {
createSkill,
deleteSkill,
discoverSkills,
getSkillSources,
isManagedSkillPath,
mergeDiscoveredSkills,
renameSkill,
updateSkill,
} from './skills.js';
import {
SKILL_DIR,
SKILL_SCOPE,
deleteSkillSupportingFile,
readSkillSupportingFile,
writeSkillSupportingFile,
} from './shared.js';
const createTempProject = () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-routes-'));
fs.mkdirSync(path.join(projectRoot, '.git'));
return projectRoot;
};
const startSkillsApp = ({ projectRoot }) => {
const app = express();
app.use(express.json());
registerSkillRoutes(app, {
fs,
path,
os,
resolveProjectDirectory: async () => ({ directory: projectRoot, error: null }),
resolveOptionalProjectDirectory: async (req) => {
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
if (!queryDirectory) {
return { directory: null, error: null };
}
return { directory: String(queryDirectory), error: null };
},
readSettingsFromDisk: async () => ({}),
sanitizeSkillCatalogs: (value) => value,
isUnsafeSkillRelativePath: () => false,
refreshOpenCodeAfterConfigChange: async () => {},
clientReloadDelayMs: 0,
buildOpenCodeUrl: () => 'http://127.0.0.1:9/',
getOpenCodeAuthHeaders: () => ({}),
getOpenCodePort: () => 0,
getSkillSources,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
getCuratedSkillsSources: () => [],
getCacheKey: () => 'k',
getCachedScan: () => null,
setCachedScan: () => {},
parseSkillRepoSource: () => ({ ok: false }),
scanSkillsRepository: async () => ({ ok: false }),
installSkillsFromRepository: async () => ({ ok: false }),
scanClawdHubPage: async () => ({ ok: false }),
installSkillsFromClawdHub: async () => ({ ok: false }),
isClawdHubSource: () => false,
getProfiles: () => [],
getProfile: () => null,
});
const server = app.listen(0);
const { port } = server.address();
return {
baseUrl: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
};
describe('skill-routes directory soft fallback', () => {
/** @type {string | null} */
let projectRoot = null;
/** @type {{ close: () => Promise<void> } | null} */
let appHandle = null;
afterEach(async () => {
if (appHandle) {
await appHandle.close();
appHandle = null;
}
if (projectRoot) {
fs.rmSync(projectRoot, { recursive: true, force: true });
projectRoot = null;
}
});
it('lists repository-local .agents skills after create even when list omits directory', async () => {
projectRoot = createTempProject();
appHandle = startSkillsApp({ projectRoot });
const createResponse = await fetch(`${appHandle.baseUrl}/api/config/skills/repo-local-skill`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: 'Created without list directory',
instructions: 'Do the thing.',
scope: 'project',
source: 'agents',
}),
});
expect(createResponse.status).toBe(200);
expect(fs.existsSync(path.join(projectRoot, '.agents', 'skills', 'repo-local-skill', 'SKILL.md'))).toBe(true);
const listResponse = await fetch(`${appHandle.baseUrl}/api/config/skills`);
expect(listResponse.status).toBe(200);
const payload = await listResponse.json();
expect(payload.skills.map((skill) => skill.name)).toContain('repo-local-skill');
const skill = payload.skills.find((entry) => entry.name === 'repo-local-skill');
expect(skill.scope).toBe('project');
expect(skill.source).toBe('agents');
});
it('lists manually created repository-local .agents skills via active-project fallback', async () => {
projectRoot = createTempProject();
const skillDir = path.join(projectRoot, '.agents', 'skills', 'manual-repo-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, 'SKILL.md'),
[
'---',
'name: manual-repo-skill',
'description: Manual repository skill',
'---',
'',
'Instructions',
'',
].join('\n'),
'utf8',
);
appHandle = startSkillsApp({ projectRoot });
const listResponse = await fetch(`${appHandle.baseUrl}/api/config/skills`);
expect(listResponse.status).toBe(200);
const payload = await listResponse.json();
expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill');
});
it('marks managed-root skills renamable and cache skills not renamable', async () => {
projectRoot = createTempProject();
const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-list-skill');
fs.mkdirSync(managedDir, { recursive: true });
fs.writeFileSync(
path.join(managedDir, 'SKILL.md'),
[
'---',
'name: managed-list-skill',
'description: Managed list skill',
'---',
'',
'Managed body',
'',
].join('\n'),
'utf8',
);
const cacheStamp = `oc-skill-routes-${Date.now()}`;
const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-list-skill');
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
path.join(cacheDir, 'SKILL.md'),
[
'---',
'name: cache-list-skill',
'description: Cache list skill',
'---',
'',
'Cache body',
'',
].join('\n'),
'utf8',
);
try {
appHandle = startSkillsApp({ projectRoot });
const listResponse = await fetch(
`${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`,
);
expect(listResponse.status).toBe(200);
const payload = await listResponse.json();
const managed = payload.skills.find((entry) => entry.name === 'managed-list-skill');
const cached = payload.skills.find((entry) => entry.name === 'cache-list-skill');
expect(managed).toBeTruthy();
expect(managed.renamable).toBe(true);
expect(cached).toBeTruthy();
expect(cached.renamable).toBe(false);
} finally {
fs.rmSync(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), {
recursive: true,
force: true,
});
}
});
});
+140 -4
View File
@@ -412,12 +412,22 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
return sources;
}
function createSkill(skillName, config, workingDirectory, scope) {
ensureDirs();
function isValidSkillName(skillName) {
return typeof skillName === 'string'
&& skillName.length > 0
&& skillName.length <= 64
&& /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName);
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
function assertValidSkillName(skillName) {
if (!isValidSkillName(skillName)) {
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
}
}
function createSkill(skillName, config, workingDirectory, scope) {
ensureDirs();
assertValidSkillName(skillName);
const existing = getSkillScope(skillName, workingDirectory);
if (existing.path) {
@@ -505,7 +515,7 @@ function updateSkill(skillName, updates, workingDirectory, targetPath = null) {
let mdModified = false;
for (const [field, value] of Object.entries(updates)) {
if (field === 'scope' || field === 'source' || field === 'targetPath') {
if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') {
continue;
}
@@ -592,6 +602,130 @@ function deleteSkill(skillName, workingDirectory) {
}
}
function isPathInside(candidatePath, parentPath) {
if (!candidatePath || !parentPath) return false;
const resolvedCandidate = path.resolve(candidatePath);
const resolvedParent = path.resolve(parentPath);
return resolvedCandidate === resolvedParent
|| resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`);
}
function getManagedSkillRoots(workingDirectory) {
const roots = [];
const pushRoot = (dir) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!roots.includes(resolved)) {
roots.push(resolved);
}
};
pushRoot(SKILL_DIR);
pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill'));
pushRoot(path.join(os.homedir(), '.opencode', 'skills'));
pushRoot(path.join(os.homedir(), '.opencode', 'skill'));
pushRoot(path.join(os.homedir(), '.claude', 'skills'));
pushRoot(path.join(os.homedir(), '.agents', 'skills'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
if (customConfigDir) {
pushRoot(path.join(customConfigDir, 'skills'));
pushRoot(path.join(customConfigDir, 'skill'));
}
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) {
pushRoot(path.join(ancestor, '.opencode', 'skills'));
pushRoot(path.join(ancestor, '.opencode', 'skill'));
pushRoot(path.join(ancestor, '.claude', 'skills'));
pushRoot(path.join(ancestor, '.agents', 'skills'));
}
}
return roots;
}
function isManagedSkillPath(skillMdPath, workingDirectory) {
if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) {
return false;
}
const skillDir = path.dirname(path.resolve(skillMdPath));
return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root));
}
function renameSkill(oldName, newName, workingDirectory) {
ensureDirs();
assertValidSkillName(newName);
if (oldName === newName) {
return;
}
const existing = getSkillScope(oldName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${oldName}" not found`);
}
if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) {
throw new Error(`Skill "${oldName}" cannot be renamed`);
}
if (path.basename(existing.path) !== 'SKILL.md') {
throw new Error(`Skill "${oldName}" target must be a SKILL.md file`);
}
if (!isManagedSkillPath(existing.path, workingDirectory)) {
throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`);
}
const mdDataBeforeMove = parseMdFile(existing.path);
const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string'
? mdDataBeforeMove.frontmatter.name
: oldName;
if (frontmatterName !== oldName) {
throw new Error(`Skill "${oldName}" does not match ${existing.path}`);
}
const conflict = getSkillScope(newName, workingDirectory);
if (conflict.path) {
throw new Error(`Skill ${newName} already exists at ${conflict.path}`);
}
const oldDir = path.dirname(existing.path);
const newDir = path.join(path.dirname(oldDir), newName);
const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir);
if (directoriesDiffer && fs.existsSync(newDir)) {
throw new Error(`Skill directory already exists at ${newDir}`);
}
// Rename the skill directory in place so supporting files and SKILL.md body are preserved.
if (directoriesDiffer) {
fs.renameSync(oldDir, newDir);
}
const newPath = path.join(newDir, 'SKILL.md');
try {
const mdData = parseMdFile(newPath);
mdData.frontmatter = {
...mdData.frontmatter,
name: newName,
};
writeMdFile(newPath, mdData.frontmatter, mdData.body);
} catch (error) {
if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) {
try {
fs.renameSync(newDir, oldDir);
} catch (rollbackError) {
console.error(`Failed to rollback skill rename from ${newDir} to ${oldDir}:`, rollbackError);
}
}
throw error;
}
console.log(`Renamed skill: ${oldName} -> ${newName} (path: ${newPath})`);
}
export {
getSkillSources,
discoverSkills,
@@ -599,4 +733,6 @@ export {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
};
+233 -1
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import fsPromises from 'fs/promises';
import os from 'os';
import path from 'path';
import { getSkillSources, mergeDiscoveredSkills } from './skills.js';
import { discoverSkills, getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js';
describe('skills', () => {
it('merges locally discovered skills missing from OpenCode live discovery', () => {
@@ -24,6 +25,43 @@ describe('skills', () => {
]);
});
it('discovers repository-local .agents skills for the project directory', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-project-agents-'));
const skillDir = path.join(tempRoot, '.agents', 'skills', 'repo-local-skill');
const skillPath = path.join(skillDir, 'SKILL.md');
try {
await fsPromises.mkdir(skillDir, { recursive: true });
await fsPromises.mkdir(path.join(tempRoot, '.git'));
await fsPromises.writeFile(
skillPath,
[
'---',
'name: repo-local-skill',
'description: Repository-local agents skill',
'---',
'',
'Use this skill in this repository.',
'',
].join('\n'),
'utf8',
);
const discovered = discoverSkills(tempRoot);
const match = discovered.find((skill) => skill.name === 'repo-local-skill');
expect(match).toEqual({
name: 'repo-local-skill',
path: skillPath,
scope: 'project',
source: 'agents',
description: 'Repository-local agents skill',
});
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('resolves built-in OpenCode skill content without parsing virtual locations as files', () => {
const sources = getSkillSources(
'customize-opencode',
@@ -110,4 +148,198 @@ describe('skills', () => {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('renames a skill directory while preserving SKILL.md body and supporting files', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-'));
const projectRoot = path.join(tempRoot, 'project');
const skillDir = path.join(projectRoot, '.opencode', 'skills', 'original-skill');
const skillPath = path.join(skillDir, 'SKILL.md');
const supportPath = path.join(skillDir, 'notes.md');
const body = [
'# Original Skill',
'',
'Preserve this non-trivial body across rename.',
'',
'## Details',
'',
'- step one',
'- step two',
].join('\n');
try {
await fsPromises.mkdir(skillDir, { recursive: true });
await fsPromises.writeFile(
skillPath,
[
'---',
'name: original-skill',
'description: Original skill description',
'license: MIT',
'---',
'',
body,
'',
].join('\n'),
'utf8',
);
await fsPromises.writeFile(supportPath, 'supporting file contents\n', 'utf8');
renameSkill('original-skill', 'renamed-skill', projectRoot);
const renamedDir = path.join(projectRoot, '.opencode', 'skills', 'renamed-skill');
const renamedPath = path.join(renamedDir, 'SKILL.md');
const renamedSupportPath = path.join(renamedDir, 'notes.md');
expect(fs.existsSync(skillDir)).toBe(false);
expect(fs.existsSync(renamedPath)).toBe(true);
expect(fs.existsSync(renamedSupportPath)).toBe(true);
const sources = getSkillSources('renamed-skill', projectRoot, {
name: 'renamed-skill',
path: renamedPath,
scope: 'project',
source: 'opencode',
description: 'fallback',
});
expect(sources.md.exists).toBe(true);
expect(sources.md.name).toBe('renamed-skill');
expect(sources.md.description).toBe('Original skill description');
expect(sources.md.instructions).toBe(body);
expect(await fsPromises.readFile(renamedSupportPath, 'utf8')).toBe('supporting file contents\n');
const raw = await fsPromises.readFile(renamedPath, 'utf8');
expect(raw).toContain('license: MIT');
expect(raw).not.toContain('Renamed skill');
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('rolls back the directory rename when frontmatter write fails', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-rollback-'));
const projectRoot = path.join(tempRoot, 'project');
const skillDir = path.join(projectRoot, '.opencode', 'skills', 'rollback-skill');
const skillPath = path.join(skillDir, 'SKILL.md');
const body = '# Rollback body\n\nMust remain in the original directory.';
try {
await fsPromises.mkdir(skillDir, { recursive: true });
await fsPromises.writeFile(
skillPath,
[
'---',
'name: rollback-skill',
'description: Rollback skill',
'---',
'',
body,
'',
].join('\n'),
'utf8',
);
await fsPromises.chmod(skillPath, 0o444);
expect(() => renameSkill('rollback-skill', 'rollback-skill-renamed', projectRoot)).toThrow();
expect(fs.existsSync(skillDir)).toBe(true);
expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'rollback-skill-renamed'))).toBe(false);
expect(await fsPromises.readFile(skillPath, 'utf8')).toContain(body);
} finally {
try {
await fsPromises.chmod(skillPath, 0o644);
} catch {
// Best-effort cleanup when the file was rolled back under a different mode.
}
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('rejects invalid names, missing skills, conflicts, unmanaged paths, and frontmatter mismatches', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-reject-'));
const projectRoot = path.join(tempRoot, 'project');
const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-skill');
const conflictDir = path.join(projectRoot, '.opencode', 'skills', 'taken-name');
const mismatchDir = path.join(projectRoot, '.opencode', 'skills', 'folder-name');
const cacheStamp = `oc-rename-${Date.now()}`;
const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-skill');
try {
await fsPromises.mkdir(managedDir, { recursive: true });
await fsPromises.writeFile(
path.join(managedDir, 'SKILL.md'),
[
'---',
'name: managed-skill',
'description: Managed',
'---',
'',
'Managed body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(conflictDir, { recursive: true });
await fsPromises.writeFile(
path.join(conflictDir, 'SKILL.md'),
[
'---',
'name: taken-name',
'description: Taken',
'---',
'',
'Taken body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(mismatchDir, { recursive: true });
await fsPromises.writeFile(
path.join(mismatchDir, 'SKILL.md'),
[
'---',
'name: frontmatter-name',
'description: Mismatch',
'---',
'',
'Mismatch body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(cacheDir, { recursive: true });
await fsPromises.writeFile(
path.join(cacheDir, 'SKILL.md'),
[
'---',
'name: cache-skill',
'description: Cache skill',
'---',
'',
'Cache body',
'',
].join('\n'),
'utf8',
);
expect(() => renameSkill('managed-skill', 'Invalid_Name', projectRoot)).toThrow(/Invalid skill name/);
expect(() => renameSkill('missing-skill', 'new-skill', projectRoot)).toThrow(/not found/);
expect(() => renameSkill('managed-skill', 'taken-name', projectRoot)).toThrow(/already exists/);
expect(() => renameSkill('folder-name', 'renamed-mismatch', projectRoot)).toThrow(/does not match/);
expect(() => renameSkill('cache-skill', 'cache-skill-renamed', projectRoot)).toThrow(/managed skill directories/);
expect(fs.existsSync(managedDir)).toBe(true);
expect(fs.existsSync(cacheDir)).toBe(true);
expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'renamed-mismatch'))).toBe(false);
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
await fsPromises.rm(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), {
recursive: true,
force: true,
});
}
});
});
@@ -0,0 +1,44 @@
const ENABLED_VALUES = new Set(['1', 'true']);
const ALLOWED_PHASES = new Set([
'web.pipeline.start',
'web.listener.ready',
'opencode.bootstrap.start',
'opencode.bootstrap.ready',
'opencode.bootstrap.error',
'opencode.orphan-reap.ready',
'opencode.attempt.start',
'opencode.binary.ready',
'opencode.environment.ready',
'opencode.process.ready',
'opencode.health.ready',
'opencode.attempt.error',
'proxy.readiness-hold',
]);
const ALLOWED_OUTCOMES = new Set(['ready', 'timeout', 'aborted', 'error']);
const ALLOWED_ROUTE_CLASSES = new Set(['session-messages', 'session', 'events', 'other']);
const finiteNonNegative = (value) => Number.isFinite(value) && value >= 0 ? value : undefined;
const nonNegativeInteger = (value) => Number.isInteger(value) && value >= 0 ? value : undefined;
const isStartupPerformanceEnabled = () => (
ENABLED_VALUES.has(String(process.env.OPENCHAMBER_STARTUP_PERF ?? '').toLowerCase())
);
export const recordStartupPerformance = (phase, details = {}) => {
if (!isStartupPerformanceEnabled() || !ALLOWED_PHASES.has(phase)) return;
const event = {
phase,
at: Date.now(),
};
const durationMs = finiteNonNegative(details.durationMs);
const totalDurationMs = finiteNonNegative(details.totalDurationMs);
const attempt = nonNegativeInteger(details.attempt);
if (durationMs !== undefined) event.durationMs = durationMs;
if (totalDurationMs !== undefined) event.totalDurationMs = totalDurationMs;
if (attempt !== undefined) event.attempt = attempt;
if (ALLOWED_OUTCOMES.has(details.outcome)) event.outcome = details.outcome;
if (ALLOWED_ROUTE_CLASSES.has(details.routeClass)) event.routeClass = details.routeClass;
console.info('[startup-performance]', event);
};
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { recordStartupPerformance } from './startup-performance.js';
describe('startup performance diagnostics', () => {
const previousValue = process.env.OPENCHAMBER_STARTUP_PERF;
afterEach(() => {
if (previousValue === undefined) delete process.env.OPENCHAMBER_STARTUP_PERF;
else process.env.OPENCHAMBER_STARTUP_PERF = previousValue;
vi.restoreAllMocks();
});
it('is disabled by default', () => {
delete process.env.OPENCHAMBER_STARTUP_PERF;
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
recordStartupPerformance('opencode.health.ready', { durationMs: 5 });
expect(info).not.toHaveBeenCalled();
});
it('records only approved labels and numeric metadata', () => {
process.env.OPENCHAMBER_STARTUP_PERF = '1';
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
recordStartupPerformance('proxy.readiness-hold', {
durationMs: 75,
totalDurationMs: 100,
attempt: 1,
outcome: 'ready',
routeClass: 'session-messages',
sessionID: 'secret-session',
directory: '/secret/directory',
token: 'secret-token',
});
expect(info).toHaveBeenCalledOnce();
const event = info.mock.calls[0][1];
expect(event).toMatchObject({
phase: 'proxy.readiness-hold',
durationMs: 75,
totalDurationMs: 100,
attempt: 1,
outcome: 'ready',
routeClass: 'session-messages',
});
expect(Number.isFinite(event.at)).toBe(true);
expect(JSON.stringify(event)).not.toContain('secret');
});
it('rejects unknown phases and invalid field values', () => {
process.env.OPENCHAMBER_STARTUP_PERF = 'true';
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
recordStartupPerformance('secret.phase', { durationMs: 1 });
recordStartupPerformance('opencode.bootstrap.error', {
durationMs: -1,
attempt: 1.5,
outcome: 'secret-outcome',
routeClass: 'secret-route',
});
expect(info).toHaveBeenCalledOnce();
expect(info.mock.calls[0][1]).toEqual(expect.objectContaining({
phase: 'opencode.bootstrap.error',
}));
expect(info.mock.calls[0][1]).not.toHaveProperty('durationMs');
expect(info.mock.calls[0][1]).not.toHaveProperty('attempt');
expect(info.mock.calls[0][1]).not.toHaveProperty('outcome');
expect(info.mock.calls[0][1]).not.toHaveProperty('routeClass');
});
});
@@ -1,11 +1,16 @@
import { recordStartupPerformance } from './startup-performance.js';
export const createStartupPipelineRuntime = (dependencies) => {
const {
createTerminalRuntime,
createDictationRuntime,
createMessageStreamWsRuntime,
createServerStartupRuntime,
} = dependencies;
const run = async (options) => {
const pipelineStartedAt = performance.now();
recordStartupPerformance('web.pipeline.start');
const {
app,
server,
@@ -52,6 +57,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
tunnelRuntimeContext,
attachSignals,
apiOnly,
dictationModelsDir,
} = options;
const terminalRuntime = createTerminalRuntime({
@@ -71,6 +77,16 @@ export const createStartupPipelineRuntime = (dependencies) => {
TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: terminalMaxRebindsPerWindow,
});
const dictationRuntime = createDictationRuntime({
app,
server,
express,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
modelsDir: dictationModelsDir,
});
const messageStreamRuntime = createMessageStreamWsRuntime({
server,
uiAuthController,
@@ -86,8 +102,6 @@ export const createStartupPipelineRuntime = (dependencies) => {
});
setupProxy(app);
scheduleOpenCodeApiDetection();
void bootstrapOpenCodeAtStartup();
if (apiOnly) {
staticRoutesRuntime.registerApiOnlyFallbackRoutes(app);
@@ -119,12 +133,18 @@ export const createStartupPipelineRuntime = (dependencies) => {
startupTunnelRequest,
onTunnelReady,
});
recordStartupPerformance('web.listener.ready', {
durationMs: performance.now() - pipelineStartedAt,
});
tunnelRuntimeContext.setActivePort(startupResult.activePort);
scheduleOpenCodeApiDetection();
void bootstrapOpenCodeAtStartup();
serverStartupRuntime.attachProcessHandlers({ attachSignals });
return {
terminalRuntime,
dictationRuntime,
messageStreamRuntime,
};
};
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { createStartupPipelineRuntime } from './startup-pipeline-runtime.js';
describe('startup pipeline runtime', () => {
it('publishes the listening port before bootstrapping managed OpenCode', async () => {
const order = [];
const runtime = createStartupPipelineRuntime({
createTerminalRuntime: () => ({}),
createDictationRuntime: () => ({}),
createMessageStreamWsRuntime: () => ({}),
createServerStartupRuntime: () => ({
resolveBindHost: () => '127.0.0.1',
startListeningAndMaybeTunnel: async () => {
order.push('listen');
return { activePort: 3901 };
},
attachProcessHandlers: vi.fn(),
}),
});
await runtime.run({
app: {},
setupProxy: vi.fn(),
staticRoutesRuntime: { registerStaticRoutes: vi.fn() },
apiOnly: false,
tunnelRuntimeContext: {
setActivePort: (port) => order.push(`port:${port}`),
},
scheduleOpenCodeApiDetection: () => order.push('detect'),
bootstrapOpenCodeAtStartup: () => order.push('bootstrap'),
process: {},
crypto: {},
server: {},
attachSignals: false,
});
expect(order).toEqual(['listen', 'port:3901', 'detect', 'bootstrap']);
});
});
@@ -172,15 +172,10 @@ const isLocalHost = (host, req) => {
return false;
}
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
return true;
}
if (host === 'host.docker.internal') {
return isPrivateOrLoopbackIp(getSocketRemoteIp(req));
}
return false;
const isLocalHostname = host === 'localhost'
|| host === 'host.docker.internal'
|| isPrivateOrLoopbackIp(host);
return isLocalHostname && isPrivateOrLoopbackIp(getSocketRemoteIp(req));
};
const getClientIp = (req) => {
@@ -0,0 +1,50 @@
// TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
// FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
// Disable OpenCode self-upgrade on ARM64 so it can't overwrite the working x64
// binary with the broken ARM64 build. Remove when the upstream issue is resolved.
const isWindowsArm64 = () => process.platform === 'win32' && process.arch === 'arm64';
export const resolveOpenCodeUpgradeCapability = ({
isExternal,
hasManagedProcess,
activeBinary,
isBundledBinary,
}) => {
if (isWindowsArm64()) {
return {
supported: false,
manager: 'openchamber',
reason: 'windows-arm64-workaround',
};
}
if (isExternal) {
return {
supported: false,
manager: 'external',
reason: 'external',
};
}
if (!hasManagedProcess || !activeBinary) {
return {
supported: false,
manager: null,
reason: 'unavailable',
};
}
if (isBundledBinary(activeBinary)) {
return {
supported: false,
manager: 'openchamber',
reason: 'bundled',
};
}
return {
supported: true,
manager: 'opencode',
reason: null,
};
};
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveOpenCodeUpgradeCapability } from './upgrade-capability.js';
describe('OpenCode upgrade capability', () => {
it('assigns bundled binaries to the OpenChamber updater', () => {
const isBundledBinary = vi.fn(() => true);
expect(resolveOpenCodeUpgradeCapability({
isExternal: false,
hasManagedProcess: true,
activeBinary: '/Applications/OpenChamber.app/Contents/Resources/opencode-cli/opencode',
isBundledBinary,
})).toEqual({
supported: false,
manager: 'openchamber',
reason: 'bundled',
});
});
it('never upgrades external or unresolved runtimes', () => {
const isBundledBinary = vi.fn(() => false);
expect(resolveOpenCodeUpgradeCapability({
isExternal: true,
hasManagedProcess: false,
activeBinary: null,
isBundledBinary,
})).toEqual({
supported: false,
manager: 'external',
reason: 'external',
});
expect(resolveOpenCodeUpgradeCapability({
isExternal: false,
hasManagedProcess: false,
activeBinary: '/usr/local/bin/opencode',
isBundledBinary,
})).toEqual({
supported: false,
manager: null,
reason: 'unavailable',
});
});
it('allows OpenCode to upgrade a managed non-bundled binary', () => {
expect(resolveOpenCodeUpgradeCapability({
isExternal: false,
hasManagedProcess: true,
activeBinary: '/Users/alice/.opencode/bin/opencode',
isBundledBinary: () => false,
})).toEqual({
supported: true,
manager: 'opencode',
reason: null,
});
});
});