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