Merge branch 'main' into reproduce/issue-1720
Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# Managed OpenChamber Agent Tool
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes OpenChamber orchestration to agents as one typed OpenCode
|
||||
custom tool named `openchamber`. It is injected only when OpenChamber launches
|
||||
and owns the OpenCode process, and only while the persisted
|
||||
`agentControlToolEnabled` setting is not `false` (default on; toggled in
|
||||
Settings → General → OpenCode CLI and applied on the next managed OpenCode
|
||||
restart).
|
||||
|
||||
## Runtime flow
|
||||
|
||||
1. The OpenChamber HTTP listener binds and publishes its authoritative port.
|
||||
2. `prepareManagedOpenCodeEnv()` materializes the plugin under
|
||||
`<openchamber-data-dir>/agent-tool/` and appends its `file://` URL to
|
||||
`OPENCODE_CONFIG_CONTENT` without replacing existing plugin entries.
|
||||
3. A random per-child token and loopback callback URL are added only to the
|
||||
managed OpenCode child environment.
|
||||
4. The plugin calls `POST /api/openchamber/agent-tool` with its typed input and
|
||||
OpenCode's authoritative session directory.
|
||||
5. The route delegates the fixed action allowlist directly to the shared
|
||||
OpenChamber control service. The CLI uses the same service through its
|
||||
authenticated HTTP adapter, so Goal Mode ordering, wait behavior,
|
||||
partial-failure reporting, and scheduled-task contracts have one owner.
|
||||
6. Each action definition owns a short presentation title and a separate
|
||||
agent-facing description. The generated schema uses the description to state
|
||||
required inputs or one non-obvious behavior, while completed calls use the
|
||||
short title in native tool metadata.
|
||||
|
||||
## Agent context budget
|
||||
|
||||
- The tool exposes one shared parameter object rather than repeating parameters
|
||||
in a large per-action union. Action descriptions carry only required inputs,
|
||||
defaults, or one non-obvious semantic detail.
|
||||
- Obvious fields rely on their names and JSON types. Parameter descriptions are
|
||||
reserved for formats, dependencies, scope, and behavior that cannot be safely
|
||||
inferred from the field name.
|
||||
- Session dispatches do not wait by default. Agents are told to set `wait` only
|
||||
when the user asks or the next step requires the completed result.
|
||||
- The tool exposes only agent-relevant actions
|
||||
(`OPENCHAMBER_AGENT_TOOL_ACTIONS`): `schedule.status` stays CLI-only because
|
||||
`schedule.list` already returns scheduler status, and enable/disable are one
|
||||
`schedule.toggle` action driven by the `disabled` boolean.
|
||||
- The tool description frames intent: created sessions and scheduled tasks are
|
||||
user-facing work the user follows up with, never a channel for the agent to
|
||||
delegate parts of its own current task.
|
||||
- Optional behavior switches (`worktree`, `goal`, `agent`, `variant`, `wait`)
|
||||
state their default and an explicit "only when the user asks" rule so agents
|
||||
do not invent worktrees, goal mode, or waits the user never requested.
|
||||
- Detailed combination rules are enforced by the shared control service and
|
||||
returned as actionable usage errors only after an invalid call. Per-action
|
||||
examples and a repeated per-action parameter schema are intentionally omitted.
|
||||
|
||||
## Security invariants
|
||||
|
||||
- The callback accepts loopback requests only and requires the current
|
||||
per-child bearer token using a timing-safe comparison.
|
||||
- The token is never persisted, logged, returned to the UI, or written into
|
||||
the materialized plugin.
|
||||
- Inputs map to a fixed action and parameter allowlist. There is no arbitrary
|
||||
CLI, shell, route, or URL forwarding.
|
||||
- Session/worktree deletion and project-path registration are not exposed.
|
||||
- An aborted tool request propagates an abort signal into the shared service.
|
||||
|
||||
## Result contract
|
||||
|
||||
Every completed call returns JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"ok": true,
|
||||
"action": "session.create",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Command and operational failures use the same envelope with `ok: false` and
|
||||
an `error` object. OpenCode-level cancellation can still produce a native tool
|
||||
error state.
|
||||
|
||||
## Runtime parity
|
||||
|
||||
- Web and Desktop managed OpenCode: injected automatically.
|
||||
- External OpenCode selected with `OPENCODE_HOST` or skip-start: not injected,
|
||||
because OpenChamber does not control that process environment.
|
||||
- VS Code: not injected; the extension owns a separate OpenCode lifecycle.
|
||||
- Hosted and Capacitor mobile clients use the server's managed OpenCode tool
|
||||
when connected to such a server; no tool runs in the client runtime.
|
||||
@@ -0,0 +1,256 @@
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS,
|
||||
OPENCHAMBER_AGENT_TOOL_ACTIONS,
|
||||
} from '../openchamber-control/actions.js';
|
||||
|
||||
const TOOL_SCHEMA_VERSION = 1;
|
||||
const ACTIONS = new Set(OPENCHAMBER_AGENT_TOOL_ACTIONS);
|
||||
const AGENT_TOOL_ACTION_TITLES = Object.fromEntries(
|
||||
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action, title }) => [action, title]),
|
||||
);
|
||||
|
||||
const PLUGIN_PARAMETER_PROPERTIES = {
|
||||
projectId: { type: 'string', description: 'Configured project ID; do not combine with directory' },
|
||||
directory: { type: 'string', description: 'Absolute checkout or session directory; defaults to the current session directory' },
|
||||
sessionId: { type: 'string' },
|
||||
messageId: { type: 'string', description: 'Optional fork boundary message ID' },
|
||||
taskId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
prompt: { type: 'string' },
|
||||
model: { type: 'string', description: 'Model in provider/model format. When the user names no model: for session.create pick a suitable one from models.list favorites or recents (omit if there are none); for send and fork omit it — the session reuses its previous model' },
|
||||
agent: { type: 'string', description: 'OpenCode agent name; new sessions default to the build agent and existing sessions keep their previous one. Set only when the user explicitly requests a different agent' },
|
||||
variant: { type: 'string', description: 'Model variant; use only when the user explicitly requests it' },
|
||||
worktree: { type: 'string', description: 'New worktree name for session.create. Omit by default; use only when the user explicitly asks for an isolated worktree. Uncommitted changes do not carry over into a new worktree' },
|
||||
branch: { type: 'string', description: 'Branch name for the new worktree' },
|
||||
startRef: { type: 'string', description: 'Git ref used to create the new worktree' },
|
||||
setUpstream: { type: 'boolean', description: 'Make the new worktree branch track its upstream' },
|
||||
goal: { type: 'boolean', description: 'Run the dispatched prompt in Goal Mode; use only when the user explicitly requests it' },
|
||||
goalTokenBudget: { type: 'integer', minimum: 1000, maximum: 100_000_000, description: 'Goal token budget; requires goal' },
|
||||
wait: { type: 'boolean', description: 'Wait for current session activity to become idle. Omit by default; use only when the user asks or the next step requires the completed result' },
|
||||
timeout: { type: 'integer', minimum: 1, maximum: 86_400, description: 'Wait timeout in seconds (default 600); requires wait' },
|
||||
lastAssistant: { type: 'boolean', description: 'Return the last assistant text; create/send/fork require wait' },
|
||||
limit: { type: 'integer', minimum: 1, description: 'Maximum sessions or messages to return (default 10)' },
|
||||
all: { type: 'boolean', description: 'Include archived sessions or all messages, depending on the action' },
|
||||
last: { type: 'boolean', description: 'Return only the last matching session message' },
|
||||
withStatus: { type: 'boolean', description: 'Include authoritative status in session.list' },
|
||||
role: { type: 'string', enum: ['all', 'user', 'assistant'], description: 'Message role filter' },
|
||||
name: { type: 'string' },
|
||||
daily: { type: 'string', description: 'Daily run time in HH:mm format' },
|
||||
weekly: { type: 'string', description: 'Comma-separated weekdays; 0=Sunday and 6=Saturday' },
|
||||
once: { type: 'string', description: 'One-time run date in YYYY-MM-DD format' },
|
||||
time: { type: 'string', description: 'Weekly or one-time run time in HH:mm format' },
|
||||
cron: { type: 'string', description: 'Cron expression' },
|
||||
timezone: { type: 'string', description: 'IANA timezone' },
|
||||
disabled: { type: 'boolean', description: 'true disables and false enables; required for schedule.toggle' },
|
||||
};
|
||||
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const createResult = ({ ok, action, data, error, exitCode }) => ({
|
||||
schemaVersion: TOOL_SCHEMA_VERSION,
|
||||
ok,
|
||||
action: action || 'unknown',
|
||||
...(data !== undefined ? { data } : {}),
|
||||
...(error ? { error } : {}),
|
||||
...(Number.isInteger(exitCode) ? { exitCode } : {}),
|
||||
});
|
||||
|
||||
const isLoopbackAddress = (value) => {
|
||||
const address = typeof value === 'string' ? value.toLowerCase() : '';
|
||||
return address === '127.0.0.1'
|
||||
|| address === '::1'
|
||||
|| address === '::ffff:127.0.0.1';
|
||||
};
|
||||
|
||||
const createPluginSource = () => String.raw`
|
||||
export const OpenChamberPlugin = async () => ({
|
||||
tool: {
|
||||
openchamber: {
|
||||
description: "Control OpenChamber projects, sessions, and scheduled tasks on the user's behalf. Sessions and scheduled tasks you create are for the user to follow and interact with; never use this tool to delegate parts of your own current task. Use one action per call. Scope with projectId or directory; omit both to use the current session directory. Session dispatches return immediately by default and you receive no notification when a dispatched session finishes, so never promise to report back on it; the user follows it in OpenChamber; a dispatched session needs no follow-up from you. If the user later asks how it went, use session.messages (add wait to block until it is idle, lastAssistant for just the final answer) — session.send always sends a NEW prompt and never just waits. Set wait only when the user asks or the next step requires the completed result. Session and worktree deletion are unavailable.",
|
||||
args: {
|
||||
action: { type: "string", enum: ${JSON.stringify(OPENCHAMBER_AGENT_TOOL_ACTIONS)}, oneOf: ${JSON.stringify(OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action, description }) => ({ const: action, description })))}, description: "OpenChamber action to perform" },
|
||||
parameters: { type: "object", properties: ${JSON.stringify(PLUGIN_PARAMETER_PROPERTIES)}, additionalProperties: false, description: "Inputs for the action; use an empty object when none are needed" },
|
||||
},
|
||||
async execute(input, context) {
|
||||
const args = { ...(input.parameters ?? {}), action: input.action }
|
||||
const actionTitles = ${JSON.stringify(AGENT_TOOL_ACTION_TITLES)}
|
||||
const title = Object.hasOwn(actionTitles, args.action) ? actionTitles[args.action] : args.action
|
||||
context.metadata({
|
||||
title,
|
||||
metadata: {
|
||||
openchamber: {
|
||||
schemaVersion: ${TOOL_SCHEMA_VERSION},
|
||||
action: args.action,
|
||||
description: title,
|
||||
},
|
||||
},
|
||||
})
|
||||
const endpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL
|
||||
const token = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN
|
||||
const failure = (payload) => ({
|
||||
title,
|
||||
output: JSON.stringify(payload),
|
||||
metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: false } },
|
||||
})
|
||||
if (!endpoint || !token) {
|
||||
return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber managed tool connection is unavailable" } })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer " + token,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ input: args, contextDirectory: context.directory }),
|
||||
signal: context.abort,
|
||||
})
|
||||
const output = await response.text()
|
||||
let result = null
|
||||
try { result = JSON.parse(output) } catch {}
|
||||
const valid = result?.schemaVersion === ${TOOL_SCHEMA_VERSION} && typeof result?.ok === "boolean" && typeof result?.action === "string"
|
||||
context.metadata({
|
||||
title,
|
||||
metadata: {
|
||||
openchamber: {
|
||||
schemaVersion: ${TOOL_SCHEMA_VERSION},
|
||||
action: args.action,
|
||||
description: title,
|
||||
ok: valid && result.ok === true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (valid) return { title, output, metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: result.ok === true } } }
|
||||
return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber returned an invalid response", kind: "runtime", status: response.status } })
|
||||
} catch (error) {
|
||||
if (context.abort.aborted) throw error
|
||||
return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: error instanceof Error ? error.message : String(error), kind: "runtime" } })
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
`;
|
||||
|
||||
const mergePluginConfig = (rawConfig, pluginUrl) => {
|
||||
const errors = [];
|
||||
const parsed = asNonEmptyString(rawConfig) ? parseJsonc(rawConfig, errors, { allowTrailingComma: true }) : {};
|
||||
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its managed tool');
|
||||
}
|
||||
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
|
||||
throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its managed tool');
|
||||
}
|
||||
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
|
||||
parsed.plugin = [
|
||||
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
|
||||
pluginUrl,
|
||||
];
|
||||
return JSON.stringify(parsed);
|
||||
};
|
||||
|
||||
export const createAgentToolRuntime = (dependencies) => {
|
||||
const {
|
||||
crypto,
|
||||
fsPromises,
|
||||
path,
|
||||
dataDir,
|
||||
getActivePort,
|
||||
executeAction,
|
||||
env = process.env,
|
||||
} = dependencies;
|
||||
const pluginDirectory = path.join(dataDir, 'agent-tool');
|
||||
const pluginPath = path.join(pluginDirectory, 'openchamber-plugin.js');
|
||||
let activeToken = null;
|
||||
|
||||
const prepareManagedOpenCodeEnv = async () => {
|
||||
const port = getActivePort();
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
throw new Error('OpenChamber listener port is unavailable for managed tool injection');
|
||||
}
|
||||
await fsPromises.mkdir(pluginDirectory, { recursive: true });
|
||||
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
|
||||
activeToken = crypto.randomBytes(32).toString('base64url');
|
||||
const pluginUrl = pathToFileURL(pluginPath).href;
|
||||
return {
|
||||
OPENCODE_CONFIG_CONTENT: mergePluginConfig(env.OPENCODE_CONFIG_CONTENT, pluginUrl),
|
||||
OPENCHAMBER_AGENT_TOOL_URL: `http://127.0.0.1:${port}/api/openchamber/agent-tool`,
|
||||
OPENCHAMBER_AGENT_TOOL_TOKEN: activeToken,
|
||||
};
|
||||
};
|
||||
|
||||
const authorize = (req) => {
|
||||
if (!activeToken || !isLoopbackAddress(req.socket?.remoteAddress)) return false;
|
||||
const header = asNonEmptyString(req.headers?.authorization);
|
||||
if (!header?.startsWith('Bearer ')) return false;
|
||||
const provided = Buffer.from(header.slice(7));
|
||||
const expected = Buffer.from(activeToken);
|
||||
return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
|
||||
};
|
||||
|
||||
const execute = async (payload = {}, options = {}) => {
|
||||
const action = asNonEmptyString(payload.input?.action);
|
||||
if (!action || !ACTIONS.has(action)) {
|
||||
return createResult({ ok: false, action, error: { message: `Unsupported OpenChamber action: ${action || 'missing'}`, kind: 'usage' } });
|
||||
}
|
||||
if (typeof executeAction !== 'function') {
|
||||
return createResult({ ok: false, action, error: { message: 'OpenChamber control service is unavailable', kind: 'runtime' } });
|
||||
}
|
||||
try {
|
||||
const data = await executeAction(action, payload.input, payload.contextDirectory, options);
|
||||
return createResult({ ok: true, action, data });
|
||||
} catch (error) {
|
||||
return createResult({
|
||||
ok: false,
|
||||
action,
|
||||
...(error?.partial === true ? { data: {
|
||||
partial: true,
|
||||
partialAction: error.partialAction,
|
||||
sessionId: error.sessionId,
|
||||
directory: error.directory,
|
||||
} } : {}),
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
kind: Number(error?.statusCode) >= 400 && Number(error?.statusCode) < 499 ? 'usage' : 'runtime',
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const registerRoutes = (app, express) => {
|
||||
app.post('/api/openchamber/agent-tool', express.json({ limit: '1mb' }), async (req, res) => {
|
||||
if (!authorize(req)) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const controller = new AbortController();
|
||||
const abortOnDisconnect = () => {
|
||||
if (!res.writableEnded) controller.abort();
|
||||
};
|
||||
req.once('aborted', abortOnDisconnect);
|
||||
res.once('close', abortOnDisconnect);
|
||||
try {
|
||||
return res.json(await execute(req.body, { signal: controller.signal }));
|
||||
} catch (error) {
|
||||
return res.json(createResult({
|
||||
ok: false,
|
||||
action: req.body?.input?.action,
|
||||
error: { message: error instanceof Error ? error.message : String(error), kind: 'runtime' },
|
||||
}));
|
||||
} finally {
|
||||
req.off('aborted', abortOnDisconnect);
|
||||
res.off('close', abortOnDisconnect);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
prepareManagedOpenCodeEnv,
|
||||
registerRoutes,
|
||||
execute,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAgentToolRuntime } from './runtime.js';
|
||||
import { OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS, OPENCHAMBER_CONTROL_ACTION_DEFINITIONS } from '../openchamber-control/actions.js';
|
||||
|
||||
const temporaryDirectories = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
const createRuntime = async (overrides = {}) => {
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-agent-tool-'));
|
||||
temporaryDirectories.push(dataDir);
|
||||
const executeAction = vi.fn(async () => ({ projects: [] }));
|
||||
const env = {};
|
||||
const runtime = createAgentToolRuntime({
|
||||
crypto,
|
||||
fsPromises: fs,
|
||||
path,
|
||||
dataDir,
|
||||
getActivePort: () => 3901,
|
||||
executeAction,
|
||||
env,
|
||||
...overrides,
|
||||
});
|
||||
return { runtime, dataDir, executeAction, env };
|
||||
};
|
||||
|
||||
describe('agent tool action allowlist', () => {
|
||||
it('defines a short title and agent description for every action', () => {
|
||||
expect(OPENCHAMBER_CONTROL_ACTION_DEFINITIONS.every(({ action, title, description }) => action && title && description)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'projects.list',
|
||||
'models.list',
|
||||
'session.list',
|
||||
'session.create',
|
||||
'session.send',
|
||||
'session.fork',
|
||||
'session.status',
|
||||
'session.messages',
|
||||
'schedule.list',
|
||||
'schedule.create',
|
||||
'schedule.run',
|
||||
'schedule.delete',
|
||||
'schedule.toggle',
|
||||
])('delegates %s to the shared control service', async (action) => {
|
||||
const { runtime, executeAction } = await createRuntime();
|
||||
const input = { action, projectId: 'project-1' };
|
||||
await runtime.execute({ input, contextDirectory: '/work/project' });
|
||||
expect(executeAction).toHaveBeenCalledWith(action, input, '/work/project', {});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'session.delete',
|
||||
'schedule.status',
|
||||
])('rejects %s outside the agent allowlist without invoking the service', async (action) => {
|
||||
const { runtime, executeAction } = await createRuntime();
|
||||
await expect(runtime.execute({ input: { action } })).resolves.toEqual(expect.objectContaining({
|
||||
ok: false,
|
||||
action,
|
||||
error: expect.objectContaining({ kind: 'usage' }),
|
||||
}));
|
||||
expect(executeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed agent tool runtime', () => {
|
||||
it('materializes the plugin and preserves configured plugin entries', async () => {
|
||||
const { runtime, dataDir, env } = await createRuntime();
|
||||
env.OPENCODE_CONFIG_CONTENT = '{ // existing\n "plugin": ["file:///existing.js", ["example-plugin", {"flag": true}]], "model": "test/model" }';
|
||||
|
||||
const preparedEnv = await runtime.prepareManagedOpenCodeEnv();
|
||||
const config = JSON.parse(preparedEnv.OPENCODE_CONFIG_CONTENT);
|
||||
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
|
||||
const source = await fs.readFile(pluginPath, 'utf8');
|
||||
|
||||
expect(config.model).toBe('test/model');
|
||||
expect(config.plugin).toEqual([
|
||||
'file:///existing.js',
|
||||
['example-plugin', { flag: true }],
|
||||
expect.stringContaining('/agent-tool/openchamber-plugin.js'),
|
||||
]);
|
||||
expect(preparedEnv.OPENCHAMBER_AGENT_TOOL_URL).toBe('http://127.0.0.1:3901/api/openchamber/agent-tool');
|
||||
expect(preparedEnv.OPENCHAMBER_AGENT_TOOL_TOKEN).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(source).toContain('openchamber: {');
|
||||
for (const { action, description } of OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS) {
|
||||
expect(source).toContain(JSON.stringify({ const: action, description }));
|
||||
}
|
||||
expect(source).not.toContain('"schedule.status"');
|
||||
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?schema=${Date.now()}`);
|
||||
const hooks = await pluginModule.OpenChamberPlugin();
|
||||
expect(hooks.tool.openchamber.description).toContain('Session dispatches return immediately by default');
|
||||
expect(hooks.tool.openchamber.description).toContain('Set wait only when the user asks or the next step requires the completed result');
|
||||
expect(hooks.tool.openchamber.args.action.oneOf).toContainEqual({
|
||||
const: 'session.messages',
|
||||
description: 'Read text-only messages and current sessionStatus for sessionId; directory and limit 10 are defaults',
|
||||
});
|
||||
expect(hooks.tool.openchamber.args.parameters.properties.wait.description).toBe(
|
||||
'Wait for current session activity to become idle. Omit by default; use only when the user asks or the next step requires the completed result',
|
||||
);
|
||||
expect(hooks.tool.openchamber.args.parameters.properties.sessionId).toEqual({ type: 'string' });
|
||||
expect(source).not.toContain('title: "OpenChamber"');
|
||||
expect(source).not.toContain('@opencode-ai/plugin');
|
||||
expect(source).not.toContain(preparedEnv.OPENCHAMBER_AGENT_TOOL_TOKEN);
|
||||
});
|
||||
|
||||
it('executes actions through the shared control service', async () => {
|
||||
const executeAction = vi.fn(async () => ({ projects: [] }));
|
||||
const { runtime } = await createRuntime({ executeAction });
|
||||
const result = await runtime.execute({
|
||||
input: { action: 'projects.list' },
|
||||
contextDirectory: '/work/project',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
schemaVersion: 1,
|
||||
ok: true,
|
||||
action: 'projects.list',
|
||||
data: { projects: [] },
|
||||
});
|
||||
expect(executeAction).toHaveBeenCalledWith('projects.list', { action: 'projects.list' }, '/work/project', {});
|
||||
});
|
||||
|
||||
it('keeps service failures as structured tool results', async () => {
|
||||
const error = Object.assign(new Error('Task not found'), { statusCode: 404 });
|
||||
const { runtime } = await createRuntime({ executeAction: vi.fn(async () => { throw error; }) });
|
||||
|
||||
await expect(runtime.execute({
|
||||
input: { action: 'schedule.run', taskId: 'missing' },
|
||||
contextDirectory: '/work/project',
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
action: 'schedule.run',
|
||||
error: { message: 'Task not found', kind: 'usage' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('forwards cancellation to the shared control service', async () => {
|
||||
const executeAction = vi.fn(async (_action, _input, _directory, options) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('OpenChamber action was cancelled'), { statusCode: 499 })), { once: true });
|
||||
});
|
||||
});
|
||||
const { runtime } = await createRuntime({ executeAction });
|
||||
const controller = new AbortController();
|
||||
const pending = runtime.execute({ input: { action: 'projects.list' } }, { signal: controller.signal });
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).resolves.toEqual(expect.objectContaining({
|
||||
ok: false,
|
||||
action: 'projects.list',
|
||||
error: { message: 'OpenChamber action was cancelled', kind: 'runtime' },
|
||||
}));
|
||||
expect(executeAction).toHaveBeenCalledWith('projects.list', { action: 'projects.list' }, undefined, { signal: controller.signal });
|
||||
});
|
||||
|
||||
it('requires the per-child token on the loopback route', async () => {
|
||||
const { runtime } = await createRuntime();
|
||||
const env = await runtime.prepareManagedOpenCodeEnv();
|
||||
const app = express();
|
||||
runtime.registerRoutes(app, express);
|
||||
|
||||
await request(app)
|
||||
.post('/api/openchamber/agent-tool')
|
||||
.send({ input: { action: 'projects.list' } })
|
||||
.expect(401);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/agent-tool')
|
||||
.set('authorization', `Bearer ${env.OPENCHAMBER_AGENT_TOOL_TOKEN}`)
|
||||
.send({ input: { action: 'projects.list' } })
|
||||
.expect(200);
|
||||
expect(response.body).toEqual(expect.objectContaining({ ok: true, action: 'projects.list' }));
|
||||
});
|
||||
|
||||
it('executes through the materialized plugin and authenticated callback', async () => {
|
||||
let activePort = null;
|
||||
const { runtime, dataDir } = await createRuntime({ getActivePort: () => activePort });
|
||||
const app = express();
|
||||
runtime.registerRoutes(app, express);
|
||||
const server = await new Promise((resolve) => {
|
||||
const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
|
||||
});
|
||||
activePort = server.address().port;
|
||||
|
||||
const previousUrl = process.env.OPENCHAMBER_AGENT_TOOL_URL;
|
||||
const previousToken = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN;
|
||||
try {
|
||||
const env = await runtime.prepareManagedOpenCodeEnv();
|
||||
process.env.OPENCHAMBER_AGENT_TOOL_URL = env.OPENCHAMBER_AGENT_TOOL_URL;
|
||||
process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = env.OPENCHAMBER_AGENT_TOOL_TOKEN;
|
||||
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
|
||||
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`);
|
||||
const hooks = await pluginModule.OpenChamberPlugin();
|
||||
const metadata = vi.fn();
|
||||
|
||||
const result = await hooks.tool.openchamber.execute(
|
||||
{ action: 'projects.list', parameters: {} },
|
||||
{ directory: '/work/project', abort: new AbortController().signal, metadata },
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.output)).toEqual({
|
||||
schemaVersion: 1,
|
||||
ok: true,
|
||||
action: 'projects.list',
|
||||
data: { projects: [] },
|
||||
});
|
||||
expect(result.title).toBe('List configured projects');
|
||||
expect(result.metadata.openchamber.description).toBe('List configured projects');
|
||||
expect(metadata).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: 'List configured projects',
|
||||
metadata: expect.objectContaining({
|
||||
openchamber: expect.objectContaining({ description: 'List configured projects' }),
|
||||
}),
|
||||
}));
|
||||
} finally {
|
||||
if (previousUrl === undefined) delete process.env.OPENCHAMBER_AGENT_TOOL_URL;
|
||||
else process.env.OPENCHAMBER_AGENT_TOOL_URL = previousUrl;
|
||||
if (previousToken === undefined) delete process.env.OPENCHAMBER_AGENT_TOOL_TOKEN;
|
||||
else process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = previousToken;
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
const STORE_VERSION = 1;
|
||||
const PAIRING_ID_PREFIX = 'pair_';
|
||||
const SECRET_BYTES = 32;
|
||||
const FINGERPRINT_BYTES = 4;
|
||||
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_LABEL_LENGTH = 80;
|
||||
const VALID_CLIENT_KINDS = new Set(['mobile', 'desktop']);
|
||||
const GENERIC_REDEEM_ERROR = 'Invalid or expired pairing session';
|
||||
|
||||
const normalizeOptionalString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
// Placeholder shown in the pending-devices list when the operator did not type a
|
||||
// name. It is a DISPLAY default only — the stored label stays null so redeem can
|
||||
// fall back to the device's own reported name instead of this placeholder.
|
||||
const PAIRING_LABEL_PLACEHOLDER = 'Pair new device';
|
||||
|
||||
// The operator's typed device label, capped. Returns null when unset so callers
|
||||
// can distinguish "no name given" from a real name.
|
||||
const normalizeStoredLabel = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) return null;
|
||||
return normalized.length > MAX_LABEL_LENGTH ? normalized.slice(0, MAX_LABEL_LENGTH) : normalized;
|
||||
};
|
||||
|
||||
const normalizeTimestamp = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) return null;
|
||||
const time = Date.parse(normalized);
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
||||
};
|
||||
|
||||
const normalizeClientKind = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
return normalized && VALID_CLIENT_KINDS.has(normalized) ? normalized : null;
|
||||
};
|
||||
|
||||
const normalizeAllowedClientKinds = (value) => {
|
||||
if (!Array.isArray(value)) return ['mobile', 'desktop'];
|
||||
const kinds = value.map(normalizeClientKind).filter(Boolean);
|
||||
return kinds.length > 0 ? Array.from(new Set(kinds)) : ['mobile', 'desktop'];
|
||||
};
|
||||
|
||||
const safeJsonParse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const constantTimeEqual = (left, right, crypto) => {
|
||||
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
||||
const leftBuffer = Buffer.from(left, 'hex');
|
||||
const rightBuffer = Buffer.from(right, 'hex');
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
};
|
||||
|
||||
const publicSession = (session) => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
usedAt: session.usedAt,
|
||||
cancelledAt: session.cancelledAt,
|
||||
clientId: session.clientId,
|
||||
label: session.label || PAIRING_LABEL_PLACEHOLDER,
|
||||
fingerprint: session.fingerprint,
|
||||
allowedClientKinds: session.allowedClientKinds,
|
||||
createdByClientId: session.createdByClientId,
|
||||
usesRelay: session.usesRelay === true,
|
||||
});
|
||||
|
||||
// A pending session is one that can still be redeemed: not used, not cancelled,
|
||||
// not expired.
|
||||
const isPendingSession = (session) => !session.usedAt
|
||||
&& !session.cancelledAt
|
||||
&& Number.isFinite(Date.parse(session.expiresAt))
|
||||
&& Date.parse(session.expiresAt) > Date.now();
|
||||
|
||||
const redeemError = () => {
|
||||
const error = new Error(GENERIC_REDEEM_ERROR);
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
};
|
||||
|
||||
export const createClientPairingRuntime = ({
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
storePath,
|
||||
remoteClientAuthRuntime,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
} = {}) => {
|
||||
if (!fsPromises || !path || !crypto || !storePath || !remoteClientAuthRuntime) {
|
||||
throw new Error('createClientPairingRuntime requires fsPromises, path, crypto, storePath, and remoteClientAuthRuntime');
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const hashSecret = (secret) => crypto.createHash('sha256').update(secret).digest('hex');
|
||||
const generateId = () => `${PAIRING_ID_PREFIX}${crypto.randomBytes(12).toString('hex')}`;
|
||||
const generateSecret = () => crypto.randomBytes(SECRET_BYTES).toString('base64url');
|
||||
const generateFingerprint = () => crypto.randomBytes(FINGERPRINT_BYTES).toString('hex').toUpperCase().replace(/^(.{4})(.{4})$/, '$1-$2');
|
||||
let storeMutationQueue = Promise.resolve();
|
||||
|
||||
const withStoreMutation = async (fn) => {
|
||||
const previous = storeMutationQueue;
|
||||
let release;
|
||||
storeMutationQueue = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeStore = (payload) => ({
|
||||
version: STORE_VERSION,
|
||||
sessions: Array.isArray(payload?.sessions)
|
||||
? payload.sessions
|
||||
.filter((session) => session && typeof session === 'object')
|
||||
.map((session) => ({
|
||||
id: typeof session.id === 'string' ? session.id : generateId(),
|
||||
secretHash: typeof session.secretHash === 'string' ? session.secretHash : '',
|
||||
createdAt: typeof session.createdAt === 'string' ? session.createdAt : nowIso(),
|
||||
expiresAt: normalizeTimestamp(session.expiresAt) || new Date(Date.now() + ttlMs).toISOString(),
|
||||
usedAt: normalizeTimestamp(session.usedAt),
|
||||
cancelledAt: normalizeTimestamp(session.cancelledAt),
|
||||
clientId: normalizeOptionalString(session.clientId),
|
||||
label: normalizeStoredLabel(session.label),
|
||||
fingerprint: normalizeOptionalString(session.fingerprint) || generateFingerprint(),
|
||||
allowedClientKinds: normalizeAllowedClientKinds(session.allowedClientKinds),
|
||||
createdByClientId: normalizeOptionalString(session.createdByClientId),
|
||||
usesRelay: session.usesRelay === true,
|
||||
}))
|
||||
.filter((session) => session.secretHash.length > 0)
|
||||
: [],
|
||||
});
|
||||
|
||||
const readStore = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(storePath, 'utf8');
|
||||
return normalizeStore(safeJsonParse(raw));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return normalizeStore(null);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStore = async (store) => {
|
||||
await fsPromises.mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 });
|
||||
await fsPromises.writeFile(storePath, JSON.stringify(normalizeStore(store), null, 2), { mode: 0o600 });
|
||||
if (typeof fsPromises.chmod === 'function') {
|
||||
await fsPromises.chmod(storePath, 0o600).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const sweepExpiredSessionsFromStore = (store) => {
|
||||
const now = Date.now();
|
||||
const cutoff = now - ttlMs;
|
||||
store.sessions = store.sessions.filter((session) => {
|
||||
const usedAt = Date.parse(session.usedAt || '');
|
||||
const cancelledAt = Date.parse(session.cancelledAt || '');
|
||||
const inactiveAt = Number.isFinite(usedAt) ? usedAt : cancelledAt;
|
||||
if (Number.isFinite(inactiveAt)) return inactiveAt >= cutoff;
|
||||
// Never used or cancelled: drop once the session itself has expired —
|
||||
// it can no longer be redeemed and would otherwise sit in the store forever.
|
||||
const expiresAt = Date.parse(session.expiresAt || '');
|
||||
return !Number.isFinite(expiresAt) || expiresAt > now;
|
||||
});
|
||||
};
|
||||
|
||||
const createPairingSession = async ({ label, allowedClientKinds, createdByClientId, usesRelay } = {}) => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
sweepExpiredSessionsFromStore(store);
|
||||
const secret = generateSecret();
|
||||
const session = {
|
||||
id: generateId(),
|
||||
secretHash: hashSecret(secret),
|
||||
createdAt: nowIso(),
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
usedAt: null,
|
||||
cancelledAt: null,
|
||||
clientId: null,
|
||||
label: normalizeStoredLabel(label),
|
||||
fingerprint: generateFingerprint(),
|
||||
allowedClientKinds: normalizeAllowedClientKinds(allowedClientKinds),
|
||||
createdByClientId: normalizeOptionalString(createdByClientId),
|
||||
usesRelay: usesRelay === true,
|
||||
};
|
||||
store.sessions.push(session);
|
||||
await writeStore(store);
|
||||
return { pairing: { ...publicSession(session), secret } };
|
||||
});
|
||||
};
|
||||
|
||||
// Sessions that can still be redeemed (link created, device not yet connected).
|
||||
const listPendingSessions = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
return store.sessions.filter(isPendingSession).map(publicSession);
|
||||
});
|
||||
|
||||
// Relay-transport demand from pairing: any still-redeemable relay session.
|
||||
const hasActiveRelaySession = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
return store.sessions.some((session) => session.usesRelay === true && isPendingSession(session));
|
||||
});
|
||||
|
||||
const getPairingSession = async (id) => {
|
||||
const normalizedId = normalizeOptionalString(id);
|
||||
if (!normalizedId) return null;
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
return session ? publicSession(session) : null;
|
||||
});
|
||||
};
|
||||
|
||||
const cancelPairingSession = async (id) => {
|
||||
const normalizedId = normalizeOptionalString(id);
|
||||
if (!normalizedId) return { cancelled: false };
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
if (!session) return { cancelled: false };
|
||||
if (!session.cancelledAt) session.cancelledAt = nowIso();
|
||||
await writeStore(store);
|
||||
return { cancelled: true, pairing: publicSession(session) };
|
||||
});
|
||||
};
|
||||
|
||||
const redeemPairingSession = async ({
|
||||
pairingId,
|
||||
secret,
|
||||
clientLabel,
|
||||
clientKind,
|
||||
deviceName,
|
||||
devicePlatform,
|
||||
deviceModel,
|
||||
appVersion,
|
||||
dedupeKey,
|
||||
} = {}) => {
|
||||
const normalizedId = normalizeOptionalString(pairingId);
|
||||
const normalizedSecret = normalizeOptionalString(secret);
|
||||
const normalizedKind = normalizeClientKind(clientKind) || 'mobile';
|
||||
if (!normalizedId || !normalizedSecret) throw redeemError();
|
||||
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
if (!session) throw redeemError();
|
||||
if (session.cancelledAt || session.usedAt) throw redeemError();
|
||||
if (Date.parse(session.expiresAt) <= Date.now()) throw redeemError();
|
||||
if (!session.allowedClientKinds.includes(normalizedKind)) throw redeemError();
|
||||
if (!constantTimeEqual(session.secretHash, hashSecret(normalizedSecret), crypto)) throw redeemError();
|
||||
|
||||
// The operator's typed pairing label is THIS server's name for the device
|
||||
// (shown in the device list). It wins over the device's self-reported
|
||||
// label; fall back to that only when no pairing label was set.
|
||||
const label = normalizeOptionalString(session.label)
|
||||
|| normalizeOptionalString(clientLabel)
|
||||
|| normalizeOptionalString(deviceName)
|
||||
|| 'Remote client';
|
||||
const result = await remoteClientAuthRuntime.createClient({
|
||||
label,
|
||||
clientKind: normalizedKind,
|
||||
dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`,
|
||||
authMethod: 'pairing',
|
||||
pairingId: session.id,
|
||||
deviceName,
|
||||
devicePlatform,
|
||||
deviceModel,
|
||||
appVersion,
|
||||
usesRelay: session.usesRelay === true,
|
||||
});
|
||||
session.usedAt = nowIso();
|
||||
session.clientId = result.client?.id || null;
|
||||
await writeStore(store);
|
||||
return { pairing: publicSession(session), client: result.client, token: result.token };
|
||||
});
|
||||
};
|
||||
|
||||
const sweepExpiredSessions = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const before = store.sessions.length;
|
||||
sweepExpiredSessionsFromStore(store);
|
||||
const purged = before - store.sessions.length;
|
||||
if (purged > 0) await writeStore(store);
|
||||
return { purged };
|
||||
});
|
||||
|
||||
return {
|
||||
createPairingSession,
|
||||
getPairingSession,
|
||||
listPendingSessions,
|
||||
hasActiveRelaySession,
|
||||
cancelPairingSession,
|
||||
redeemPairingSession,
|
||||
sweepExpiredSessions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { createClientPairingRuntime } from './pairing.js';
|
||||
|
||||
const makeRuntime = async (options = {}) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-pairing-test-'));
|
||||
const createdClients = [];
|
||||
const remoteClientAuthRuntime = options.remoteClientAuthRuntime || {
|
||||
createClient: vi.fn(async (input) => {
|
||||
const client = {
|
||||
id: `client-${createdClients.length + 1}`,
|
||||
label: input.label,
|
||||
clientKind: input.clientKind,
|
||||
authMethod: input.authMethod,
|
||||
pairingId: input.pairingId,
|
||||
deviceName: input.deviceName ?? null,
|
||||
};
|
||||
createdClients.push(client);
|
||||
return { client, token: `token-${createdClients.length}` };
|
||||
}),
|
||||
};
|
||||
const runtime = createClientPairingRuntime({
|
||||
fsPromises: fs,
|
||||
path,
|
||||
crypto,
|
||||
storePath: path.join(dir, 'pairing.json'),
|
||||
remoteClientAuthRuntime,
|
||||
ttlMs: options.ttlMs ?? 10 * 60 * 1000,
|
||||
});
|
||||
return { dir, runtime, remoteClientAuthRuntime, createdClients };
|
||||
};
|
||||
|
||||
describe('client auth pairing runtime', () => {
|
||||
it('redeems a pairing session once and propagates client metadata', async () => {
|
||||
const { runtime, remoteClientAuthRuntime } = await makeRuntime();
|
||||
const created = await runtime.createPairingSession({ allowedClientKinds: ['mobile'] });
|
||||
|
||||
const result = await runtime.redeemPairingSession({
|
||||
pairingId: created.pairing.id,
|
||||
secret: created.pairing.secret,
|
||||
clientLabel: 'Iryna iPhone',
|
||||
clientKind: 'mobile',
|
||||
deviceName: 'Iryna iPhone',
|
||||
dedupeKey: 'device-key',
|
||||
});
|
||||
|
||||
expect(result.token).toBe('token-1');
|
||||
expect(result.client).toMatchObject({
|
||||
label: 'Iryna iPhone',
|
||||
clientKind: 'mobile',
|
||||
authMethod: 'pairing',
|
||||
pairingId: created.pairing.id,
|
||||
deviceName: 'Iryna iPhone',
|
||||
});
|
||||
expect(remoteClientAuthRuntime.createClient).toHaveBeenCalledWith(expect.objectContaining({
|
||||
authMethod: 'pairing',
|
||||
pairingId: created.pairing.id,
|
||||
clientKind: 'mobile',
|
||||
dedupeKey: 'device-key',
|
||||
}));
|
||||
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: created.pairing.id,
|
||||
secret: created.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
});
|
||||
|
||||
it('rejects expired, cancelled, wrong-secret, and disallowed-kind redemption', async () => {
|
||||
const { runtime: expiredRuntime } = await makeRuntime({ ttlMs: -1000 });
|
||||
const expired = await expiredRuntime.createPairingSession();
|
||||
await expect(expiredRuntime.redeemPairingSession({
|
||||
pairingId: expired.pairing.id,
|
||||
secret: expired.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
|
||||
const { runtime } = await makeRuntime();
|
||||
const cancelled = await runtime.createPairingSession();
|
||||
await runtime.cancelPairingSession(cancelled.pairing.id);
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: cancelled.pairing.id,
|
||||
secret: cancelled.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
|
||||
const wrongSecret = await runtime.createPairingSession();
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: wrongSecret.pairing.id,
|
||||
secret: 'wrong',
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
|
||||
const desktopOnly = await runtime.createPairingSession({ allowedClientKinds: ['desktop'] });
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: desktopOnly.pairing.id,
|
||||
secret: desktopOnly.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('Invalid or expired pairing session');
|
||||
});
|
||||
|
||||
it('does not consume the pairing session if client issuance fails', async () => {
|
||||
const createClient = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('disk failed'))
|
||||
.mockResolvedValueOnce({ client: { id: 'client-1' }, token: 'token-1' });
|
||||
const { runtime } = await makeRuntime({ remoteClientAuthRuntime: { createClient } });
|
||||
const created = await runtime.createPairingSession();
|
||||
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: created.pairing.id,
|
||||
secret: created.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).rejects.toThrow('disk failed');
|
||||
|
||||
await expect(runtime.redeemPairingSession({
|
||||
pairingId: created.pairing.id,
|
||||
secret: created.pairing.secret,
|
||||
clientKind: 'mobile',
|
||||
})).resolves.toMatchObject({ token: 'token-1' });
|
||||
expect(createClient).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
dedupeKey: `pairing:${created.pairing.id}`,
|
||||
}));
|
||||
});
|
||||
|
||||
it('sweeps expired never-used sessions from the store on the next create', async () => {
|
||||
const { dir, runtime } = await makeRuntime({ ttlMs: -1000 });
|
||||
// Immediately expired (negative TTL), never used or cancelled.
|
||||
const expired = await runtime.createPairingSession({ label: 'stale' });
|
||||
|
||||
// The next create sweeps the store; only the fresh session should remain.
|
||||
const storePath = path.join(dir, 'pairing.json');
|
||||
await runtime.createPairingSession({ label: 'fresh' });
|
||||
const store = JSON.parse(await fs.readFile(storePath, 'utf8'));
|
||||
const ids = store.sessions.map((session) => session.id);
|
||||
expect(ids).not.toContain(expired.pairing.id);
|
||||
expect(ids).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,15 @@ const normalizeOptionalString = (value) => {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const normalizeMetadata = (client) => ({
|
||||
authMethod: normalizeOptionalString(client.authMethod),
|
||||
pairingId: normalizeOptionalString(client.pairingId),
|
||||
deviceName: normalizeOptionalString(client.deviceName),
|
||||
devicePlatform: normalizeOptionalString(client.devicePlatform),
|
||||
deviceModel: normalizeOptionalString(client.deviceModel),
|
||||
appVersion: normalizeOptionalString(client.appVersion),
|
||||
});
|
||||
|
||||
const safeJsonParse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
@@ -77,6 +86,9 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
expiresAt: normalizeTimestamp(client.expiresAt),
|
||||
clientKind: normalizeOptionalString(client.clientKind),
|
||||
dedupeKey: normalizeOptionalString(client.dedupeKey),
|
||||
usesRelay: client.usesRelay === true,
|
||||
lastTransport: client.lastTransport === 'relay' || client.lastTransport === 'direct' ? client.lastTransport : null,
|
||||
...normalizeMetadata(client),
|
||||
}))
|
||||
.filter((client) => client.tokenHash.length > 0)
|
||||
: [],
|
||||
@@ -108,6 +120,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
revokedAt: client.revokedAt,
|
||||
expiresAt: client.expiresAt,
|
||||
clientKind: client.clientKind,
|
||||
authMethod: client.authMethod,
|
||||
pairingId: client.pairingId,
|
||||
deviceName: client.deviceName,
|
||||
devicePlatform: client.devicePlatform,
|
||||
deviceModel: client.deviceModel,
|
||||
appVersion: client.appVersion,
|
||||
usesRelay: client.usesRelay === true,
|
||||
lastTransport: client.lastTransport ?? null,
|
||||
});
|
||||
|
||||
const listClients = async () => {
|
||||
@@ -117,7 +137,34 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
});
|
||||
};
|
||||
|
||||
const createClient = async ({ label, expiresAt, clientKind, dedupeKey } = {}) => {
|
||||
// Relay-transport demand from paired devices: any non-revoked, non-expired
|
||||
// client that was paired over the relay.
|
||||
const hasActiveRelayClients = async () => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const now = Date.now();
|
||||
return store.clients.some((client) => {
|
||||
if (client.usesRelay !== true) return false;
|
||||
if (client.revokedAt) return false;
|
||||
const expires = Date.parse(client.expiresAt || '');
|
||||
return !Number.isFinite(expires) || expires > now;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const createClient = async ({
|
||||
label,
|
||||
expiresAt,
|
||||
clientKind,
|
||||
dedupeKey,
|
||||
authMethod,
|
||||
pairingId,
|
||||
deviceName,
|
||||
devicePlatform,
|
||||
deviceModel,
|
||||
appVersion,
|
||||
usesRelay,
|
||||
} = {}) => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const normalizedDedupeKey = normalizeOptionalString(dedupeKey);
|
||||
@@ -132,9 +179,24 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
expiresAt: normalizeTimestamp(expiresAt),
|
||||
clientKind: normalizeOptionalString(clientKind),
|
||||
dedupeKey: normalizedDedupeKey,
|
||||
authMethod: normalizeOptionalString(authMethod),
|
||||
pairingId: normalizeOptionalString(pairingId),
|
||||
deviceName: normalizeOptionalString(deviceName),
|
||||
devicePlatform: normalizeOptionalString(devicePlatform),
|
||||
deviceModel: normalizeOptionalString(deviceModel),
|
||||
appVersion: normalizeOptionalString(appVersion),
|
||||
usesRelay: usesRelay === true,
|
||||
};
|
||||
if (normalizedDedupeKey) {
|
||||
store.clients = store.clients.filter((entry) => entry.dedupeKey !== normalizedDedupeKey);
|
||||
// Migrate pre-clientKind desktop tokens: a deduped, kind-tagged mint
|
||||
// supersedes legacy records with the same label that carry neither a
|
||||
// kind nor a dedupe key — those tokens can no longer pass the
|
||||
// desktop-local client-create gate and would otherwise linger forever.
|
||||
if (client.clientKind) {
|
||||
store.clients = store.clients.filter((entry) =>
|
||||
!(entry.label === client.label && !entry.clientKind && !entry.dedupeKey));
|
||||
}
|
||||
}
|
||||
store.clients.push(client);
|
||||
await writeStore(store);
|
||||
@@ -169,10 +231,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
});
|
||||
};
|
||||
|
||||
const authenticateBearerToken = async (token) => {
|
||||
const authenticateBearerToken = async (token, req) => {
|
||||
if (typeof token !== 'string' || !token.startsWith(TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
// Which transport carried this request: the relay tunnel proxy stamps every
|
||||
// forwarded request with x-openchamber-relay-connection; anything else is a
|
||||
// direct (local/LAN/tunnel-URL) request. Display-only device metadata.
|
||||
const transport = req?.headers?.['x-openchamber-relay-connection'] ? 'relay' : 'direct';
|
||||
return withStoreMutation(async () => {
|
||||
const tokenHash = hashToken(token);
|
||||
const store = await readStore();
|
||||
@@ -181,8 +247,11 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
if (client.expiresAt && Date.parse(client.expiresAt) <= Date.now()) return null;
|
||||
const now = Date.now();
|
||||
const lastUsedAt = Date.parse(client.lastUsedAt || '');
|
||||
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS) {
|
||||
// Write on the throttle interval — or immediately when the transport
|
||||
// changed, so a LAN⇄relay switch is visible right away, not a minute late.
|
||||
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS || client.lastTransport !== transport) {
|
||||
client.lastUsedAt = new Date(now).toISOString();
|
||||
client.lastTransport = transport;
|
||||
await writeStore(store);
|
||||
}
|
||||
return { ok: true, clientId: client.id, sessionToken: client.id, client: publicClient(client) };
|
||||
@@ -193,6 +262,7 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
authenticateBearerToken,
|
||||
createClient,
|
||||
listClients,
|
||||
hasActiveRelayClients,
|
||||
purgeRevokedClients,
|
||||
revokeClient,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Context Obligatory Messages
|
||||
|
||||
Messages explicitly pinned by the user are stored under
|
||||
`session.metadata.openchamber.context_obligatory_messages` as `{ id, createdAt,
|
||||
role }`. The UI uses a fresh-read metadata merge when pinning or unpinning.
|
||||
|
||||
The server runtime listens for OpenCode's dedicated `session.compacted` event.
|
||||
It fetches every pinned message by ID, keeps non-empty text parts, sorts them
|
||||
by the stored creation time, and immediately sends one synthetic user part
|
||||
through `prompt_async`. OpenCode's session runner serializes this with its own
|
||||
post-compaction continuation. Missing individual messages are skipped without
|
||||
discarding the remaining context. Ordinary idle events perform no work and
|
||||
make no requests.
|
||||
|
||||
After a successful send, the runtime merge-writes
|
||||
`context_obligatory_last_compaction_message_id`. This cursor prevents a
|
||||
replayed compaction event from reinjecting the same summary. The runtime is
|
||||
owned by the OpenChamber web backend and therefore is not available in
|
||||
extension-only VS Code mode.
|
||||
@@ -0,0 +1,140 @@
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
const MESSAGE_FETCH_LIMIT = 20;
|
||||
|
||||
const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
|
||||
const readContextState = (session) => {
|
||||
const metadata = isRecord(session?.metadata) ? session.metadata : {};
|
||||
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
|
||||
const messages = Array.isArray(openchamber.context_obligatory_messages)
|
||||
? openchamber.context_obligatory_messages.filter((item) =>
|
||||
isRecord(item)
|
||||
&& typeof item.id === 'string'
|
||||
&& typeof item.createdAt === 'number'
|
||||
&& (item.role === 'user' || item.role === 'assistant'))
|
||||
: [];
|
||||
return { metadata, openchamber, messages };
|
||||
};
|
||||
|
||||
const buildContextPrompt = (entries) => {
|
||||
const timeline = entries.map(({ pinned, text }) => {
|
||||
const timestamp = new Date(pinned.createdAt).toISOString();
|
||||
return `## ${pinned.role} — ${timestamp}\n\n${text}`;
|
||||
}).join('\n\n---\n\n');
|
||||
return [
|
||||
'The following messages are from the compacted conversation. The user explicitly marked them as important and required in your context. Pay close attention to them; they may have been sent by either the user or you before compaction.',
|
||||
'Use them while continuing the pre-compaction work. Do not treat this context restoration as a new standalone task.',
|
||||
'If any tasks or next steps remain, do not acknowledge, summarize, or mention this restored context in a separate response. Simply continue the work and use it silently as background context. Do not append a recap of it after completing those tasks. Only if no tasks or next steps remain, give the user a very brief summary of the important restored context in no more than one short paragraph, without lists or a detailed recap.',
|
||||
'',
|
||||
timeline,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createContextObligatoryRuntime = ({
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
}) => {
|
||||
const inflight = new Set();
|
||||
let stopped = false;
|
||||
|
||||
const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
|
||||
const params = new URLSearchParams(query || {});
|
||||
if (directory) params.set('directory', directory);
|
||||
const search = params.toString();
|
||||
const response = await fetch(`${buildOpenCodeUrl(fetchPath, '')}${search ? `?${search}` : ''}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`OpenCode ${method} ${fetchPath} failed with ${response.status}`);
|
||||
return response.json().catch(() => null);
|
||||
};
|
||||
|
||||
const tick = async (sessionId, directory) => {
|
||||
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
||||
if (session?.parentID) return;
|
||||
const state = readContextState(session);
|
||||
if (state.messages.length === 0) return;
|
||||
|
||||
const recent = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
|
||||
directory,
|
||||
query: { limit: String(MESSAGE_FETCH_LIMIT) },
|
||||
});
|
||||
if (!Array.isArray(recent) || recent.length === 0) return;
|
||||
const summary = recent.toReversed().find((message) =>
|
||||
message?.info?.role === 'assistant' && message.info.summary === true)?.info;
|
||||
if (!summary?.id || !summary?.time?.completed) return;
|
||||
if (state.openchamber.context_obligatory_last_compaction_message_id === summary.id) return;
|
||||
|
||||
const fetched = await Promise.allSettled(state.messages.map(async (pinned) => {
|
||||
const message = await openCodeFetch(
|
||||
`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(pinned.id)}`,
|
||||
{ directory },
|
||||
);
|
||||
const text = Array.isArray(message?.parts)
|
||||
? message.parts.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
||||
.map((part) => part.text.trim()).filter(Boolean).join('\n\n')
|
||||
: '';
|
||||
return { pinned, text };
|
||||
}));
|
||||
const entries = fetched
|
||||
.filter((result) => result.status === 'fulfilled' && result.value.text)
|
||||
.map((result) => result.value)
|
||||
.sort((left, right) => left.pinned.createdAt - right.pinned.createdAt);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const executionInfo = recent.toReversed().find((message) =>
|
||||
message?.info?.role === 'assistant' && message.info.summary !== true)?.info;
|
||||
const providerID = typeof executionInfo?.providerID === 'string' ? executionInfo.providerID : '';
|
||||
const modelID = typeof executionInfo?.modelID === 'string' ? executionInfo.modelID : '';
|
||||
if (!providerID || !modelID) throw new Error('no pre-compaction assistant provider/model');
|
||||
const agent = typeof executionInfo.agent === 'string' ? executionInfo.agent : executionInfo.mode;
|
||||
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
|
||||
directory,
|
||||
method: 'POST',
|
||||
body: {
|
||||
model: { providerID, modelID },
|
||||
...(typeof agent === 'string' && agent ? { agent } : {}),
|
||||
parts: [{ type: 'text', text: buildContextPrompt(entries), synthetic: true }],
|
||||
},
|
||||
});
|
||||
|
||||
const fresh = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
||||
const freshState = readContextState(fresh);
|
||||
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
|
||||
directory,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
metadata: {
|
||||
...freshState.metadata,
|
||||
openchamber: {
|
||||
...freshState.openchamber,
|
||||
context_obligatory_last_compaction_message_id: summary.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const processPayload = (payload, directoryHint = '') => {
|
||||
if (stopped || payload?.type !== 'session.compacted') return;
|
||||
const sessionId = payload?.properties?.sessionID;
|
||||
if (typeof sessionId !== 'string' || inflight.has(sessionId)) return;
|
||||
const directory = payload?.properties?.directory || directoryHint;
|
||||
inflight.add(sessionId);
|
||||
return tick(sessionId, directory)
|
||||
.catch((error) => console.warn('[context-obligatory] injection failed:', error?.message || error))
|
||||
.finally(() => inflight.delete(sessionId));
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
};
|
||||
|
||||
return { processPayload, stop };
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createContextObligatoryRuntime } from './runtime.js';
|
||||
|
||||
const json = (body) => new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
describe('context obligatory runtime', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('injects pinned text in chronological order after compaction and records the summary cursor', async () => {
|
||||
const requests = [];
|
||||
let sessionReads = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input, init = {}) => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.url);
|
||||
requests.push({ path: url.pathname, method: init.method ?? 'GET', body: init.body });
|
||||
if (url.pathname === '/session/ses_1' && init.method === 'PATCH') return json({});
|
||||
if (url.pathname === '/session/ses_1') {
|
||||
sessionReads += 1;
|
||||
return json({
|
||||
id: 'ses_1',
|
||||
metadata: { openchamber: { context_obligatory_messages: [
|
||||
{ id: 'msg_2', createdAt: 20, role: 'assistant' },
|
||||
{ id: 'msg_1', createdAt: 10, role: 'user' },
|
||||
] } },
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/session/ses_1/message') return json([
|
||||
{ info: { id: 'msg_agent', role: 'assistant', providerID: 'provider', modelID: 'model', agent: 'build' } },
|
||||
{ info: { id: 'msg_summary', role: 'assistant', summary: true, time: { completed: 30 } } },
|
||||
]);
|
||||
if (url.pathname === '/session/ses_1/message/msg_1') return json({ parts: [{ type: 'text', text: 'First' }] });
|
||||
if (url.pathname === '/session/ses_1/message/msg_2') return json({ parts: [{ type: 'text', text: 'Second' }] });
|
||||
if (url.pathname === '/session/ses_1/prompt_async') return json({});
|
||||
throw new Error(`Unexpected ${url.pathname}`);
|
||||
}));
|
||||
const runtime = createContextObligatoryRuntime({
|
||||
buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
});
|
||||
|
||||
await runtime.processPayload({ type: 'session.compacted', properties: { sessionID: 'ses_1' } });
|
||||
|
||||
const prompt = requests.find((request) => request.path.endsWith('/prompt_async'));
|
||||
const payload = JSON.parse(prompt.body);
|
||||
expect(payload).toMatchObject({
|
||||
model: { providerID: 'provider', modelID: 'model' },
|
||||
agent: 'build',
|
||||
parts: [{ type: 'text', synthetic: true }],
|
||||
});
|
||||
expect(payload.parts[0].text.indexOf('First')).toBeLessThan(payload.parts[0].text.indexOf('Second'));
|
||||
expect(payload.parts[0].text).toContain('continuing the pre-compaction work');
|
||||
expect(payload.parts[0].text).toContain('use it silently as background context');
|
||||
expect(payload.parts[0].text).toContain('Only if no tasks or next steps remain');
|
||||
expect(payload.parts[0].text).toContain('no more than one short paragraph');
|
||||
const patch = requests.find((request) => request.method === 'PATCH');
|
||||
expect(JSON.parse(patch.body).metadata.openchamber.context_obligatory_last_compaction_message_id).toBe('msg_summary');
|
||||
expect(sessionReads).toBe(2);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('ignores ordinary idle events without making requests', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchImpl);
|
||||
const runtime = createContextObligatoryRuntime({
|
||||
buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
});
|
||||
await runtime.processPayload({ type: 'session.status', properties: { sessionID: 'ses_1', status: { type: 'idle' } } });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
# Dictation module
|
||||
|
||||
Server-authoritative streaming speech-to-text for the chat composer, plus
|
||||
local text-to-speech. The client streams 16 kHz mono PCM16 chunks (base64)
|
||||
over a WebSocket; the server runs the transcription and streams live partial
|
||||
transcripts back.
|
||||
|
||||
Local TTS (Kokoro via sherpa-onnx OfflineTts) runs in the same worker process
|
||||
and is exposed as `POST /api/dictation/tts/speak` (JSON `{text, speakerId?,
|
||||
speed?, model?}` → WAV bytes; 503 with `reasonCode` while the model is
|
||||
downloading). TTS models live in the same catalog/downloader as STT models
|
||||
(`local/model-catalog.js` `LOCAL_TTS_MODEL_CATALOG`) and are managed by the
|
||||
same status/download/delete routes.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `runtime.js` — registers `GET /api/dictation/status`,
|
||||
`POST /api/dictation/models/:modelId/download`, and the
|
||||
`/api/dictation/ws` WebSocket endpoint (auth-gated the same way as the
|
||||
terminal WS: UI session token or `oc_url_token`, plus origin check).
|
||||
Created from the startup pipeline (`startup-pipeline-runtime.js`) before
|
||||
the generic OpenCode proxy so routes are not shadowed.
|
||||
- `stream-manager.js` — `DictationStreamManager`, one per WS connection.
|
||||
Chunk reordering by `seq` + ack, resampling to the provider rate,
|
||||
auto-commit every ~15 s of audio, silence suppression by PCM peak,
|
||||
partial-transcript concatenation, adaptive finalization timeout.
|
||||
- `service.js` — provider resolution and readiness. Providers:
|
||||
- `local` (default): sherpa-onnx Parakeet TDT in a forked worker process.
|
||||
Models auto-download in the background on first use; while missing, the
|
||||
stream fails with `reasonCode: 'model_download_in_progress'` and the
|
||||
status route reports per-model install/download state.
|
||||
- `openai-compatible`: buffered per-segment transcription against any
|
||||
OpenAI-compatible `/v1/audio/transcriptions` endpoint
|
||||
(`openai-compatible-session.js`, reuses `../tts/stt.js`).
|
||||
- `local/` — worker process + client (IPC, idle shutdown TTL), sherpa
|
||||
recognizer engine and realtime session (throttled re-decode for partials),
|
||||
model catalog and downloader. The native `sherpa-onnx-node` addon is only
|
||||
ever loaded inside the worker process.
|
||||
- `audio.js` — PCM16 helpers: format parsing, peak, WAV wrapping, streaming
|
||||
linear resampler.
|
||||
|
||||
## WebSocket protocol (JSON text frames)
|
||||
|
||||
Client → server: `start {dictationId, format, options}`,
|
||||
`chunk {dictationId, seq, audio}`, `finish {dictationId, finalSeq}`,
|
||||
`cancel {dictationId}`, `ping`.
|
||||
|
||||
Server → client: `ready`, `ack {ackSeq}`, `partial {text}`,
|
||||
`finish_accepted {timeoutMs}`, `final {text}`,
|
||||
`error {error, retryable, reasonCode?}`, `pong`.
|
||||
|
||||
`options` in `start` carries the client-selected provider config:
|
||||
`{ provider: 'local' | 'openai-compatible', language?, localModel?,
|
||||
openaiCompatible?: { baseUrl, model, apiKey } }`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Never load `sherpa-onnx-node` in the main server process.
|
||||
- The stream manager acks only the highest contiguous seq; the client is
|
||||
expected to retain unacked segments for retry/replay.
|
||||
- Silence-only segments (peak < 300) are cleared, never committed, so
|
||||
Whisper-style providers do not hallucinate on silence.
|
||||
- Model files live under `~/.config/openchamber/speech-models`.
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* PCM16 audio helpers for the dictation streaming pipeline.
|
||||
*
|
||||
* All dictation audio travels as 16-bit little-endian mono PCM. The client
|
||||
* captures at 16 kHz; providers may require a different rate, so chunks are
|
||||
* resampled with Pcm16MonoResampler before being appended to an STT session.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse the sample rate out of a format string like "audio/pcm;rate=16000;bits=16".
|
||||
* @param {string} format
|
||||
* @param {number|null} [fallback]
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function parsePcmRateFromFormat(format, fallback = null) {
|
||||
const match = /(?:^|[;,\s])rate\s*=\s*(\d+)(?:$|[;,\s])/i.exec(String(format || ''));
|
||||
if (!match) {
|
||||
return fallback;
|
||||
}
|
||||
const rate = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an Int16Array view over a PCM16LE buffer, copying when the buffer's
|
||||
* byteOffset is not 2-byte aligned (IPC-transferred buffers can be views at
|
||||
* odd offsets, and Int16Array requires an even start offset).
|
||||
* @param {Buffer} pcm16le
|
||||
* @returns {Int16Array}
|
||||
*/
|
||||
function toInt16Samples(pcm16le) {
|
||||
if (pcm16le.byteOffset % 2 !== 0) {
|
||||
const copy = Buffer.from(pcm16le);
|
||||
return new Int16Array(copy.buffer, copy.byteOffset, copy.byteLength / 2);
|
||||
}
|
||||
return new Int16Array(pcm16le.buffer, pcm16le.byteOffset, pcm16le.byteLength / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak absolute sample value of a PCM16LE buffer. Used for silence detection.
|
||||
* @param {Buffer} pcm16le
|
||||
* @returns {number}
|
||||
*/
|
||||
export function pcm16lePeakAbs(pcm16le) {
|
||||
if (!pcm16le || pcm16le.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (pcm16le.length % 2 !== 0) {
|
||||
throw new Error(`PCM16 chunk byteLength must be even, got ${pcm16le.length}`);
|
||||
}
|
||||
const samples = toInt16Samples(pcm16le);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const v = samples[i];
|
||||
const abs = v < 0 ? -v : v;
|
||||
if (abs > peak) {
|
||||
peak = abs;
|
||||
if (peak >= 32767) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PCM16LE to Float32 samples in [-1, 1], with optional gain.
|
||||
* @param {Buffer} pcm16le
|
||||
* @param {number} [gain]
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
export function pcm16leToFloat32(pcm16le, gain = 1) {
|
||||
if (pcm16le.length % 2 !== 0) {
|
||||
throw new Error(`PCM16 chunk byteLength must be even, got ${pcm16le.length}`);
|
||||
}
|
||||
const int16 = toInt16Samples(pcm16le);
|
||||
const out = new Float32Array(int16.length);
|
||||
for (let i = 0; i < int16.length; i += 1) {
|
||||
const v = (int16[i] / 32768.0) * gain;
|
||||
out[i] = Math.max(-1, Math.min(1, v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw PCM16LE mono audio in a WAV container.
|
||||
* @param {Buffer} pcmBuffer
|
||||
* @param {number} sampleRate
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function pcm16ToWav(pcmBuffer, sampleRate) {
|
||||
const channels = 1;
|
||||
const bitsPerSample = 16;
|
||||
const headerSize = 44;
|
||||
const wavBuffer = Buffer.alloc(headerSize + pcmBuffer.length);
|
||||
const byteRate = (sampleRate * channels * bitsPerSample) / 8;
|
||||
const blockAlign = (channels * bitsPerSample) / 8;
|
||||
|
||||
wavBuffer.write('RIFF', 0);
|
||||
wavBuffer.writeUInt32LE(36 + pcmBuffer.length, 4);
|
||||
wavBuffer.write('WAVE', 8);
|
||||
wavBuffer.write('fmt ', 12);
|
||||
wavBuffer.writeUInt32LE(16, 16);
|
||||
wavBuffer.writeUInt16LE(1, 20);
|
||||
wavBuffer.writeUInt16LE(channels, 22);
|
||||
wavBuffer.writeUInt32LE(sampleRate, 24);
|
||||
wavBuffer.writeUInt32LE(byteRate, 28);
|
||||
wavBuffer.writeUInt16LE(blockAlign, 32);
|
||||
wavBuffer.writeUInt16LE(bitsPerSample, 34);
|
||||
wavBuffer.write('data', 36);
|
||||
wavBuffer.writeUInt32LE(pcmBuffer.length, 40);
|
||||
pcmBuffer.copy(wavBuffer, 44);
|
||||
|
||||
return wavBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming linear-interpolation resampler for PCM16LE mono audio.
|
||||
* Carries one sample across chunk boundaries so consecutive chunks resample
|
||||
* without seams.
|
||||
*/
|
||||
export class Pcm16MonoResampler {
|
||||
/**
|
||||
* @param {{ inputRate: number, outputRate: number }} params
|
||||
*/
|
||||
constructor({ inputRate, outputRate }) {
|
||||
this.inputRate = inputRate;
|
||||
this.outputRate = outputRate;
|
||||
this.step = inputRate / outputRate;
|
||||
this.pos = 0;
|
||||
this.carrySample = null;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.pos = 0;
|
||||
this.carrySample = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} pcm16le
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
processChunk(pcm16le) {
|
||||
if (pcm16le.length === 0) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (pcm16le.length % 2 !== 0) {
|
||||
throw new Error(`PCM16 chunk byteLength must be even, got ${pcm16le.length}`);
|
||||
}
|
||||
|
||||
const srcChunk = toInt16Samples(pcm16le);
|
||||
|
||||
const hasCarry = this.carrySample !== null;
|
||||
const srcLen = srcChunk.length + (hasCarry ? 1 : 0);
|
||||
if (srcLen < 2) {
|
||||
this.carrySample = srcChunk.length ? srcChunk[srcChunk.length - 1] : this.carrySample;
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
const src = new Float32Array(srcLen);
|
||||
let offset = 0;
|
||||
if (hasCarry) {
|
||||
src[0] = this.carrySample / 32768;
|
||||
offset = 1;
|
||||
}
|
||||
for (let i = 0; i < srcChunk.length; i += 1) {
|
||||
src[offset + i] = srcChunk[i] / 32768;
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const maxPos = src.length - 1;
|
||||
|
||||
while (this.pos < maxPos) {
|
||||
const i = Math.floor(this.pos);
|
||||
const frac = this.pos - i;
|
||||
const s0 = src[i];
|
||||
const s1 = src[i + 1];
|
||||
const sample = s0 + (s1 - s0) * frac;
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
out.push(Math.round(clamped * 32767));
|
||||
this.pos += this.step;
|
||||
}
|
||||
|
||||
this.carrySample = srcChunk[srcChunk.length - 1];
|
||||
|
||||
const shift = src.length - 1;
|
||||
this.pos = this.pos - shift;
|
||||
if (this.pos < 0) {
|
||||
this.pos = 0;
|
||||
}
|
||||
|
||||
const outArr = Int16Array.from(out);
|
||||
return Buffer.from(outArr.buffer, outArr.byteOffset, outArr.byteLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Catalog of local sherpa-onnx STT models available for dictation.
|
||||
* Models are downloaded on demand from the k2-fsa GitHub releases and
|
||||
* extracted under the OpenChamber speech-models directory.
|
||||
*
|
||||
* `type` selects the recognizer construction path in the worker:
|
||||
* - 'nemo_transducer': encoder/decoder/joiner transducer (Parakeet)
|
||||
* - 'whisper': encoder/decoder Whisper export
|
||||
* `files` maps logical roles to file names inside the extracted directory.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
|
||||
export const LOCAL_STT_MODEL_CATALOG = {
|
||||
'parakeet-tdt-0.6b-v2-int8': {
|
||||
type: 'nemo_transducer',
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2',
|
||||
extractedDir: 'sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8',
|
||||
files: {
|
||||
encoder: 'encoder.int8.onnx',
|
||||
decoder: 'decoder.int8.onnx',
|
||||
joiner: 'joiner.int8.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
},
|
||||
description: 'NVIDIA Parakeet TDT v2 (English)',
|
||||
},
|
||||
'parakeet-tdt-0.6b-v3-int8': {
|
||||
type: 'nemo_transducer',
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2',
|
||||
extractedDir: 'sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8',
|
||||
files: {
|
||||
encoder: 'encoder.int8.onnx',
|
||||
decoder: 'decoder.int8.onnx',
|
||||
joiner: 'joiner.int8.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
},
|
||||
description: 'NVIDIA Parakeet TDT v3 (25 European languages, auto-detected)',
|
||||
},
|
||||
'whisper-base-int8': {
|
||||
type: 'whisper',
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-base.tar.bz2',
|
||||
extractedDir: 'sherpa-onnx-whisper-base',
|
||||
files: {
|
||||
encoder: 'base-encoder.int8.onnx',
|
||||
decoder: 'base-decoder.int8.onnx',
|
||||
tokens: 'base-tokens.txt',
|
||||
},
|
||||
description: 'OpenAI Whisper base (multilingual, smaller and lighter)',
|
||||
},
|
||||
'whisper-tiny-int8': {
|
||||
type: 'whisper',
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.tar.bz2',
|
||||
extractedDir: 'sherpa-onnx-whisper-tiny',
|
||||
files: {
|
||||
encoder: 'tiny-encoder.int8.onnx',
|
||||
decoder: 'tiny-decoder.int8.onnx',
|
||||
tokens: 'tiny-tokens.txt',
|
||||
},
|
||||
description: 'OpenAI Whisper tiny (multilingual, fastest and lightest)',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and
|
||||
* managed through the same pipeline as the STT models.
|
||||
*/
|
||||
export const LOCAL_TTS_MODEL_CATALOG = {
|
||||
'kokoro-en-v0_19': {
|
||||
type: 'kokoro',
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2',
|
||||
extractedDir: 'kokoro-en-v0_19',
|
||||
files: {
|
||||
model: 'model.onnx',
|
||||
voices: 'voices.bin',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Kokoro TTS (English, natural voices)',
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCAL_STT_MODEL = 'parakeet-tdt-0.6b-v2-int8';
|
||||
export const DEFAULT_LOCAL_TTS_MODEL = 'kokoro-en-v0_19';
|
||||
|
||||
export const LOCAL_STT_MODEL_IDS = Object.keys(LOCAL_STT_MODEL_CATALOG);
|
||||
export const LOCAL_TTS_MODEL_IDS = Object.keys(LOCAL_TTS_MODEL_CATALOG);
|
||||
|
||||
/**
|
||||
* @param {string} modelId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLocalSttModelId(modelId) {
|
||||
return typeof modelId === 'string' && Object.hasOwn(LOCAL_STT_MODEL_CATALOG, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} modelId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLocalTtsModelId(modelId) {
|
||||
return typeof modelId === 'string' && Object.hasOwn(LOCAL_TTS_MODEL_CATALOG, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Any managed local model (STT or TTS).
|
||||
* @param {string} modelId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLocalModelId(modelId) {
|
||||
return isLocalSttModelId(modelId) || isLocalTtsModelId(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec lookup across both catalogs (STT and TTS).
|
||||
* @param {string} modelId
|
||||
*/
|
||||
export function getLocalSttModelSpec(modelId) {
|
||||
const spec = LOCAL_STT_MODEL_CATALOG[modelId] ?? LOCAL_TTS_MODEL_CATALOG[modelId];
|
||||
if (!spec) {
|
||||
throw new Error(`Unknown local speech model id: ${modelId}`);
|
||||
}
|
||||
return {
|
||||
id: modelId,
|
||||
...spec,
|
||||
requiredFiles: Object.values(spec.files),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} modelsDir
|
||||
* @param {string} modelId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getLocalSttModelDir(modelsDir, modelId) {
|
||||
return path.join(modelsDir, getLocalSttModelSpec(modelId).extractedDir);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Downloads and extracts local sherpa-onnx STT model archives.
|
||||
* Archives (.tar.bz2) come from the k2-fsa GitHub releases and are extracted
|
||||
* with the system `tar` into the speech-models directory.
|
||||
*/
|
||||
|
||||
import { createWriteStream } from 'fs';
|
||||
import { mkdir, rename, rm, stat } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import { getLocalSttModelSpec } from './model-catalog.js';
|
||||
|
||||
async function hasRequiredFiles(modelDir, requiredFiles) {
|
||||
const results = await Promise.all(
|
||||
requiredFiles.map(async (rel) => {
|
||||
try {
|
||||
const s = await stat(path.join(modelDir, rel));
|
||||
if (s.isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
return s.isFile() && s.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results.every(Boolean);
|
||||
}
|
||||
|
||||
async function downloadToFile(url, outputPath, onProgress) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new Error(`Failed to download ${url}: missing response body`);
|
||||
}
|
||||
|
||||
const totalBytes = Number.parseInt(res.headers.get('content-length') || '', 10) || null;
|
||||
let downloadedBytes = 0;
|
||||
|
||||
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const nodeStream = Readable.fromWeb(res.body);
|
||||
if (typeof onProgress === 'function') {
|
||||
nodeStream.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length;
|
||||
onProgress(downloadedBytes, totalBytes);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await pipeline(nodeStream, createWriteStream(tmpPath));
|
||||
await rename(tmpPath, outputPath);
|
||||
} catch (error) {
|
||||
await rm(tmpPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractTarArchive(archivePath, destDir) {
|
||||
await mkdir(destDir, { recursive: true });
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn('tar', ['xf', archivePath, '-C', destDir], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`tar exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function isNonEmptyFile(filePath) {
|
||||
try {
|
||||
const s = await stat(filePath);
|
||||
return s.isFile() && s.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a model is fully installed (all required files present).
|
||||
* @param {string} modelsDir
|
||||
* @param {string} modelId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function isLocalSttModelInstalled(modelsDir, modelId) {
|
||||
const spec = getLocalSttModelSpec(modelId);
|
||||
return hasRequiredFiles(path.join(modelsDir, spec.extractedDir), spec.requiredFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a model is downloaded and extracted. Resolves with the model dir.
|
||||
*
|
||||
* Extraction is staged: the archive unpacks into a temporary directory and is
|
||||
* verified before being renamed into place. An interrupted or failed tar must
|
||||
* never leave partial files at the final path — the installed check only
|
||||
* verifies file presence, so a truncated .onnx there would be treated as an
|
||||
* installed model forever ("Protobuf parsing failed" at load time).
|
||||
*
|
||||
* @param {{ modelsDir: string, modelId: string,
|
||||
* onProgress?: (downloadedBytes: number, totalBytes: number | null) => void }} options
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function ensureLocalSttModel({ modelsDir, modelId, onProgress }) {
|
||||
const spec = getLocalSttModelSpec(modelId);
|
||||
const modelDir = path.join(modelsDir, spec.extractedDir);
|
||||
if (await hasRequiredFiles(modelDir, spec.requiredFiles)) {
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
// A directory that exists but fails the required-files check is a partial
|
||||
// extraction from an earlier interrupted attempt — remove it before retrying.
|
||||
await rm(modelDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
|
||||
const downloadsDir = path.join(modelsDir, '.downloads');
|
||||
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
||||
const archivePath = path.join(downloadsDir, archiveFilename);
|
||||
|
||||
if (!(await isNonEmptyFile(archivePath))) {
|
||||
await downloadToFile(spec.archiveUrl, archivePath, onProgress);
|
||||
}
|
||||
|
||||
const stagingDir = path.join(modelsDir, `.staging-${spec.extractedDir}-${Date.now()}`);
|
||||
try {
|
||||
await extractTarArchive(archivePath, stagingDir);
|
||||
|
||||
const stagedModelDir = path.join(stagingDir, spec.extractedDir);
|
||||
if (!(await hasRequiredFiles(stagedModelDir, spec.requiredFiles))) {
|
||||
// Bad archive (truncated download / corrupt cache): drop it so the next
|
||||
// attempt re-downloads instead of re-extracting the same broken bytes.
|
||||
await rm(archivePath, { force: true }).catch(() => undefined);
|
||||
throw new Error(
|
||||
`Extracted ${archiveFilename}, but required model files are missing or empty. The archive was discarded; retry to re-download.`,
|
||||
);
|
||||
}
|
||||
|
||||
await rename(stagedModelDir, modelDir);
|
||||
} catch (error) {
|
||||
await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
// Any extraction failure means the cached archive can't be trusted
|
||||
// (corrupt bz2, truncated download). Discard it so retry re-downloads.
|
||||
await rm(archivePath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
|
||||
await rm(archivePath, { force: true }).catch(() => undefined);
|
||||
|
||||
return modelDir;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Loader for the sherpa-onnx-node native addon.
|
||||
*
|
||||
* sherpa-onnx-node ships its native addon and shared libraries in a
|
||||
* platform-specific package (e.g. sherpa-onnx-darwin-arm64). The shared
|
||||
* libraries must be findable via the platform's dynamic-loader search path,
|
||||
* so the loader prepends the platform package directory to LD_LIBRARY_PATH /
|
||||
* DYLD_LIBRARY_PATH / PATH before requiring the addon.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let cached = null;
|
||||
|
||||
function sherpaPlatformPackageName(platform = process.platform, arch = process.arch) {
|
||||
const normalizedPlatform = platform === 'win32' ? 'win' : platform;
|
||||
return `sherpa-onnx-${normalizedPlatform}-${arch}`;
|
||||
}
|
||||
|
||||
function sherpaLoaderEnvKey(platform = process.platform) {
|
||||
if (platform === 'linux') {
|
||||
return 'LD_LIBRARY_PATH';
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return 'DYLD_LIBRARY_PATH';
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return 'PATH';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prependEnvPath(existing, value) {
|
||||
const parts = String(existing ?? '').split(path.delimiter).filter(Boolean);
|
||||
if (parts.includes(value)) {
|
||||
return parts.join(path.delimiter);
|
||||
}
|
||||
return [value, ...parts].join(path.delimiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive env key lookup: on Windows `{...process.env}` yields a
|
||||
* plain object where PATH may be stored as `Path`. Using a hardcoded 'PATH'
|
||||
* would create a duplicate key and break the child process PATH.
|
||||
*/
|
||||
function findEnvKey(env, key) {
|
||||
const lower = key.toLowerCase();
|
||||
for (const k of Object.keys(env)) {
|
||||
if (k.toLowerCase() === lower) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function resolveSherpaLibDir(platform = process.platform, arch = process.arch) {
|
||||
const packageName = sherpaPlatformPackageName(platform, arch);
|
||||
try {
|
||||
const pkgJson = require.resolve(`${packageName}/package.json`);
|
||||
// Electron packages node_modules inside app.asar, but native addons and
|
||||
// their shared libraries are extracted to app.asar.unpacked. The dynamic
|
||||
// loader (dlopen/DYLD/LD) cannot read from the asar archive, so point the
|
||||
// search path at the unpacked copy.
|
||||
const dir = path.dirname(pkgJson);
|
||||
const unpacked = dir.replace(`app.asar${path.sep}`, `app.asar.unpacked${path.sep}`);
|
||||
return existsSync(unpacked) ? unpacked : dir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend the sherpa platform package dir to the loader search path env var.
|
||||
* Mutates the provided env object.
|
||||
* @param {NodeJS.ProcessEnv} env
|
||||
*/
|
||||
export function applySherpaLoaderEnv(env) {
|
||||
const key = sherpaLoaderEnvKey();
|
||||
const libDir = resolveSherpaLibDir();
|
||||
if (!key || !libDir) {
|
||||
return { key: null, libDir: null };
|
||||
}
|
||||
const actualKey = findEnvKey(env, key);
|
||||
env[actualKey] = prependEnvPath(env[actualKey], libDir);
|
||||
return { key, libDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the sherpa-onnx-node module, trying the upstream entry first and then
|
||||
* the platform addon directly.
|
||||
*/
|
||||
export function loadSherpaOnnxNode() {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const attempts = [];
|
||||
|
||||
try {
|
||||
cached = require('sherpa-onnx-node');
|
||||
return cached;
|
||||
} catch (error) {
|
||||
attempts.push(`sherpa-onnx-node: ${error?.message || String(error)}`);
|
||||
}
|
||||
|
||||
const libDir = resolveSherpaLibDir();
|
||||
if (libDir) {
|
||||
applySherpaLoaderEnv(process.env);
|
||||
const addonPath = path.join(libDir, 'sherpa-onnx.node');
|
||||
if (existsSync(addonPath)) {
|
||||
try {
|
||||
cached = require(addonPath);
|
||||
return cached;
|
||||
} catch (error) {
|
||||
attempts.push(`${addonPath}: ${error?.message || String(error)}`);
|
||||
}
|
||||
} else {
|
||||
attempts.push(`${addonPath}: file not found`);
|
||||
}
|
||||
} else {
|
||||
attempts.push(`${sherpaPlatformPackageName()}: platform package not installed`);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to load sherpa-onnx-node for ${process.platform}-${process.arch}.`,
|
||||
`Node ${process.version} (ABI ${process.versions.modules}).`,
|
||||
'Load attempts:',
|
||||
...attempts.map((line) => `- ${line}`),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Sherpa-onnx offline recognizer engine (NeMo transducer / Parakeet) plus a
|
||||
* realtime streaming transcription session that re-decodes the accumulated
|
||||
* segment audio on a throttle to produce live partial transcripts.
|
||||
*
|
||||
* Runs inside the dictation worker process only — never load the native
|
||||
* addon in the main server process.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { existsSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { loadSherpaOnnxNode } from './sherpa-loader.js';
|
||||
import { pcm16lePeakAbs, pcm16leToFloat32 } from '../audio.js';
|
||||
|
||||
function assertFileExists(filePath, label) {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`Missing ${label}: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class SherpaOfflineRecognizerEngine {
|
||||
/**
|
||||
* @param {{ type: 'nemo_transducer' | 'whisper',
|
||||
* encoder: string, decoder: string, joiner?: string, tokens: string,
|
||||
* numThreads?: number }} config
|
||||
*/
|
||||
constructor(config) {
|
||||
assertFileExists(config.encoder, 'offline encoder');
|
||||
assertFileExists(config.decoder, 'offline decoder');
|
||||
if (config.type === 'nemo_transducer') {
|
||||
assertFileExists(config.joiner, 'offline joiner');
|
||||
}
|
||||
assertFileExists(config.tokens, 'tokens');
|
||||
|
||||
const sherpa = loadSherpaOnnxNode();
|
||||
|
||||
const modelConfig =
|
||||
config.type === 'whisper'
|
||||
? {
|
||||
whisper: {
|
||||
encoder: config.encoder,
|
||||
decoder: config.decoder,
|
||||
// Empty language auto-detects for multilingual Whisper exports.
|
||||
language: '',
|
||||
task: 'transcribe',
|
||||
tailPaddings: -1,
|
||||
},
|
||||
tokens: config.tokens,
|
||||
modelType: 'whisper',
|
||||
numThreads: config.numThreads ?? 2,
|
||||
provider: 'cpu',
|
||||
debug: 0,
|
||||
}
|
||||
: {
|
||||
transducer: {
|
||||
encoder: config.encoder,
|
||||
decoder: config.decoder,
|
||||
joiner: config.joiner,
|
||||
},
|
||||
tokens: config.tokens,
|
||||
modelType: 'nemo_transducer',
|
||||
numThreads: config.numThreads ?? 2,
|
||||
provider: 'cpu',
|
||||
debug: 0,
|
||||
};
|
||||
|
||||
const recognizerConfig = {
|
||||
featConfig: {
|
||||
sampleRate: 16000,
|
||||
featureDim: 80,
|
||||
},
|
||||
modelConfig,
|
||||
decodingMethod: 'greedy_search',
|
||||
maxActivePaths: 4,
|
||||
};
|
||||
|
||||
this.recognizer = new sherpa.OfflineRecognizer(recognizerConfig);
|
||||
const sr = this.recognizer?.config?.featConfig?.sampleRate;
|
||||
this.sampleRate =
|
||||
typeof sr === 'number' && Number.isFinite(sr) && sr > 0
|
||||
? sr
|
||||
: recognizerConfig.featConfig.sampleRate;
|
||||
}
|
||||
|
||||
createStream() {
|
||||
return this.recognizer.createStream();
|
||||
}
|
||||
|
||||
acceptWaveform(stream, sampleRate, samples) {
|
||||
if (!stream || typeof stream.acceptWaveform !== 'function') {
|
||||
throw new Error('Unexpected sherpa offline stream: missing acceptWaveform()');
|
||||
}
|
||||
// sherpa-onnx-node expects acceptWaveform({ samples, sampleRate });
|
||||
// the WASM build expects acceptWaveform(sampleRate, samples).
|
||||
if (stream.acceptWaveform.length <= 1) {
|
||||
stream.acceptWaveform({ samples, sampleRate });
|
||||
} else {
|
||||
stream.acceptWaveform(sampleRate, samples);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a full PCM16 segment and return its text.
|
||||
* Applies auto-gain when the peak is low so quiet microphones still decode.
|
||||
* @param {Buffer} pcm16
|
||||
* @returns {string}
|
||||
*/
|
||||
decodePcm16(pcm16) {
|
||||
if (pcm16.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const peak = pcm16lePeakAbs(pcm16);
|
||||
const peakFloat = peak / 32768.0;
|
||||
const targetPeak = 0.6;
|
||||
const maxGain = 50;
|
||||
const gain =
|
||||
peakFloat > 0 && peakFloat < targetPeak ? Math.min(maxGain, targetPeak / peakFloat) : 1;
|
||||
|
||||
const stream = this.createStream();
|
||||
try {
|
||||
const floatSamples = pcm16leToFloat32(pcm16, gain);
|
||||
this.acceptWaveform(stream, this.sampleRate, floatSamples);
|
||||
this.recognizer.decode(stream);
|
||||
const result = this.recognizer.getResult(stream);
|
||||
const text =
|
||||
typeof result === 'object' && result && 'text' in result ? result.text : result;
|
||||
return String(text ?? '').trim();
|
||||
} finally {
|
||||
try {
|
||||
stream.free?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free() {
|
||||
try {
|
||||
this.recognizer?.free?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming transcription session backed by the offline recognizer.
|
||||
* Accumulates the current segment's PCM and re-decodes it at most every
|
||||
* `minDecodeIntervalMs` to emit non-final partial transcripts; `commit()`
|
||||
* finalizes the segment and starts a new one.
|
||||
*
|
||||
* Implements the StreamingTranscriptionSession contract used by
|
||||
* DictationStreamManager.
|
||||
*/
|
||||
export class SherpaRealtimeTranscriptionSession extends EventEmitter {
|
||||
/**
|
||||
* @param {{ engine: SherpaOfflineRecognizerEngine, minDecodeIntervalMs?: number }} params
|
||||
*/
|
||||
constructor({ engine, minDecodeIntervalMs }) {
|
||||
super();
|
||||
this.engine = engine;
|
||||
this.requiredSampleRate = engine.sampleRate;
|
||||
this.minDecodeIntervalMs = minDecodeIntervalMs ?? 350;
|
||||
this.connected = false;
|
||||
this.currentSegmentId = null;
|
||||
this.previousSegmentId = null;
|
||||
this.lastPartialText = '';
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
this.lastDecodeAt = 0;
|
||||
this.decoding = false;
|
||||
this.pendingDecode = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
this.currentSegmentId = randomUUID();
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
appendPcm16(chunk) {
|
||||
if (!this.connected || !this.currentSegmentId) {
|
||||
this.emit('error', new Error('Sherpa realtime session not connected'));
|
||||
return;
|
||||
}
|
||||
this.pcm16 = this.pcm16.length === 0 ? chunk : Buffer.concat([this.pcm16, chunk]);
|
||||
this.maybeDecode(false).catch((err) => {
|
||||
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
}
|
||||
|
||||
commit() {
|
||||
if (!this.connected || !this.currentSegmentId) {
|
||||
this.emit('error', new Error('Sherpa realtime session not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await this.maybeDecode(true);
|
||||
const finalText = this.lastPartialText;
|
||||
const segmentId = this.currentSegmentId;
|
||||
const previousSegmentId = this.previousSegmentId;
|
||||
|
||||
this.emit('committed', { segmentId, previousSegmentId });
|
||||
this.emit('transcript', { segmentId, transcript: finalText, isFinal: true });
|
||||
|
||||
this.previousSegmentId = segmentId;
|
||||
this.currentSegmentId = randomUUID();
|
||||
this.lastPartialText = '';
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
} catch (err) {
|
||||
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
this.currentSegmentId = randomUUID();
|
||||
this.lastPartialText = '';
|
||||
}
|
||||
|
||||
close() {
|
||||
this.connected = false;
|
||||
this.currentSegmentId = null;
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
async maybeDecode(force) {
|
||||
if (!this.connected || !this.currentSegmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - this.lastDecodeAt < this.minDecodeIntervalMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.decoding) {
|
||||
this.pendingDecode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.decoding = true;
|
||||
try {
|
||||
const decodeStartedAt = Date.now();
|
||||
const text = this.engine.decodePcm16(this.pcm16);
|
||||
this.lastDecodeAt = Date.now();
|
||||
// Adaptive throttle: on slow hardware (or heavy models) re-decoding the
|
||||
// growing segment every 350ms would monopolize the worker. Space partial
|
||||
// decodes to ~1.5x the observed decode time.
|
||||
this.minDecodeIntervalMs = Math.max(350, (this.lastDecodeAt - decodeStartedAt) * 1.5);
|
||||
if (text !== this.lastPartialText) {
|
||||
this.lastPartialText = text;
|
||||
this.emit('transcript', {
|
||||
segmentId: this.currentSegmentId,
|
||||
transcript: text,
|
||||
isFinal: false,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.decoding = false;
|
||||
if (this.pendingDecode) {
|
||||
this.pendingDecode = false;
|
||||
await this.maybeDecode(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Sherpa-onnx offline TTS (Kokoro). Runs inside the dictation worker process
|
||||
* only — never load the native addon in the main server process.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { loadSherpaOnnxNode } from './sherpa-loader.js';
|
||||
|
||||
function assertFileExists(filePath, label) {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`Missing ${label}: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function float32ToPcm16le(samples) {
|
||||
const out = new Int16Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
||||
out[i] = Math.round(clamped * 32767);
|
||||
}
|
||||
return Buffer.from(out.buffer, out.byteOffset, out.byteLength);
|
||||
}
|
||||
|
||||
export class SherpaTtsEngine {
|
||||
/**
|
||||
* @param {{ modelDir: string, files: { model: string, voices: string, tokens: string, espeakData: string }, numThreads?: number }} config
|
||||
*/
|
||||
constructor(config) {
|
||||
const modelPath = path.join(config.modelDir, config.files.model);
|
||||
const voicesPath = path.join(config.modelDir, config.files.voices);
|
||||
const tokensPath = path.join(config.modelDir, config.files.tokens);
|
||||
const dataDir = path.join(config.modelDir, config.files.espeakData);
|
||||
|
||||
assertFileExists(modelPath, 'TTS model');
|
||||
assertFileExists(voicesPath, 'TTS voices');
|
||||
assertFileExists(tokensPath, 'TTS tokens');
|
||||
assertFileExists(dataDir, 'TTS espeak-ng dataDir');
|
||||
|
||||
const sherpa = loadSherpaOnnxNode();
|
||||
if (typeof sherpa.OfflineTts !== 'function') {
|
||||
throw new Error('sherpa-onnx-node OfflineTts is unavailable');
|
||||
}
|
||||
|
||||
this.tts = new sherpa.OfflineTts({
|
||||
model: {
|
||||
kokoro: {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
tokens: tokensPath,
|
||||
dataDir,
|
||||
lengthScale: 1.0,
|
||||
},
|
||||
},
|
||||
numThreads: config.numThreads ?? 2,
|
||||
provider: 'cpu',
|
||||
maxNumSentences: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize text to PCM16LE.
|
||||
* @param {string} text
|
||||
* @param {{ speakerId?: number, speed?: number }} [options]
|
||||
* @returns {{ pcm16: Buffer, sampleRate: number }}
|
||||
*/
|
||||
synthesize(text, options = {}) {
|
||||
const trimmed = String(text || '').trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Cannot synthesize empty text');
|
||||
}
|
||||
|
||||
const audio = this.tts.generate({
|
||||
text: trimmed,
|
||||
sid: Number.isInteger(options.speakerId) ? options.speakerId : 0,
|
||||
speed: typeof options.speed === 'number' && options.speed > 0 ? options.speed : 1.0,
|
||||
// Request a copied buffer from sherpa itself: native external-backed
|
||||
// typed arrays are rejected by Electron.
|
||||
enableExternalBuffer: false,
|
||||
});
|
||||
|
||||
let samples = null;
|
||||
if (audio && audio.samples instanceof Float32Array) {
|
||||
samples = Float32Array.from(audio.samples);
|
||||
} else if (audio && Array.isArray(audio.samples)) {
|
||||
samples = Float32Array.from(audio.samples);
|
||||
}
|
||||
if (!samples) {
|
||||
throw new Error('Unexpected sherpa TTS output: missing Float32 samples');
|
||||
}
|
||||
|
||||
const sampleRate =
|
||||
audio && typeof audio.sampleRate === 'number' && audio.sampleRate > 0
|
||||
? audio.sampleRate
|
||||
: typeof this.tts.sampleRate === 'number' && this.tts.sampleRate > 0
|
||||
? this.tts.sampleRate
|
||||
: 24000;
|
||||
|
||||
return { pcm16: float32ToPcm16le(samples), sampleRate };
|
||||
}
|
||||
|
||||
free() {
|
||||
try {
|
||||
this.tts?.free?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Client for the dictation local-speech worker process.
|
||||
*
|
||||
* Lazily forks the worker on first use, correlates request/response messages
|
||||
* by requestId, routes session events to per-session EventEmitters, and
|
||||
* shuts the worker down after an idle TTL so the ONNX runtime does not sit
|
||||
* in memory while dictation is unused.
|
||||
*/
|
||||
|
||||
import { fork } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { applySherpaLoaderEnv } from './sherpa-loader.js';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_LOCAL_SAMPLE_RATE = 16000;
|
||||
const STDERR_TAIL_MAX_CHARS = 2000;
|
||||
|
||||
function forkDictationWorker() {
|
||||
const env = { ...process.env };
|
||||
applySherpaLoaderEnv(env);
|
||||
return fork(fileURLToPath(new URL('./worker-process.js', import.meta.url)), [], {
|
||||
env,
|
||||
serialization: 'advanced',
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
export class DictationWorkerClient {
|
||||
/**
|
||||
* @param {{ requestTimeoutMs?: number, idleTtlMs?: number }} [options]
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
||||
this.pendingRequests = new Map();
|
||||
this.sessionEmitters = new Map();
|
||||
this.worker = null;
|
||||
this.stderrTail = '';
|
||||
this.inFlightRequests = 0;
|
||||
this.idleTimer = null;
|
||||
this.intentionalCloses = new WeakSet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize speech in the worker. Returns WAV bytes.
|
||||
* @param {{ modelsDir: string, modelId: string, text: string, speakerId?: number, speed?: number }} params
|
||||
* @returns {Promise<{ audio: Buffer, format: string }>}
|
||||
*/
|
||||
async synthesizeSpeech(params) {
|
||||
// Long texts on slow hardware can exceed the default request timeout.
|
||||
const result = await this.sendRequest(
|
||||
{ type: 'tts.synthesize', ...params },
|
||||
{ timeoutMs: 120000 },
|
||||
);
|
||||
return {
|
||||
audio: Buffer.isBuffer(result.audio) ? result.audio : Buffer.from(result.audio),
|
||||
format: result.format || 'audio/wav',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a streaming STT session in the worker.
|
||||
* @param {{ modelsDir: string, modelId: string }} params
|
||||
* @param {EventEmitter} emitter receives 'committed' | 'transcript' | 'error'
|
||||
* @returns {Promise<{ sessionId: string, requiredSampleRate: number }>}
|
||||
*/
|
||||
async createSession({ modelsDir, modelId }, emitter) {
|
||||
const sessionId = randomUUID();
|
||||
this.sessionEmitters.set(sessionId, emitter);
|
||||
try {
|
||||
const result = await this.sendRequest({
|
||||
type: 'session.create',
|
||||
sessionId,
|
||||
modelsDir,
|
||||
modelId,
|
||||
});
|
||||
return { sessionId, requiredSampleRate: result?.requiredSampleRate ?? DEFAULT_LOCAL_SAMPLE_RATE };
|
||||
} catch (err) {
|
||||
this.sessionEmitters.delete(sessionId);
|
||||
this.scheduleIdleShutdownIfReady();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
appendSessionAudio(sessionId, audio) {
|
||||
void this.sendRequest({ type: 'session.append', sessionId, audio }).catch((err) => {
|
||||
this.emitSessionError(sessionId, err);
|
||||
});
|
||||
}
|
||||
|
||||
commitSession(sessionId) {
|
||||
void this.sendRequest({ type: 'session.commit', sessionId }).catch((err) => {
|
||||
this.emitSessionError(sessionId, err);
|
||||
});
|
||||
}
|
||||
|
||||
clearSession(sessionId) {
|
||||
void this.sendRequest({ type: 'session.clear', sessionId }).catch((err) => {
|
||||
this.emitSessionError(sessionId, err);
|
||||
});
|
||||
}
|
||||
|
||||
closeSession(sessionId) {
|
||||
this.sessionEmitters.delete(sessionId);
|
||||
void this.sendRequest({ type: 'session.close', sessionId }).catch(() => {
|
||||
// Closing is best-effort; the parent already dropped the session.
|
||||
});
|
||||
this.scheduleIdleShutdownIfReady();
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.clearIdleTimer();
|
||||
this.rejectAllPending(new Error('Dictation worker shut down'));
|
||||
this.sessionEmitters.clear();
|
||||
const worker = this.worker;
|
||||
this.worker = null;
|
||||
if (worker && !worker.killed) {
|
||||
this.intentionalCloses.add(worker);
|
||||
try {
|
||||
worker.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
worker.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendRequest(input, options = {}) {
|
||||
const worker = this.ensureWorker();
|
||||
const requestId = randomUUID();
|
||||
const message = { ...input, requestId };
|
||||
this.inFlightRequests += 1;
|
||||
this.clearIdleTimer();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
|
||||
this.scheduleIdleShutdownIfReady();
|
||||
reject(new Error(`Dictation worker request timed out: ${input.type}`));
|
||||
}, options.timeoutMs ?? this.requestTimeoutMs);
|
||||
|
||||
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||
|
||||
worker.send(message, (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pendingRequests.get(requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
|
||||
this.scheduleIdleShutdownIfReady();
|
||||
pending.reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ensureWorker() {
|
||||
if (this.worker && !this.worker.killed && this.worker.connected) {
|
||||
return this.worker;
|
||||
}
|
||||
const worker = forkDictationWorker();
|
||||
this.worker = worker;
|
||||
this.stderrTail = '';
|
||||
worker.stderr?.on('data', (chunk) => {
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
||||
this.stderrTail = (this.stderrTail + text).slice(-STDERR_TAIL_MAX_CHARS);
|
||||
});
|
||||
worker.on('message', (message) => this.handleWorkerMessage(message));
|
||||
worker.on('close', (code, signal) => this.handleWorkerExit(worker, code, signal));
|
||||
return worker;
|
||||
}
|
||||
|
||||
handleWorkerMessage(message) {
|
||||
if (message?.type === 'response') {
|
||||
const pending = this.pendingRequests.get(message.requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(message.requestId);
|
||||
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
|
||||
this.scheduleIdleShutdownIfReady();
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result);
|
||||
} else {
|
||||
pending.reject(new Error(message.error || 'Dictation worker request failed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const emitter = this.sessionEmitters.get(message?.sessionId);
|
||||
if (!emitter) {
|
||||
return;
|
||||
}
|
||||
switch (message.type) {
|
||||
case 'session.committed':
|
||||
emitter.emit('committed', message.payload);
|
||||
return;
|
||||
case 'session.transcript':
|
||||
emitter.emit('transcript', message.payload);
|
||||
return;
|
||||
case 'session.error':
|
||||
emitter.emit('error', new Error(message.error));
|
||||
return;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
handleWorkerExit(worker, code, signal) {
|
||||
const wasCurrentWorker = this.worker === worker;
|
||||
const wasIntentional = this.intentionalCloses.has(worker);
|
||||
this.intentionalCloses.delete(worker);
|
||||
if (!wasCurrentWorker || wasIntentional) {
|
||||
if (wasCurrentWorker) {
|
||||
this.worker = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const stderr = this.stderrTail.trim();
|
||||
const error = new Error(
|
||||
`Dictation worker exited (code ${code ?? 'null'}${signal ? `, signal ${signal}` : ''}).` +
|
||||
(stderr ? ` Last stderr: ${stderr.slice(-500)}` : ''),
|
||||
);
|
||||
|
||||
this.worker = null;
|
||||
this.clearIdleTimer();
|
||||
this.rejectAllPending(error);
|
||||
for (const emitter of this.sessionEmitters.values()) {
|
||||
if (emitter.listenerCount('error') > 0) {
|
||||
emitter.emit('error', error);
|
||||
}
|
||||
}
|
||||
this.sessionEmitters.clear();
|
||||
this.inFlightRequests = 0;
|
||||
}
|
||||
|
||||
rejectAllPending(error) {
|
||||
for (const [requestId, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(error);
|
||||
this.pendingRequests.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
emitSessionError(sessionId, error) {
|
||||
const emitter = this.sessionEmitters.get(sessionId);
|
||||
if (emitter && emitter.listenerCount('error') > 0) {
|
||||
emitter.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
scheduleIdleShutdownIfReady() {
|
||||
if (!this.worker || this.inFlightRequests > 0 || this.sessionEmitters.size > 0) {
|
||||
return;
|
||||
}
|
||||
this.clearIdleTimer();
|
||||
this.idleTimer = setTimeout(() => {
|
||||
if (this.inFlightRequests === 0 && this.sessionEmitters.size === 0) {
|
||||
this.shutdown();
|
||||
}
|
||||
}, this.idleTtlMs);
|
||||
}
|
||||
|
||||
clearIdleTimer() {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* StreamingTranscriptionSession backed by the worker process.
|
||||
* Matches the session contract consumed by DictationStreamManager.
|
||||
*/
|
||||
export class WorkerBackedTranscriptionSession extends EventEmitter {
|
||||
/**
|
||||
* @param {DictationWorkerClient} client
|
||||
* @param {{ modelsDir: string, modelId: string }} modelConfig
|
||||
*/
|
||||
constructor(client, modelConfig) {
|
||||
super();
|
||||
this.client = client;
|
||||
this.modelConfig = modelConfig;
|
||||
this.requiredSampleRate = DEFAULT_LOCAL_SAMPLE_RATE;
|
||||
this.connectedSessionId = null;
|
||||
this.connecting = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connectedSessionId) {
|
||||
return;
|
||||
}
|
||||
if (!this.connecting) {
|
||||
this.connecting = (async () => {
|
||||
try {
|
||||
const result = await this.client.createSession(this.modelConfig, this);
|
||||
this.connectedSessionId = result.sessionId;
|
||||
this.requiredSampleRate = result.requiredSampleRate;
|
||||
} finally {
|
||||
this.connecting = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
await this.connecting;
|
||||
}
|
||||
|
||||
appendPcm16(pcm16le) {
|
||||
if (!this.connectedSessionId) {
|
||||
this.emit('error', new Error('Local STT session not connected'));
|
||||
return;
|
||||
}
|
||||
this.client.appendSessionAudio(this.connectedSessionId, pcm16le);
|
||||
}
|
||||
|
||||
commit() {
|
||||
if (!this.connectedSessionId) {
|
||||
this.emit('error', new Error('Local STT session not connected'));
|
||||
return;
|
||||
}
|
||||
this.client.commitSession(this.connectedSessionId);
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.connectedSessionId) {
|
||||
this.client.clearSession(this.connectedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
const sessionId = this.connectedSessionId;
|
||||
this.connectedSessionId = null;
|
||||
if (sessionId) {
|
||||
this.client.closeSession(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Dictation local-speech worker process.
|
||||
*
|
||||
* Hosts the sherpa-onnx native inference (Parakeet STT) in a separate process
|
||||
* so ONNX decoding never blocks the main OpenChamber server. Communicates
|
||||
* with the parent over child_process IPC (advanced serialization, so Buffers
|
||||
* survive the trip as Uint8Array).
|
||||
*
|
||||
* Request/response protocol (parent -> worker):
|
||||
* { type: 'session.create', requestId, sessionId, modelsDir, modelId }
|
||||
* { type: 'session.append', requestId, sessionId, audio }
|
||||
* { type: 'session.commit' | 'session.clear' | 'session.close', requestId, sessionId }
|
||||
* Worker -> parent:
|
||||
* { type: 'response', requestId, ok, result?, error? }
|
||||
* { type: 'session.committed' | 'session.transcript' | 'session.error', sessionId, ... }
|
||||
*/
|
||||
|
||||
import {
|
||||
SherpaOfflineRecognizerEngine,
|
||||
SherpaRealtimeTranscriptionSession,
|
||||
} from './sherpa-recognizer.js';
|
||||
import { SherpaTtsEngine } from './sherpa-tts.js';
|
||||
import { getLocalSttModelDir, getLocalSttModelSpec } from './model-catalog.js';
|
||||
import { pcm16ToWav } from '../audio.js';
|
||||
import path from 'path';
|
||||
|
||||
process.title = 'OpenChamber Dictation';
|
||||
|
||||
const engines = new Map();
|
||||
const ttsEngines = new Map();
|
||||
const sessions = new Map();
|
||||
let ipcClosing = false;
|
||||
|
||||
function sendToParent(message) {
|
||||
if (ipcClosing || !process.connected || !process.send) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.send(message, (error) => {
|
||||
if (error) {
|
||||
ipcClosing = true;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
ipcClosing = true;
|
||||
}
|
||||
}
|
||||
|
||||
function sendOk(requestId, result) {
|
||||
sendToParent({ type: 'response', requestId, ok: true, ...(result !== undefined ? { result } : {}) });
|
||||
}
|
||||
|
||||
function getEngine(modelsDir, modelId) {
|
||||
const key = `${modelsDir}:${modelId}`;
|
||||
const existing = engines.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const modelDir = getLocalSttModelDir(modelsDir, modelId);
|
||||
const spec = getLocalSttModelSpec(modelId);
|
||||
const created = new SherpaOfflineRecognizerEngine({
|
||||
type: spec.type,
|
||||
encoder: path.join(modelDir, spec.files.encoder),
|
||||
decoder: path.join(modelDir, spec.files.decoder),
|
||||
...(spec.files.joiner ? { joiner: path.join(modelDir, spec.files.joiner) } : {}),
|
||||
tokens: path.join(modelDir, spec.files.tokens),
|
||||
numThreads: 2,
|
||||
});
|
||||
engines.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function cleanupSession(sessionId) {
|
||||
const session = sessions.get(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
try {
|
||||
session?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toBuffer(audio) {
|
||||
if (Buffer.isBuffer(audio)) {
|
||||
return audio;
|
||||
}
|
||||
if (audio instanceof Uint8Array) {
|
||||
return Buffer.from(audio.buffer, audio.byteOffset, audio.byteLength);
|
||||
}
|
||||
if (audio && typeof audio === 'object' && audio.type === 'Buffer' && Array.isArray(audio.data)) {
|
||||
return Buffer.from(audio.data);
|
||||
}
|
||||
throw new Error('Unsupported audio payload in dictation worker');
|
||||
}
|
||||
|
||||
function getTtsEngine(modelsDir, modelId) {
|
||||
const key = `${modelsDir}:${modelId}`;
|
||||
const existing = ttsEngines.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const spec = getLocalSttModelSpec(modelId);
|
||||
const created = new SherpaTtsEngine({
|
||||
modelDir: getLocalSttModelDir(modelsDir, modelId),
|
||||
files: spec.files,
|
||||
numThreads: 2,
|
||||
});
|
||||
ttsEngines.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function handleRequest(message) {
|
||||
switch (message.type) {
|
||||
case 'tts.synthesize': {
|
||||
const engine = getTtsEngine(message.modelsDir, message.modelId);
|
||||
const { pcm16, sampleRate } = engine.synthesize(message.text, {
|
||||
speakerId: message.speakerId,
|
||||
speed: message.speed,
|
||||
});
|
||||
sendOk(message.requestId, {
|
||||
audio: pcm16ToWav(pcm16, sampleRate),
|
||||
format: 'audio/wav',
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'session.create': {
|
||||
cleanupSession(message.sessionId);
|
||||
const engine = getEngine(message.modelsDir, message.modelId);
|
||||
const session = new SherpaRealtimeTranscriptionSession({ engine });
|
||||
session.on('committed', (payload) => {
|
||||
sendToParent({ type: 'session.committed', sessionId: message.sessionId, payload });
|
||||
});
|
||||
session.on('transcript', (payload) => {
|
||||
sendToParent({ type: 'session.transcript', sessionId: message.sessionId, payload });
|
||||
});
|
||||
session.on('error', (err) => {
|
||||
sendToParent({
|
||||
type: 'session.error',
|
||||
sessionId: message.sessionId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
await session.connect();
|
||||
sessions.set(message.sessionId, session);
|
||||
sendOk(message.requestId, { requiredSampleRate: session.requiredSampleRate });
|
||||
return;
|
||||
}
|
||||
case 'session.append': {
|
||||
sessions.get(message.sessionId)?.appendPcm16(toBuffer(message.audio));
|
||||
sendOk(message.requestId);
|
||||
return;
|
||||
}
|
||||
case 'session.commit': {
|
||||
sessions.get(message.sessionId)?.commit();
|
||||
sendOk(message.requestId);
|
||||
return;
|
||||
}
|
||||
case 'session.clear': {
|
||||
sessions.get(message.sessionId)?.clear();
|
||||
sendOk(message.requestId);
|
||||
return;
|
||||
}
|
||||
case 'session.close': {
|
||||
cleanupSession(message.sessionId);
|
||||
sendOk(message.requestId);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown dictation worker request: ${message?.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.on('message', (message) => {
|
||||
void handleRequest(message).catch((error) => {
|
||||
sendToParent({
|
||||
type: 'response',
|
||||
requestId: message?.requestId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : 'Dictation worker request failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
process.once('disconnect', () => {
|
||||
ipcClosing = true;
|
||||
for (const sessionId of Array.from(sessions.keys())) {
|
||||
cleanupSession(sessionId);
|
||||
}
|
||||
for (const engine of engines.values()) {
|
||||
engine.free();
|
||||
}
|
||||
for (const tts of ttsEngines.values()) {
|
||||
tts.free();
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Pseudo-streaming transcription session for OpenAI-compatible Whisper
|
||||
* endpoints (faster-whisper, whisper.cpp, OpenAI, ...).
|
||||
*
|
||||
* The Whisper HTTP API cannot stream, so audio is buffered per segment and
|
||||
* transcribed on commit(). Live partials therefore only advance at segment
|
||||
* boundaries (the DictationStreamManager auto-commits every ~15s of speech).
|
||||
*
|
||||
* Implements the StreamingTranscriptionSession contract used by
|
||||
* DictationStreamManager.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { transcribeAudio } from '../tts/stt.js';
|
||||
import { pcm16ToWav } from './audio.js';
|
||||
|
||||
const OPENAI_COMPATIBLE_SAMPLE_RATE = 16000;
|
||||
|
||||
export class OpenAICompatibleTranscriptionSession extends EventEmitter {
|
||||
/**
|
||||
* @param {{ baseURL: string, model: string, apiKey?: string, language?: string, prompt?: string }} config
|
||||
*/
|
||||
constructor(config) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.requiredSampleRate = OPENAI_COMPATIBLE_SAMPLE_RATE;
|
||||
this.connected = false;
|
||||
this.segmentId = randomUUID();
|
||||
this.previousSegmentId = null;
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (!this.config.baseURL) {
|
||||
throw new Error('Custom STT server URL is not configured');
|
||||
}
|
||||
if (!this.config.model) {
|
||||
throw new Error('STT model is not configured');
|
||||
}
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
appendPcm16(chunk) {
|
||||
if (!this.connected) {
|
||||
this.emit('error', new Error('STT session not connected'));
|
||||
return;
|
||||
}
|
||||
this.pcm16 = this.pcm16.length === 0 ? chunk : Buffer.concat([this.pcm16, chunk]);
|
||||
}
|
||||
|
||||
commit() {
|
||||
if (!this.connected) {
|
||||
this.emit('error', new Error('STT session not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const committedId = this.segmentId;
|
||||
const previousSegmentId = this.previousSegmentId;
|
||||
const committedPcm16 = this.pcm16;
|
||||
this.previousSegmentId = committedId;
|
||||
this.segmentId = randomUUID();
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
this.emit('committed', { segmentId: committedId, previousSegmentId });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const wav = pcm16ToWav(committedPcm16, OPENAI_COMPATIBLE_SAMPLE_RATE);
|
||||
const text = await transcribeAudio({
|
||||
audioBuffer: wav,
|
||||
mimeType: 'audio/wav',
|
||||
model: this.config.model,
|
||||
baseURL: this.config.baseURL,
|
||||
apiKey: this.config.apiKey,
|
||||
language: this.config.language,
|
||||
});
|
||||
this.emit('transcript', {
|
||||
segmentId: committedId,
|
||||
transcript: (text ?? '').trim(),
|
||||
isFinal: true,
|
||||
});
|
||||
} catch (err) {
|
||||
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
this.segmentId = randomUUID();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.connected = false;
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Dictation runtime: registers the streaming dictation WebSocket endpoint and
|
||||
* the HTTP status/model routes.
|
||||
*
|
||||
* WebSocket protocol (JSON text frames) on /api/dictation/ws:
|
||||
* client -> server:
|
||||
* { type: 'start', dictationId, format, options? }
|
||||
* options: { provider?, language?, localModel?, openaiCompatible? }
|
||||
* { type: 'chunk', dictationId, seq, audio } // audio: base64 PCM16LE
|
||||
* { type: 'finish', dictationId, finalSeq }
|
||||
* { type: 'cancel', dictationId }
|
||||
* { type: 'ping' }
|
||||
* server -> client:
|
||||
* { type: 'ready' }
|
||||
* { type: 'ack', dictationId, ackSeq }
|
||||
* { type: 'partial', dictationId, text }
|
||||
* { type: 'finish_accepted', dictationId, timeoutMs }
|
||||
* { type: 'final', dictationId, text }
|
||||
* { type: 'error', dictationId, error, retryable, reasonCode? }
|
||||
* { type: 'pong' }
|
||||
*/
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { DictationStreamManager } from './stream-manager.js';
|
||||
import { createDictationService } from './service.js';
|
||||
|
||||
const DICTATION_WS_PATH = '/api/dictation/ws';
|
||||
|
||||
const DICTATION_WS_MAX_PAYLOAD_BYTES = 512 * 1024;
|
||||
const DICTATION_WS_HEARTBEAT_INTERVAL_MS = 30000;
|
||||
|
||||
const parseRequestPathname = (url) => {
|
||||
try {
|
||||
return new URL(url, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return typeof url === 'string' ? url.split('?')[0] : '';
|
||||
}
|
||||
};
|
||||
|
||||
export function createDictationRuntime({
|
||||
app,
|
||||
server,
|
||||
express,
|
||||
uiAuthController,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
modelsDir,
|
||||
}) {
|
||||
const service = createDictationService({ modelsDir });
|
||||
|
||||
// Local text-to-speech (Kokoro in the dictation worker). Returns WAV bytes;
|
||||
// 503 with a reason code while the model is still downloading.
|
||||
app.post('/api/dictation/tts/speak', express.json({ limit: '1mb' }), async (req, res) => {
|
||||
try {
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
|
||||
if (!text) {
|
||||
res.status(400).json({ error: 'Text is required' });
|
||||
return;
|
||||
}
|
||||
const result = await service.synthesizeSpeech({
|
||||
text,
|
||||
model: typeof req.body?.model === 'string' ? req.body.model : undefined,
|
||||
speakerId: Number.isInteger(req.body?.speakerId) ? req.body.speakerId : undefined,
|
||||
speed: typeof req.body?.speed === 'number' ? req.body.speed : undefined,
|
||||
});
|
||||
if (result.error) {
|
||||
res.status(503).json({
|
||||
error: result.error,
|
||||
retryable: result.retryable !== false,
|
||||
...(result.reasonCode ? { reasonCode: result.reasonCode } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', result.format || 'audio/wav');
|
||||
res.send(result.audio);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message || 'Failed to synthesize speech' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/dictation/status', async (req, res) => {
|
||||
try {
|
||||
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
|
||||
const localModel = typeof req.query.localModel === 'string' ? req.query.localModel : undefined;
|
||||
const status = await service.getStatus({ provider, localModel });
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message || 'Failed to read dictation status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/dictation/models/:modelId/download', async (req, res) => {
|
||||
try {
|
||||
const result = await service.requestModelDownload(req.params.modelId);
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message || 'Failed to start model download' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/dictation/models/:modelId', async (req, res) => {
|
||||
try {
|
||||
const result = await service.deleteModel(req.params.modelId);
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message || 'Failed to delete model' });
|
||||
}
|
||||
});
|
||||
|
||||
const wsServer = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: DICTATION_WS_MAX_PAYLOAD_BYTES,
|
||||
});
|
||||
|
||||
wsServer.on('connection', (socket) => {
|
||||
const send = (msg) => {
|
||||
if (socket.readyState !== 1) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
socket.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// socket is going away; the manager cleanup on close handles state
|
||||
}
|
||||
};
|
||||
|
||||
const manager = new DictationStreamManager({
|
||||
emit: ({ type, payload }) => send({ type, ...payload }),
|
||||
createSttSession: (options) => service.createSttSession(options),
|
||||
});
|
||||
|
||||
send({ type: 'ready' });
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
if (socket.readyState !== 1) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
socket.ping();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, DICTATION_WS_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
socket.on('message', (raw, isBinary) => {
|
||||
if (isBinary) {
|
||||
return;
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw.toString('utf8'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'start': {
|
||||
if (typeof message.dictationId !== 'string' || typeof message.format !== 'string') {
|
||||
return;
|
||||
}
|
||||
const options =
|
||||
message.options && typeof message.options === 'object' ? message.options : {};
|
||||
void manager.handleStart(message.dictationId, message.format, options);
|
||||
return;
|
||||
}
|
||||
case 'chunk': {
|
||||
if (
|
||||
typeof message.dictationId !== 'string' ||
|
||||
typeof message.seq !== 'number' ||
|
||||
typeof message.audio !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
manager.handleChunk({
|
||||
dictationId: message.dictationId,
|
||||
seq: message.seq,
|
||||
audioBase64: message.audio,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'finish': {
|
||||
if (typeof message.dictationId !== 'string' || typeof message.finalSeq !== 'number') {
|
||||
return;
|
||||
}
|
||||
manager.handleFinish(message.dictationId, message.finalSeq);
|
||||
return;
|
||||
}
|
||||
case 'cancel': {
|
||||
if (typeof message.dictationId !== 'string') {
|
||||
return;
|
||||
}
|
||||
manager.handleCancel(message.dictationId);
|
||||
return;
|
||||
}
|
||||
case 'ping': {
|
||||
send({ type: 'pong' });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
manager.cleanupAll();
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
// 'close' follows and performs cleanup.
|
||||
});
|
||||
});
|
||||
|
||||
const upgradeHandler = (req, socket, head) => {
|
||||
const pathname = parseRequestPathname(req.url);
|
||||
if (pathname !== DICTATION_WS_PATH) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
if (uiAuthController?.enabled) {
|
||||
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
|
||||
if (!sessionToken) {
|
||||
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
const originAllowed = await isRequestOriginAllowed(req);
|
||||
if (!originAllowed) {
|
||||
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
||||
wsServer.emit('connection', ws, req);
|
||||
});
|
||||
} catch {
|
||||
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
|
||||
}
|
||||
};
|
||||
|
||||
void handleUpgrade();
|
||||
};
|
||||
|
||||
server.on('upgrade', upgradeHandler);
|
||||
|
||||
const stop = () => {
|
||||
server.off('upgrade', upgradeHandler);
|
||||
for (const client of wsServer.clients) {
|
||||
try {
|
||||
client.close(1001, 'server shutting down');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
wsServer.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
service.shutdown();
|
||||
};
|
||||
|
||||
return { stop };
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Dictation service: resolves STT providers, tracks local model download
|
||||
* state, and exposes a readiness snapshot for the status route.
|
||||
*
|
||||
* Providers:
|
||||
* - 'local' (default): sherpa-onnx Parakeet running in a worker process.
|
||||
* Models auto-download in the background on first use.
|
||||
* - 'openai-compatible': any OpenAI-compatible /v1/audio/transcriptions
|
||||
* endpoint (faster-whisper, whisper.cpp, OpenAI).
|
||||
*/
|
||||
|
||||
import { rm } from 'fs/promises';
|
||||
|
||||
import { DictationWorkerClient, WorkerBackedTranscriptionSession } from './local/worker-client.js';
|
||||
import { OpenAICompatibleTranscriptionSession } from './openai-compatible-session.js';
|
||||
import {
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
DEFAULT_LOCAL_TTS_MODEL,
|
||||
LOCAL_STT_MODEL_CATALOG,
|
||||
LOCAL_STT_MODEL_IDS,
|
||||
LOCAL_TTS_MODEL_CATALOG,
|
||||
LOCAL_TTS_MODEL_IDS,
|
||||
getLocalSttModelDir,
|
||||
isLocalModelId,
|
||||
isLocalSttModelId,
|
||||
isLocalTtsModelId,
|
||||
} from './local/model-catalog.js';
|
||||
import { ensureLocalSttModel, isLocalSttModelInstalled } from './local/model-downloader.js';
|
||||
|
||||
export function createDictationService({ modelsDir }) {
|
||||
const workerClient = new DictationWorkerClient();
|
||||
/** modelId -> 'downloading' | 'error' */
|
||||
const downloadStates = new Map();
|
||||
/** modelId -> last download error message */
|
||||
const downloadErrors = new Map();
|
||||
/** modelId -> in-flight ensure promise */
|
||||
const downloadPromises = new Map();
|
||||
/** modelId -> 0..100 download percent (null while size unknown) */
|
||||
const downloadProgress = new Map();
|
||||
|
||||
const startModelDownload = (modelId) => {
|
||||
const existing = downloadPromises.get(modelId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
downloadStates.set(modelId, 'downloading');
|
||||
downloadErrors.delete(modelId);
|
||||
downloadProgress.set(modelId, 0);
|
||||
const promise = ensureLocalSttModel({
|
||||
modelsDir,
|
||||
modelId,
|
||||
onProgress: (downloadedBytes, totalBytes) => {
|
||||
downloadProgress.set(
|
||||
modelId,
|
||||
totalBytes ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : null,
|
||||
);
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
downloadStates.delete(modelId);
|
||||
downloadPromises.delete(modelId);
|
||||
downloadProgress.delete(modelId);
|
||||
})
|
||||
.catch((error) => {
|
||||
downloadStates.set(modelId, 'error');
|
||||
downloadErrors.set(modelId, error?.message || String(error));
|
||||
downloadPromises.delete(modelId);
|
||||
downloadProgress.delete(modelId);
|
||||
});
|
||||
downloadPromises.set(modelId, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const resolveLocalModelId = (requested) => {
|
||||
return isLocalSttModelId(requested) ? requested : DEFAULT_LOCAL_STT_MODEL;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a connected StreamingTranscriptionSession for one dictation.
|
||||
* Returns { session } on success or { error, retryable, reasonCode } when
|
||||
* the provider is not ready.
|
||||
*
|
||||
* @param {{ provider?: string, language?: string, localModel?: string,
|
||||
* openaiCompatible?: { baseUrl?: string, model?: string, apiKey?: string } }} options
|
||||
*/
|
||||
const createSttSession = async (options = {}) => {
|
||||
const provider = options.provider === 'openai-compatible' ? 'openai-compatible' : 'local';
|
||||
|
||||
if (provider === 'openai-compatible') {
|
||||
const config = options.openaiCompatible || {};
|
||||
const session = new OpenAICompatibleTranscriptionSession({
|
||||
baseURL: config.baseUrl,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey || undefined,
|
||||
language: options.language || undefined,
|
||||
});
|
||||
try {
|
||||
await session.connect();
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error?.message || String(error),
|
||||
retryable: false,
|
||||
reasonCode: 'stt_not_configured',
|
||||
};
|
||||
}
|
||||
return { session };
|
||||
}
|
||||
|
||||
const modelId = resolveLocalModelId(options.localModel);
|
||||
const installed = await isLocalSttModelInstalled(modelsDir, modelId);
|
||||
if (!installed) {
|
||||
const state = downloadStates.get(modelId);
|
||||
if (state === 'error') {
|
||||
const message = downloadErrors.get(modelId) || 'Model download failed';
|
||||
// Allow a retry on the next attempt.
|
||||
downloadStates.delete(modelId);
|
||||
return {
|
||||
error: `Failed to download dictation model: ${message}`,
|
||||
retryable: true,
|
||||
reasonCode: 'model_download_failed',
|
||||
};
|
||||
}
|
||||
void startModelDownload(modelId);
|
||||
return {
|
||||
error: 'Dictation model is downloading',
|
||||
retryable: true,
|
||||
reasonCode: 'model_download_in_progress',
|
||||
};
|
||||
}
|
||||
|
||||
const session = new WorkerBackedTranscriptionSession(workerClient, { modelsDir, modelId });
|
||||
try {
|
||||
await session.connect();
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error);
|
||||
// A model that passes the file-presence check but fails to load is
|
||||
// corrupt on disk (e.g. truncated by an interrupted extraction). Remove
|
||||
// it so the next attempt re-downloads instead of crashing forever.
|
||||
if (/Load model|Protobuf parsing failed/i.test(message)) {
|
||||
await rm(getLocalSttModelDir(modelsDir, modelId), { recursive: true, force: true })
|
||||
.catch(() => undefined);
|
||||
return {
|
||||
error: 'Dictation model files were corrupt and have been removed; retry to re-download',
|
||||
retryable: true,
|
||||
reasonCode: 'model_corrupt',
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: message,
|
||||
retryable: true,
|
||||
reasonCode: 'stt_unavailable',
|
||||
};
|
||||
}
|
||||
return { session };
|
||||
};
|
||||
|
||||
/**
|
||||
* Readiness snapshot for the status route and UI gating.
|
||||
* @param {{ provider?: string, localModel?: string }} [options]
|
||||
*/
|
||||
const getStatus = async (options = {}) => {
|
||||
const provider = options.provider === 'openai-compatible' ? 'openai-compatible' : 'local';
|
||||
const modelId = resolveLocalModelId(options.localModel);
|
||||
|
||||
const describeModel = async (id, catalog) => ({
|
||||
id,
|
||||
description: catalog[id].description,
|
||||
installed: await isLocalSttModelInstalled(modelsDir, id),
|
||||
downloading: downloadStates.get(id) === 'downloading',
|
||||
downloadProgress: downloadProgress.get(id) ?? null,
|
||||
downloadError: downloadErrors.get(id) || null,
|
||||
});
|
||||
|
||||
const models = await Promise.all(
|
||||
LOCAL_STT_MODEL_IDS.map((id) => describeModel(id, LOCAL_STT_MODEL_CATALOG)),
|
||||
);
|
||||
const ttsModels = await Promise.all(
|
||||
LOCAL_TTS_MODEL_IDS.map((id) => describeModel(id, LOCAL_TTS_MODEL_CATALOG)),
|
||||
);
|
||||
|
||||
if (provider === 'openai-compatible') {
|
||||
return { provider, available: true, models, ttsModels };
|
||||
}
|
||||
|
||||
const model = models.find((entry) => entry.id === modelId) || null;
|
||||
if (model?.installed) {
|
||||
return { provider, available: true, activeModel: modelId, models, ttsModels };
|
||||
}
|
||||
if (model?.downloading) {
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
reasonCode: 'model_download_in_progress',
|
||||
activeModel: modelId,
|
||||
models,
|
||||
ttsModels,
|
||||
};
|
||||
}
|
||||
if (model?.downloadError) {
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
reasonCode: 'model_download_failed',
|
||||
error: model.downloadError,
|
||||
activeModel: modelId,
|
||||
models,
|
||||
ttsModels,
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
reasonCode: 'models_missing',
|
||||
activeModel: modelId,
|
||||
models,
|
||||
ttsModels,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Synthesize speech with the local TTS model. Returns WAV bytes, or a
|
||||
* readiness error while the model is missing/downloading.
|
||||
* @param {{ text: string, model?: string, speakerId?: number, speed?: number }} options
|
||||
*/
|
||||
const synthesizeSpeech = async ({ text, model, speakerId, speed }) => {
|
||||
const modelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL;
|
||||
const installed = await isLocalSttModelInstalled(modelsDir, modelId);
|
||||
if (!installed) {
|
||||
const state = downloadStates.get(modelId);
|
||||
if (state === 'error') {
|
||||
const message = downloadErrors.get(modelId) || 'Model download failed';
|
||||
downloadStates.delete(modelId);
|
||||
return {
|
||||
error: `Failed to download TTS model: ${message}`,
|
||||
retryable: true,
|
||||
reasonCode: 'model_download_failed',
|
||||
};
|
||||
}
|
||||
void startModelDownload(modelId);
|
||||
return {
|
||||
error: 'TTS model is downloading',
|
||||
retryable: true,
|
||||
reasonCode: 'model_download_in_progress',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await workerClient.synthesizeSpeech({
|
||||
modelsDir,
|
||||
modelId,
|
||||
text,
|
||||
speakerId,
|
||||
speed,
|
||||
});
|
||||
return { audio: result.audio, format: result.format };
|
||||
};
|
||||
|
||||
/**
|
||||
* Kick off a background download for a model (used by the status route's
|
||||
* download action so Settings can pre-download models).
|
||||
*/
|
||||
const requestModelDownload = async (modelId) => {
|
||||
if (!isLocalModelId(modelId)) {
|
||||
return { ok: false, error: 'Unknown model id' };
|
||||
}
|
||||
if (await isLocalSttModelInstalled(modelsDir, modelId)) {
|
||||
return { ok: true, installed: true };
|
||||
}
|
||||
void startModelDownload(modelId);
|
||||
return { ok: true, installed: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an installed model from disk. A model that is mid-download cannot
|
||||
* be deleted. An engine already loaded in the worker keeps its in-memory
|
||||
* copy until the worker's idle shutdown; the files are simply re-downloaded
|
||||
* on the next use if the model is selected again.
|
||||
*/
|
||||
const deleteModel = async (modelId) => {
|
||||
if (!isLocalModelId(modelId)) {
|
||||
return { ok: false, error: 'Unknown model id' };
|
||||
}
|
||||
if (downloadStates.get(modelId) === 'downloading') {
|
||||
return { ok: false, error: 'Model is downloading' };
|
||||
}
|
||||
await rm(getLocalSttModelDir(modelsDir, modelId), { recursive: true, force: true });
|
||||
downloadErrors.delete(modelId);
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const shutdown = () => {
|
||||
workerClient.shutdown();
|
||||
};
|
||||
|
||||
return {
|
||||
createSttSession,
|
||||
synthesizeSpeech,
|
||||
getStatus,
|
||||
requestModelDownload,
|
||||
deleteModel,
|
||||
shutdown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* DictationStreamManager
|
||||
*
|
||||
* Server-authoritative streaming dictation state machine. One manager owns
|
||||
* all dictation streams for a single WebSocket connection.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Reorders inbound chunks by `seq` and acks the highest contiguous seq.
|
||||
* - Resamples client PCM (16 kHz by default) to the provider's required rate.
|
||||
* - Auto-commits a segment every `autoCommitSeconds` of audio, but clears
|
||||
* silence-only segments instead of committing them.
|
||||
* - Concatenates per-segment transcripts into live partials and emits the
|
||||
* final text once every committed segment has a final transcript.
|
||||
* - Applies an adaptive finalization timeout budget based on pending work.
|
||||
*/
|
||||
|
||||
import { Pcm16MonoResampler, parsePcmRateFromFormat, pcm16lePeakAbs } from './audio.js';
|
||||
|
||||
const DEFAULT_FINAL_TIMEOUT_MS = 10000;
|
||||
const DEFAULT_AUTO_COMMIT_SECONDS = 15;
|
||||
const FINAL_TIMEOUT_MAX_MS = 5 * 60 * 1000;
|
||||
const FINAL_TIMEOUT_PER_PENDING_SEGMENT_MS = 15 * 1000;
|
||||
const FINAL_TIMEOUT_PER_PENDING_AUDIO_SECOND_MS = 1500;
|
||||
const FINAL_TIMEOUT_PER_MISSING_SEQ_MS = 250;
|
||||
const SILENCE_PEAK_THRESHOLD = 300;
|
||||
|
||||
export class DictationStreamManager {
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {(msg: { type: string, payload: object }) => void} params.emit
|
||||
* @param {(startOptions: object) => Promise<{ session: object } | { error: string, retryable: boolean, reasonCode?: string }>} params.createSttSession
|
||||
* Resolves a connected streaming transcription session for one dictation.
|
||||
* The streaming transcription session contract:
|
||||
* { requiredSampleRate, appendPcm16(buf), commit(), clear(), close(), on(event, handler) }
|
||||
* @param {number} [params.finalTimeoutMs]
|
||||
* @param {number} [params.autoCommitSeconds]
|
||||
*/
|
||||
constructor({ emit, createSttSession, finalTimeoutMs, autoCommitSeconds }) {
|
||||
this.emit = emit;
|
||||
this.createSttSession = createSttSession;
|
||||
this.finalTimeoutMs = finalTimeoutMs ?? DEFAULT_FINAL_TIMEOUT_MS;
|
||||
this.autoCommitSeconds = autoCommitSeconds ?? DEFAULT_AUTO_COMMIT_SECONDS;
|
||||
this.streams = new Map();
|
||||
}
|
||||
|
||||
cleanupAll() {
|
||||
for (const dictationId of Array.from(this.streams.keys())) {
|
||||
this.cleanupStream(dictationId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dictationId
|
||||
* @param {string} format e.g. "audio/pcm;rate=16000;bits=16"
|
||||
* @param {object} startOptions provider/config options forwarded to createSttSession
|
||||
*/
|
||||
async handleStart(dictationId, format, startOptions = {}) {
|
||||
this.cleanupStream(dictationId);
|
||||
|
||||
const inputRate = parsePcmRateFromFormat(format, 16000) ?? 16000;
|
||||
if (!Number.isFinite(inputRate) || inputRate <= 0) {
|
||||
this.failStream(dictationId, `Invalid dictation input rate in format: ${format}`, false);
|
||||
return;
|
||||
}
|
||||
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await this.createSttSession(startOptions);
|
||||
} catch (error) {
|
||||
this.failStream(dictationId, error?.message || String(error), true);
|
||||
return;
|
||||
}
|
||||
if (!resolved || resolved.error) {
|
||||
this.failStream(
|
||||
dictationId,
|
||||
resolved?.error || 'Dictation STT not configured',
|
||||
Boolean(resolved?.retryable),
|
||||
resolved?.reasonCode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const stt = resolved.session;
|
||||
|
||||
stt.on('committed', ({ segmentId }) => {
|
||||
const state = this.streams.get(dictationId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.committedSegmentIds.push(segmentId);
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
|
||||
if (state.finishRequested && state.awaitingFinalCommit) {
|
||||
state.awaitingFinalCommit = false;
|
||||
}
|
||||
|
||||
this.maybeFinalizeStream(dictationId);
|
||||
});
|
||||
|
||||
stt.on('transcript', ({ segmentId, transcript, isFinal }) => {
|
||||
const state = this.streams.get(dictationId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.transcriptsBySegmentId.set(segmentId, transcript);
|
||||
if (isFinal) {
|
||||
state.finalTranscriptSegmentIds.add(segmentId);
|
||||
}
|
||||
|
||||
if (state.finishRequested && state.awaitingFinalCommit && isFinal) {
|
||||
state.awaitingFinalCommit = false;
|
||||
}
|
||||
|
||||
const orderedIds = state.committedSegmentIds.includes(segmentId)
|
||||
? state.committedSegmentIds
|
||||
: [...state.committedSegmentIds, segmentId];
|
||||
const partialText = orderedIds
|
||||
.map((id) => state.transcriptsBySegmentId.get(id) ?? '')
|
||||
.join(' ')
|
||||
.trim();
|
||||
this.emit({ type: 'partial', payload: { dictationId, text: partialText } });
|
||||
|
||||
this.maybeSealStreamFinish(dictationId);
|
||||
this.maybeFinalizeStream(dictationId);
|
||||
});
|
||||
|
||||
stt.on('error', (err) => {
|
||||
const message = err?.message || String(err);
|
||||
this.failAndCleanupStream(dictationId, message, true);
|
||||
});
|
||||
|
||||
this.streams.set(dictationId, {
|
||||
dictationId,
|
||||
inputFormat: format,
|
||||
stt,
|
||||
inputRate,
|
||||
outputRate: stt.requiredSampleRate,
|
||||
resampler:
|
||||
inputRate === stt.requiredSampleRate
|
||||
? null
|
||||
: new Pcm16MonoResampler({ inputRate, outputRate: stt.requiredSampleRate }),
|
||||
receivedChunks: new Map(),
|
||||
nextSeqToForward: 0,
|
||||
ackSeq: -1,
|
||||
autoCommitBytes:
|
||||
this.autoCommitSeconds > 0
|
||||
? Math.max(1, Math.round(this.autoCommitSeconds * stt.requiredSampleRate * 2))
|
||||
: 0,
|
||||
bytesSinceCommit: 0,
|
||||
peakSinceCommit: 0,
|
||||
committedSegmentIds: [],
|
||||
transcriptsBySegmentId: new Map(),
|
||||
finalTranscriptSegmentIds: new Set(),
|
||||
awaitingFinalCommit: false,
|
||||
finishRequested: false,
|
||||
finishSealed: false,
|
||||
finalSeq: null,
|
||||
finalTimeout: null,
|
||||
});
|
||||
|
||||
this.emitAck(dictationId, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ dictationId: string, seq: number, audioBase64: string }} params
|
||||
*/
|
||||
handleChunk({ dictationId, seq, audioBase64 }) {
|
||||
const state = this.streams.get(dictationId);
|
||||
if (!state) {
|
||||
this.failStream(dictationId, 'Dictation stream not started', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(seq) || seq < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seq < state.nextSeqToForward) {
|
||||
this.emitAck(dictationId, state.ackSeq);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.receivedChunks.has(seq)) {
|
||||
let chunk;
|
||||
try {
|
||||
chunk = Buffer.from(audioBase64, 'base64');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (chunk.length % 2 !== 0) {
|
||||
chunk = chunk.subarray(0, chunk.length - 1);
|
||||
}
|
||||
state.receivedChunks.set(seq, chunk);
|
||||
}
|
||||
|
||||
while (state.receivedChunks.has(state.nextSeqToForward)) {
|
||||
const nextSeq = state.nextSeqToForward;
|
||||
const pcm16 = state.receivedChunks.get(nextSeq);
|
||||
state.receivedChunks.delete(nextSeq);
|
||||
|
||||
const resampled = state.resampler ? state.resampler.processChunk(pcm16) : pcm16;
|
||||
if (resampled.length > 0) {
|
||||
state.stt.appendPcm16(resampled);
|
||||
state.bytesSinceCommit += resampled.length;
|
||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
||||
try {
|
||||
this.maybeAutoCommitSegment(state);
|
||||
} catch (error) {
|
||||
this.failAndCleanupStream(dictationId, error?.message || String(error), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.nextSeqToForward += 1;
|
||||
state.ackSeq = state.nextSeqToForward - 1;
|
||||
}
|
||||
|
||||
this.emitAck(dictationId, state.ackSeq);
|
||||
this.maybeSealStreamFinish(dictationId);
|
||||
this.maybeFinalizeStream(dictationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dictationId
|
||||
* @param {number} finalSeq highest seq the client sent (or -1 if none)
|
||||
*/
|
||||
handleFinish(dictationId, finalSeq) {
|
||||
const state = this.streams.get(dictationId);
|
||||
if (!state) {
|
||||
this.failStream(dictationId, 'Dictation stream not started', true);
|
||||
return;
|
||||
}
|
||||
|
||||
state.finishRequested = true;
|
||||
state.finalSeq = finalSeq;
|
||||
|
||||
if (
|
||||
finalSeq >= 0 &&
|
||||
state.ackSeq < 0 &&
|
||||
state.nextSeqToForward === 0 &&
|
||||
state.receivedChunks.size === 0
|
||||
) {
|
||||
this.failStream(
|
||||
dictationId,
|
||||
'Dictation finished but no audio chunks were received',
|
||||
true,
|
||||
);
|
||||
this.cleanupStream(dictationId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.maybeSealStreamFinish(dictationId);
|
||||
this.maybeFinalizeStream(dictationId);
|
||||
|
||||
const updatedState = this.streams.get(dictationId);
|
||||
if (!updatedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = this.estimateFinalizationTimeout(updatedState);
|
||||
if (updatedState.finalTimeout) {
|
||||
clearTimeout(updatedState.finalTimeout);
|
||||
}
|
||||
updatedState.finalTimeout = setTimeout(() => {
|
||||
this.failAndCleanupStream(dictationId, 'Timed out waiting for final transcription', true);
|
||||
}, timeoutMs);
|
||||
|
||||
this.emit({ type: 'finish_accepted', payload: { dictationId, timeoutMs } });
|
||||
}
|
||||
|
||||
handleCancel(dictationId) {
|
||||
this.cleanupStream(dictationId);
|
||||
}
|
||||
|
||||
emitAck(dictationId, ackSeq) {
|
||||
this.emit({ type: 'ack', payload: { dictationId, ackSeq } });
|
||||
}
|
||||
|
||||
failStream(dictationId, error, retryable, reasonCode) {
|
||||
this.emit({
|
||||
type: 'error',
|
||||
payload: {
|
||||
dictationId,
|
||||
error,
|
||||
retryable,
|
||||
...(reasonCode ? { reasonCode } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
failAndCleanupStream(dictationId, error, retryable) {
|
||||
this.failStream(dictationId, error, retryable);
|
||||
this.cleanupStream(dictationId);
|
||||
}
|
||||
|
||||
cleanupStream(dictationId) {
|
||||
const state = this.streams.get(dictationId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (state.finalTimeout) {
|
||||
clearTimeout(state.finalTimeout);
|
||||
}
|
||||
try {
|
||||
state.stt.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
this.streams.delete(dictationId);
|
||||
}
|
||||
|
||||
estimateFinalizationTimeout(state) {
|
||||
const bytesPerSecond = Math.max(1, state.outputRate * 2);
|
||||
const pendingCommittedSegments = state.committedSegmentIds.reduce((count, segmentId) => {
|
||||
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
|
||||
}, 0);
|
||||
const committedSet = new Set(state.committedSegmentIds);
|
||||
const pendingUncommittedTranscriptSegments = Array.from(
|
||||
state.transcriptsBySegmentId.keys(),
|
||||
).reduce((count, segmentId) => {
|
||||
if (committedSet.has(segmentId)) {
|
||||
return count;
|
||||
}
|
||||
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
|
||||
}, 0);
|
||||
const pendingSegments =
|
||||
pendingCommittedSegments +
|
||||
pendingUncommittedTranscriptSegments +
|
||||
(state.awaitingFinalCommit ? 1 : 0);
|
||||
const pendingAudioSeconds = Math.ceil(Math.max(0, state.bytesSinceCommit) / bytesPerSecond);
|
||||
const missingSeqCount =
|
||||
state.finalSeq === null ? 0 : Math.max(0, state.finalSeq - state.ackSeq);
|
||||
|
||||
const extraMs =
|
||||
pendingSegments * FINAL_TIMEOUT_PER_PENDING_SEGMENT_MS +
|
||||
pendingAudioSeconds * FINAL_TIMEOUT_PER_PENDING_AUDIO_SECOND_MS +
|
||||
missingSeqCount * FINAL_TIMEOUT_PER_MISSING_SEQ_MS;
|
||||
|
||||
return Math.max(
|
||||
this.finalTimeoutMs,
|
||||
Math.min(FINAL_TIMEOUT_MAX_MS, this.finalTimeoutMs + extraMs),
|
||||
);
|
||||
}
|
||||
|
||||
maybeAutoCommitSegment(state) {
|
||||
if (state.finishRequested) {
|
||||
return;
|
||||
}
|
||||
if (state.autoCommitBytes <= 0 || state.bytesSinceCommit < state.autoCommitBytes) {
|
||||
return;
|
||||
}
|
||||
if (state.peakSinceCommit < SILENCE_PEAK_THRESHOLD) {
|
||||
state.stt.clear();
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
state.stt.commit();
|
||||
}
|
||||
|
||||
maybeSealStreamFinish(dictationId) {
|
||||
const state = this.streams.get(dictationId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (!state.finishRequested || state.finalSeq === null) {
|
||||
return;
|
||||
}
|
||||
if (state.ackSeq < state.finalSeq) {
|
||||
return;
|
||||
}
|
||||
if (state.finishSealed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.bytesSinceCommit > 0) {
|
||||
if (state.peakSinceCommit < SILENCE_PEAK_THRESHOLD) {
|
||||
state.stt.clear();
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
state.awaitingFinalCommit = false;
|
||||
this.dropUncommittedNonFinalTranscripts(state);
|
||||
} else {
|
||||
state.awaitingFinalCommit = true;
|
||||
try {
|
||||
state.stt.commit();
|
||||
} catch (error) {
|
||||
this.failAndCleanupStream(dictationId, error?.message || String(error), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.awaitingFinalCommit = false;
|
||||
}
|
||||
|
||||
state.finishSealed = true;
|
||||
}
|
||||
|
||||
dropUncommittedNonFinalTranscripts(state) {
|
||||
const committedSet = new Set(state.committedSegmentIds);
|
||||
for (const segmentId of Array.from(state.transcriptsBySegmentId.keys())) {
|
||||
if (committedSet.has(segmentId)) {
|
||||
continue;
|
||||
}
|
||||
if (state.finalTranscriptSegmentIds.has(segmentId)) {
|
||||
continue;
|
||||
}
|
||||
state.transcriptsBySegmentId.delete(segmentId);
|
||||
}
|
||||
}
|
||||
|
||||
maybeFinalizeStream(dictationId) {
|
||||
const state = this.streams.get(dictationId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.finishRequested || state.finalSeq === null) {
|
||||
return;
|
||||
}
|
||||
if (state.ackSeq < state.finalSeq) {
|
||||
return;
|
||||
}
|
||||
if (state.awaitingFinalCommit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const committedSet = new Set(state.committedSegmentIds);
|
||||
const orderedSegmentIds = [...state.committedSegmentIds];
|
||||
for (const segmentId of state.transcriptsBySegmentId.keys()) {
|
||||
if (!committedSet.has(segmentId)) {
|
||||
orderedSegmentIds.push(segmentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (orderedSegmentIds.length === 0) {
|
||||
this.emit({ type: 'final', payload: { dictationId, text: '' } });
|
||||
this.cleanupStream(dictationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const allTranscriptsReady = orderedSegmentIds.every((segmentId) =>
|
||||
state.finalTranscriptSegmentIds.has(segmentId),
|
||||
);
|
||||
if (!allTranscriptsReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedText = orderedSegmentIds
|
||||
.map((segmentId) => state.transcriptsBySegmentId.get(segmentId) ?? '')
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
this.emit({ type: 'final', payload: { dictationId, text: orderedText } });
|
||||
this.cleanupStream(dictationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { DictationStreamManager } from './stream-manager.js';
|
||||
|
||||
const FORMAT = 'audio/pcm;rate=16000;bits=16';
|
||||
|
||||
class FakeSttSession extends EventEmitter {
|
||||
constructor({ transcriptBySegment = () => 'hello world' } = {}) {
|
||||
super();
|
||||
this.requiredSampleRate = 16000;
|
||||
this.appended = [];
|
||||
this.commits = 0;
|
||||
this.clears = 0;
|
||||
this.closed = false;
|
||||
this.segmentCounter = 0;
|
||||
this.transcriptBySegment = transcriptBySegment;
|
||||
}
|
||||
|
||||
async connect() {}
|
||||
|
||||
appendPcm16(buf) {
|
||||
this.appended.push(buf);
|
||||
}
|
||||
|
||||
commit() {
|
||||
this.commits += 1;
|
||||
const segmentId = `seg-${this.segmentCounter}`;
|
||||
this.segmentCounter += 1;
|
||||
this.emit('committed', { segmentId, previousSegmentId: null });
|
||||
setTimeout(() => {
|
||||
this.emit('transcript', {
|
||||
segmentId,
|
||||
transcript: this.transcriptBySegment(segmentId),
|
||||
isFinal: true,
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.clears += 1;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function loudChunkBase64(samples = 1600, amplitude = 8000) {
|
||||
const arr = new Int16Array(samples);
|
||||
for (let i = 0; i < samples; i += 1) {
|
||||
arr[i] = i % 2 === 0 ? amplitude : -amplitude;
|
||||
}
|
||||
return Buffer.from(arr.buffer).toString('base64');
|
||||
}
|
||||
|
||||
function silentChunkBase64(samples = 1600) {
|
||||
return Buffer.from(new Int16Array(samples).buffer).toString('base64');
|
||||
}
|
||||
|
||||
function createManager(session) {
|
||||
const messages = [];
|
||||
const manager = new DictationStreamManager({
|
||||
emit: (msg) => messages.push(msg),
|
||||
createSttSession: async () => ({ session }),
|
||||
});
|
||||
return { manager, messages };
|
||||
}
|
||||
|
||||
function waitFor(predicate, timeoutMs = 1000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const tick = () => {
|
||||
if (predicate()) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
reject(new Error('waitFor timed out'));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 5);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
describe('DictationStreamManager', () => {
|
||||
it('transcribes ordered chunks and emits final text', async () => {
|
||||
const session = new FakeSttSession();
|
||||
const { manager, messages } = createManager(session);
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64() });
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: loudChunkBase64() });
|
||||
manager.handleFinish('d1', 1);
|
||||
|
||||
await waitFor(() => messages.some((m) => m.type === 'final'));
|
||||
|
||||
const final = messages.find((m) => m.type === 'final');
|
||||
expect(final.payload.text).toBe('hello world');
|
||||
expect(session.commits).toBe(1);
|
||||
expect(session.closed).toBe(true);
|
||||
|
||||
const acks = messages.filter((m) => m.type === 'ack');
|
||||
expect(acks[acks.length - 1].payload.ackSeq).toBe(1);
|
||||
});
|
||||
|
||||
it('reorders out-of-order chunks before appending', async () => {
|
||||
const session = new FakeSttSession();
|
||||
const { manager, messages } = createManager(session);
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: loudChunkBase64() });
|
||||
expect(session.appended.length).toBe(0);
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64() });
|
||||
expect(session.appended.length).toBe(2);
|
||||
manager.handleFinish('d1', 1);
|
||||
|
||||
await waitFor(() => messages.some((m) => m.type === 'final'));
|
||||
});
|
||||
|
||||
it('clears silence-only tails instead of committing', async () => {
|
||||
const session = new FakeSttSession();
|
||||
const { manager, messages } = createManager(session);
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: silentChunkBase64() });
|
||||
manager.handleFinish('d1', 0);
|
||||
|
||||
await waitFor(() => messages.some((m) => m.type === 'final'));
|
||||
|
||||
const final = messages.find((m) => m.type === 'final');
|
||||
expect(final.payload.text).toBe('');
|
||||
expect(session.commits).toBe(0);
|
||||
expect(session.clears).toBe(1);
|
||||
});
|
||||
|
||||
it('fails fast when finish arrives with no chunks', async () => {
|
||||
const session = new FakeSttSession();
|
||||
const { manager, messages } = createManager(session);
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleFinish('d1', 3);
|
||||
|
||||
const error = messages.find((m) => m.type === 'error');
|
||||
expect(error).toBeDefined();
|
||||
expect(error.payload.retryable).toBe(true);
|
||||
expect(session.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('reports provider readiness errors from createSttSession', async () => {
|
||||
const messages = [];
|
||||
const manager = new DictationStreamManager({
|
||||
emit: (msg) => messages.push(msg),
|
||||
createSttSession: async () => ({
|
||||
error: 'Dictation model is downloading',
|
||||
retryable: true,
|
||||
reasonCode: 'model_download_in_progress',
|
||||
}),
|
||||
});
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
const error = messages.find((m) => m.type === 'error');
|
||||
expect(error.payload.reasonCode).toBe('model_download_in_progress');
|
||||
expect(error.payload.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('emits partials as segment transcripts arrive', async () => {
|
||||
let segment = 0;
|
||||
const session = new FakeSttSession({
|
||||
transcriptBySegment: () => {
|
||||
segment += 1;
|
||||
return segment === 1 ? 'first part' : 'second part';
|
||||
},
|
||||
});
|
||||
const { manager, messages } = createManager(session);
|
||||
// Force auto-commit after ~0.05s of audio so two segments form.
|
||||
manager.autoCommitSeconds = 0.05;
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(1600) });
|
||||
await waitFor(() => session.commits >= 1);
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: loudChunkBase64(1600) });
|
||||
manager.handleFinish('d1', 1);
|
||||
|
||||
await waitFor(() => messages.some((m) => m.type === 'final'));
|
||||
|
||||
const final = messages.find((m) => m.type === 'final');
|
||||
expect(final.payload.text).toBe('first part second part');
|
||||
const partials = messages.filter((m) => m.type === 'partial');
|
||||
expect(partials.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,12 @@ The following functions are exported and used by the web server:
|
||||
|
||||
### Status and Diff Operations
|
||||
- `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
|
||||
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs.
|
||||
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. Uses three-dot `base...head` semantics, so work merged into `head` from `base` is excluded and only the branch's own changes are returned. Prefers `origin/<base>` when that remote-tracking ref exists, so a stale local base branch does not resurface already-merged commits. Exposed as `GET /api/git/range-diff` (`path` optional; omit it for the whole range).
|
||||
- `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
|
||||
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs).
|
||||
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text).
|
||||
- `listUntrackedPaths(directory)`: List individual untracked file paths honoring ignore rules. Much cheaper than `getStatus` when that is all a caller needs. Deliberately not `--directory`: collapsed directory entries end in a slash and are rejected by the per-file diff helpers, so a caller would silently lose every file inside a new directory.
|
||||
- `getUntrackedDiffs(directory, filePaths, { concurrency, contextLines })`: Diffs for untracked files against an empty tree. Resolves the repository context once instead of per file (`getDiff` re-resolves every call, costing an extra `rev-parse` each time) and bounds how many diff processes run at once. Returns one entry per input path in order; unreadable paths yield `''` rather than failing the batch.
|
||||
- `collectDiffs(directory, files)`: Collect diff output for multiple files.
|
||||
- `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
|
||||
- `stageFile(directory, filePath)`: Add one file path to the index.
|
||||
@@ -109,6 +111,15 @@ The following functions are internal helpers used by exported functions:
|
||||
- `mergeInProgress`: Object with `{ head, message }` if merge in progress.
|
||||
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
|
||||
|
||||
### Branches Response
|
||||
- `all`: Local branches plus remote-tracking branches that still exist on their remote. A remote that fails to answer keeps its branches in the list: "we could not ask" must not be reported as "these branches are gone", because callers use this list to decide whether a base branch exists at all.
|
||||
- `current`: Current branch name.
|
||||
- `branches`: Per-branch detail keyed by branch name, as reported by `git branch`.
|
||||
- `defaultBranches`: Each remote's default branch, keyed by remote name. Read from the local `remotes/<name>/HEAD` symbolic ref; for a remote that has none — clone writes it, a hand-added remote may not — the remote itself is asked once with `ls-remote --symref`. A remote that answers neither is absent rather than guessed, and consumers fall back to conventional branch names. Omitted entirely by runtimes that do not provide this Git metadata.
|
||||
|
||||
### Runtime availability of range diffs
|
||||
- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code.
|
||||
|
||||
### Staged and unstaged change handling
|
||||
- `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.
|
||||
- A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs.
|
||||
@@ -120,8 +131,10 @@ The following functions are internal helpers used by exported functions:
|
||||
- `branch`: Local branch name.
|
||||
- `path`: Absolute path to worktree directory.
|
||||
- `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues.
|
||||
- `bootstrapStatus`: Background setup status, with `pending`, `ready`, or `failed`.
|
||||
- `bootstrapStatus`: Background setup state. The legacy `status` remains `pending`, `ready`, or `failed`, while `phase` reports `directory-created`, `git-ready`, or `setup-ready`. Fast create starts at `pending`/`directory-created`; population and upstream Git completion advances to `pending`/`git-ready` before setup/start scripts; completed setup is `ready`/`setup-ready`. A missing in-memory state falls back to `ready`/`setup-ready`; clients continue to accept legacy status responses that omit `phase`.
|
||||
- Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup.
|
||||
- Worktree removal waits for any active create/bootstrap task for that directory before deleting it, preventing a background Git or setup task from restoring removed state or racing filesystem cleanup.
|
||||
- Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures.
|
||||
|
||||
### Log Response
|
||||
- `all`: Array of commit objects with hash, date, message, author info, stats.
|
||||
@@ -133,7 +146,7 @@ The following functions are internal helpers used by exported functions:
|
||||
### Adding a New Git Operation
|
||||
1. Add the function to `packages/web/server/lib/git/service.js`.
|
||||
2. Export the function if it's part of the public API.
|
||||
3. Use `createGit(directory)` to get a simple-git instance with the correct environment.
|
||||
3. Use `createGit(directory)` to get a simple-git instance with the correct environment. `directory` is required (`baseDir`); never omit it so commands cannot inherit `process.cwd()`.
|
||||
4. Use `runGitCommand(cwd, args)` for direct git command execution with better error handling.
|
||||
5. Use `runGitCommandOrThrow(cwd, args, fallbackMessage)` for commands that must succeed.
|
||||
6. Return consistent error messages; use `parseGitErrorText(error)` to extract meaningful git errors.
|
||||
@@ -144,6 +157,11 @@ The following functions are internal helpers used by exported functions:
|
||||
- On Windows, paths are converted to MSYS format (`C:/path` → `/c/path`).
|
||||
- SSH_AUTH_SOCK is automatically resolved via `resolveSshAuthSock` (checks GPG agent, gpgconf).
|
||||
|
||||
### Working directory (simple-git)
|
||||
- Repository operations always pass an explicit `baseDir` (the opened project/directory path) into simple-git. Omitting `baseDir` would default to `process.cwd()`, which breaks when the server was launched from a neutral directory (e.g. `$HOME`) while the opened project lives elsewhere.
|
||||
- Global identity reads use the user home directory as `baseDir` (they do not need a repository).
|
||||
- A `GitError` / non-repository result from status or check must not abort project/session enumeration: routes return a soft non-repo payload and log a warning.
|
||||
|
||||
### Worktree Naming
|
||||
- Worktree names are slugified via `slugWorktreeName`.
|
||||
- Random names use adjectives/nouns from `OPENCODE_ADJECTIVES` and `OPENCODE_NOUNS` lists.
|
||||
|
||||
@@ -7,6 +7,36 @@ export function registerGitRoutes(app) {
|
||||
return gitLibraries;
|
||||
};
|
||||
|
||||
const resolveDirectoryQuery = (value) => {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
const extractGitErrorText = (error) => {
|
||||
const message = typeof error?.message === 'string' ? error.message : '';
|
||||
const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
|
||||
const stdout = typeof error?.stdout === 'string' ? error.stdout : '';
|
||||
const fallback = !message && error != null ? String(error) : '';
|
||||
return [message, stderr, stdout, fallback]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const isNonRepoGitError = (error) => /not a git repository/i.test(extractGitErrorText(error));
|
||||
|
||||
const nonRepoStatusPayload = () => ({
|
||||
isGitRepository: false,
|
||||
files: [],
|
||||
branch: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
|
||||
app.get('/api/git/identities', async (req, res) => {
|
||||
const { getProfiles } = await getGitLibraries();
|
||||
try {
|
||||
@@ -79,7 +109,7 @@ export function registerGitRoutes(app) {
|
||||
app.get('/api/git/check', async (req, res) => {
|
||||
const { isGitRepository } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
@@ -87,6 +117,10 @@ export function registerGitRoutes(app) {
|
||||
const isRepo = await isGitRepository(directory);
|
||||
res.json({ isGitRepository: isRepo });
|
||||
} catch (error) {
|
||||
if (isNonRepoGitError(error)) {
|
||||
console.warn('Git check treated non-repository path as not a git repo:', extractGitErrorText(error));
|
||||
return res.json({ isGitRepository: false });
|
||||
}
|
||||
console.error('Failed to check git repository:', error);
|
||||
res.status(500).json({ error: 'Failed to check git repository' });
|
||||
}
|
||||
@@ -188,34 +222,26 @@ export function registerGitRoutes(app) {
|
||||
app.get('/api/git/status', async (req, res) => {
|
||||
const { getStatus, isGitRepository } = await getGitLibraries();
|
||||
|
||||
const extractGitErrorText = (error) => {
|
||||
const message = typeof error?.message === 'string' ? error.message : '';
|
||||
const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
|
||||
const stdout = typeof error?.stdout === 'string' ? error.stdout : '';
|
||||
return [message, stderr, stdout]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const isRepo = await isGitRepository(directory);
|
||||
if (!isRepo) {
|
||||
return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 });
|
||||
return res.json(nonRepoStatusPayload());
|
||||
}
|
||||
|
||||
const mode = req.query.mode === 'light' ? 'light' : undefined;
|
||||
const status = await getStatus(directory, { mode });
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
const errorText = extractGitErrorText(error);
|
||||
if (/not a git repository/i.test(errorText)) {
|
||||
return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 });
|
||||
// Non-repo / GitError must not abort callers that enumerate projects or
|
||||
// sessions (e.g. sidebar discovery). Log a warning and continue.
|
||||
if (isNonRepoGitError(error)) {
|
||||
console.warn('Git status skipped for non-repository path:', extractGitErrorText(error));
|
||||
return res.json(nonRepoStatusPayload());
|
||||
}
|
||||
console.error('Failed to get git status:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get git status' });
|
||||
@@ -371,6 +397,37 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/range-diff', async (req, res) => {
|
||||
const { getRangeDiff } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory || typeof directory !== 'string') {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const base = req.query.base;
|
||||
const head = req.query.head;
|
||||
if (!base || typeof base !== 'string' || !head || typeof head !== 'string') {
|
||||
return res.status(400).json({ error: 'base and head parameters are required' });
|
||||
}
|
||||
|
||||
const pathParam = typeof req.query.path === 'string' && req.query.path ? req.query.path : undefined;
|
||||
const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined;
|
||||
|
||||
const diff = await getRangeDiff(directory, {
|
||||
base,
|
||||
head,
|
||||
path: pathParam,
|
||||
contextLines: Number.isFinite(context) ? context : 3,
|
||||
});
|
||||
|
||||
res.json({ diff });
|
||||
} catch (error) {
|
||||
console.error('Failed to get git range diff:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get git range diff' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/revert', async (req, res) => {
|
||||
const { revertFile } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -3,11 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const gitLibraries = {
|
||||
stageFiles: vi.fn(),
|
||||
unstageFiles: vi.fn(),
|
||||
isGitRepository: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('./index.js', () => ({
|
||||
stageFiles: gitLibraries.stageFiles,
|
||||
unstageFiles: gitLibraries.unstageFiles,
|
||||
isGitRepository: gitLibraries.isGitRepository,
|
||||
getStatus: gitLibraries.getStatus,
|
||||
}));
|
||||
|
||||
const { registerGitRoutes } = await import('./routes.js');
|
||||
@@ -47,7 +51,6 @@ const createMockResponse = () => {
|
||||
},
|
||||
json(payload) {
|
||||
body = payload;
|
||||
return this;
|
||||
},
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
@@ -62,6 +65,8 @@ describe('git routes index mutations', () => {
|
||||
beforeEach(() => {
|
||||
gitLibraries.stageFiles.mockReset();
|
||||
gitLibraries.unstageFiles.mockReset();
|
||||
gitLibraries.isGitRepository.mockReset();
|
||||
gitLibraries.getStatus.mockReset();
|
||||
});
|
||||
|
||||
it('accepts legacy stage path payloads', async () => {
|
||||
@@ -135,3 +140,71 @@ describe('git routes index mutations', () => {
|
||||
expect(gitLibraries.stageFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('git routes status discovery', () => {
|
||||
beforeEach(() => {
|
||||
gitLibraries.isGitRepository.mockReset();
|
||||
gitLibraries.getStatus.mockReset();
|
||||
});
|
||||
|
||||
it('returns a soft non-repo payload for non-git folders', async () => {
|
||||
gitLibraries.isGitRepository.mockResolvedValue(false);
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerGitRoutes(app);
|
||||
const response = createMockResponse();
|
||||
|
||||
await getRoute('GET', '/api/git/status')(
|
||||
{ query: { directory: '/tmp/not-a-repo' } },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
isGitRepository: false,
|
||||
files: [],
|
||||
branch: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
expect(gitLibraries.getStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not abort when getStatus throws a non-repo GitError', async () => {
|
||||
gitLibraries.isGitRepository.mockResolvedValue(true);
|
||||
gitLibraries.getStatus.mockRejectedValue(
|
||||
Object.assign(new Error('fatal: not a git repository (or any of the parent directories): .git'), {
|
||||
task: { commands: ['status'] },
|
||||
}),
|
||||
);
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerGitRoutes(app);
|
||||
const response = createMockResponse();
|
||||
|
||||
await getRoute('GET', '/api/git/status')(
|
||||
{ query: { directory: '/opened/project' } },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({ isGitRepository: false });
|
||||
expect(gitLibraries.getStatus).toHaveBeenCalledWith('/opened/project', { mode: undefined });
|
||||
});
|
||||
|
||||
it('uses the opened project path from query arrays without falling back to cwd', async () => {
|
||||
gitLibraries.isGitRepository.mockResolvedValue(true);
|
||||
gitLibraries.getStatus.mockResolvedValue({ current: 'main', files: [], isClean: true, ahead: 0, behind: 0 });
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerGitRoutes(app);
|
||||
const response = createMockResponse();
|
||||
|
||||
await getRoute('GET', '/api/git/status')(
|
||||
{ query: { directory: ['/opened/git-project', '/ignored'] } },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(gitLibraries.isGitRepository).toHaveBeenCalledWith('/opened/git-project');
|
||||
expect(gitLibraries.getStatus).toHaveBeenCalledWith('/opened/git-project', { mode: undefined });
|
||||
expect(response.body).toMatchObject({ current: 'main' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ const execFileAsync = promisify(execFile);
|
||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
||||
let resolvedGitBinary = null;
|
||||
const worktreeBootstrapState = new Map();
|
||||
const activeWorktreeBootstrapTasks = new Map();
|
||||
const remoteExistenceCache = new Map();
|
||||
const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i;
|
||||
const SIMPLE_GIT_UNSAFE_BINARY_WARNING = 'Invalid value supplied for custom binary, restricted characters must be removed';
|
||||
@@ -21,6 +22,11 @@ const gitIndexMutationQueues = new Map();
|
||||
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
|
||||
const WORKTREE_BOOTSTRAP_READY = 'ready';
|
||||
const WORKTREE_BOOTSTRAP_FAILED = 'failed';
|
||||
const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created';
|
||||
const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready';
|
||||
const WORKTREE_BOOTSTRAP_PHASE_SETUP_READY = 'setup-ready';
|
||||
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
|
||||
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
|
||||
|
||||
const toBootstrapStateKey = (directory) => {
|
||||
const normalized = normalizeDirectoryPath(directory);
|
||||
@@ -30,16 +36,21 @@ const toBootstrapStateKey = (directory) => {
|
||||
return path.resolve(normalized);
|
||||
};
|
||||
|
||||
const setWorktreeBootstrapState = (directory, status, error = null) => {
|
||||
const createWorktreeBootstrapState = (status, phase, error = null) => ({
|
||||
status,
|
||||
phase,
|
||||
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
const setWorktreeBootstrapState = (directory, status, phase, error = null) => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
worktreeBootstrapState.set(key, {
|
||||
status,
|
||||
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
const state = createWorktreeBootstrapState(status, phase, error);
|
||||
worktreeBootstrapState.set(key, state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const clearWorktreeBootstrapState = (directory) => {
|
||||
@@ -50,6 +61,37 @@ const clearWorktreeBootstrapState = (directory) => {
|
||||
worktreeBootstrapState.delete(key);
|
||||
};
|
||||
|
||||
const trackWorktreeBootstrapTask = (directory, task) => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return task;
|
||||
}
|
||||
|
||||
activeWorktreeBootstrapTasks.set(key, task);
|
||||
const clearTask = () => {
|
||||
if (activeWorktreeBootstrapTasks.get(key) === task) {
|
||||
activeWorktreeBootstrapTasks.delete(key);
|
||||
}
|
||||
};
|
||||
void task.then(clearTask, clearTask);
|
||||
return task;
|
||||
};
|
||||
|
||||
const waitForActiveWorktreeBootstrap = async (directory) => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const task = activeWorktreeBootstrapTasks.get(key);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
await task.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const isExecutableFile = (candidate) => {
|
||||
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
|
||||
return false;
|
||||
@@ -309,17 +351,28 @@ const buildGitEnv = async () => {
|
||||
return env;
|
||||
};
|
||||
|
||||
const createGit = async (directory) => {
|
||||
const createGit = async (directory, { allowUnsafeSshCommand = false } = {}) => {
|
||||
const env = await buildGitEnv();
|
||||
const spawnOptions = { windowsHide: true };
|
||||
const binary = getGitBinary();
|
||||
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
|
||||
const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined;
|
||||
if (!directory) {
|
||||
return createSimpleGit({ env, spawnOptions, binary, unsafe });
|
||||
const unsafe = hasCustomBinary || allowUnsafeSshCommand
|
||||
? {
|
||||
...(hasCustomBinary && { allowUnsafeCustomBinary: true }),
|
||||
...(allowUnsafeSshCommand && { allowUnsafeSshCommand: true }),
|
||||
}
|
||||
: undefined;
|
||||
// Always pin simple-git to an explicit working directory. Omitting baseDir
|
||||
// makes simple-git use process.cwd(), which breaks when the OpenChamber
|
||||
// server was launched from a neutral directory (e.g. $HOME) and the opened
|
||||
// project lives elsewhere — session/project discovery then sees spurious
|
||||
// "not a git repository" errors and can abort enumeration.
|
||||
const baseDir = normalizeDirectoryPath(directory);
|
||||
if (typeof baseDir !== 'string' || !baseDir.trim()) {
|
||||
throw new Error('Git directory is required');
|
||||
}
|
||||
return createSimpleGit({
|
||||
baseDir: normalizeDirectoryPath(directory),
|
||||
baseDir,
|
||||
env,
|
||||
spawnOptions,
|
||||
binary,
|
||||
@@ -327,6 +380,10 @@ const createGit = async (directory) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Global config reads do not need a repository; use the home directory as a
|
||||
// stable baseDir so we never accidentally inherit process.cwd().
|
||||
const createGitForGlobalConfig = async () => createGit(os.homedir());
|
||||
|
||||
const normalizeDirectoryPath = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
@@ -427,12 +484,25 @@ const resolveGitRepositoryRoot = async (directoryPath, git) => {
|
||||
|
||||
const createRepositoryGitContext = async (directory) => {
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
if (typeof directoryPath !== 'string' || !directoryPath.trim()) {
|
||||
throw new Error('Git directory is required');
|
||||
}
|
||||
const directoryGit = await createGit(directoryPath);
|
||||
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
|
||||
const git = path.resolve(directoryPath) === repoRoot ? directoryGit : await createGit(repoRoot);
|
||||
return { directoryPath, directoryGit, repoRoot, git };
|
||||
};
|
||||
|
||||
/**
|
||||
* Absolute repository root for a directory anywhere inside it. Callers that key
|
||||
* persisted data by repository need this so two directories in the same
|
||||
* repository do not address different records.
|
||||
*/
|
||||
export async function getRepositoryRoot(directory) {
|
||||
const { repoRoot } = await createRepositoryGitContext(directory);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
const resolveGitInternalPath = async (repoRoot, git, gitPath) => {
|
||||
const resolved = await git.raw(['rev-parse', '--git-path', gitPath]);
|
||||
return path.resolve(repoRoot, resolved.trim());
|
||||
@@ -451,7 +521,9 @@ const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverr
|
||||
}
|
||||
|
||||
const repoPath = toGitPath(path.relative(repoRoot, absolutePath));
|
||||
const existsInWorktree = await fsp.stat(absolutePath).then((stat) => stat.isFile()).catch(() => false);
|
||||
const worktreeEntry = await fsp.lstat(absolutePath).catch(() => null);
|
||||
const isSymbolicLink = worktreeEntry?.isSymbolicLink() ?? false;
|
||||
const existsInWorktree = worktreeEntry?.isFile() || isSymbolicLink;
|
||||
const existsInIndex = await git.raw(['cat-file', '-e', `:${repoPath}`]).then(() => true).catch(() => false);
|
||||
const existsInHead = await git.raw(['cat-file', '-e', `HEAD:${repoPath}`]).then(() => true).catch(() => false);
|
||||
|
||||
@@ -460,6 +532,7 @@ const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverr
|
||||
absolutePath,
|
||||
repoPath,
|
||||
repoRoot,
|
||||
isSymbolicLink,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -722,7 +795,11 @@ const parseGitErrorText = (error) => {
|
||||
const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
|
||||
const stdout = typeof error?.stdout === 'string' ? error.stdout : '';
|
||||
const message = typeof error?.message === 'string' ? error.message : '';
|
||||
return [stderr, stdout, message]
|
||||
// Some runtimes (notably Bun + simple-git GitError) surface the fatal text
|
||||
// primarily via message/toString; keep String(error) as a last resort so
|
||||
// "not a git repository" matching never misses and aborts callers.
|
||||
const fallback = !message && error != null ? String(error) : '';
|
||||
return [stderr, stdout, message, fallback]
|
||||
.map((chunk) => String(chunk || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
@@ -885,6 +962,72 @@ const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const isIndexLockError = (result) => {
|
||||
const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n');
|
||||
return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message);
|
||||
};
|
||||
|
||||
const getWorktreeIndexLockPath = async (directory) => {
|
||||
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']);
|
||||
if (!result.success) {
|
||||
return null;
|
||||
}
|
||||
const value = String(result.stdout || '').trim();
|
||||
return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null;
|
||||
};
|
||||
|
||||
const getFileIdentity = async (filePath) => {
|
||||
try {
|
||||
const stat = await fsp.stat(filePath);
|
||||
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const populateWorktreeWithLockRecovery = async (directory) => {
|
||||
let result = await runGitCommand(directory, ['reset', '--hard']);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
if (!isIndexLockError(result)) {
|
||||
throw new Error(result.message || 'Failed to populate worktree');
|
||||
}
|
||||
|
||||
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
|
||||
result = await runGitCommand(directory, ['reset', '--hard']);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
if (!isIndexLockError(result)) {
|
||||
throw new Error(result.message || 'Failed to populate worktree');
|
||||
}
|
||||
|
||||
const lockPath = await getWorktreeIndexLockPath(directory);
|
||||
const identity = lockPath ? await getFileIdentity(lockPath) : null;
|
||||
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
|
||||
|
||||
result = await runGitCommand(directory, ['reset', '--hard']);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
|
||||
throw new Error(result.message || 'Failed to populate worktree');
|
||||
}
|
||||
|
||||
await fsp.unlink(lockPath).catch((error) => {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
|
||||
};
|
||||
|
||||
const derivePrimaryWorktreeRootFromGitDir = (gitDir) => {
|
||||
const normalized = normalizePath(gitDir);
|
||||
if (!normalized) return null;
|
||||
@@ -1501,94 +1644,13 @@ const loadProjectStartCommand = async (projectID) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectStoragePath = (projectID) => {
|
||||
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
|
||||
};
|
||||
|
||||
const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => {
|
||||
try {
|
||||
const Database = require('better-sqlite3');
|
||||
const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db');
|
||||
if (!fs.existsSync(dbPath)) return;
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const row = db.prepare('SELECT sandboxes FROM project WHERE id = ?').get(projectID);
|
||||
if (!row) return;
|
||||
const json = JSON.stringify(sandboxes);
|
||||
db.prepare('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => {
|
||||
const storagePath = getProjectStoragePath(projectID);
|
||||
await fsp.mkdir(path.dirname(storagePath), { recursive: true });
|
||||
|
||||
const now = Date.now();
|
||||
const base = {
|
||||
id: projectID,
|
||||
worktree: primaryWorktree,
|
||||
vcs: 'git',
|
||||
sandboxes: [],
|
||||
time: {
|
||||
created: now,
|
||||
updated: now,
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = await fsp.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
|
||||
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
|
||||
current.id = String(current.id || projectID);
|
||||
current.worktree = String(current.worktree || primaryWorktree);
|
||||
current.vcs = current.vcs || 'git';
|
||||
current.sandboxes = Array.isArray(current.sandboxes)
|
||||
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const createdAt = Number(current?.time?.created);
|
||||
current.time = {
|
||||
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
|
||||
updated: now,
|
||||
};
|
||||
|
||||
updater(current);
|
||||
|
||||
current.sandboxes = [...new Set(
|
||||
(Array.isArray(current.sandboxes) ? current.sandboxes : [])
|
||||
.map((entry) => String(entry || '').trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
|
||||
await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
|
||||
|
||||
// Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK
|
||||
syncSandboxesToOpenCodeDb(projectID, current.sandboxes);
|
||||
};
|
||||
|
||||
const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
if (!project.sandboxes.includes(sandbox)) {
|
||||
project.sandboxes.push(sandbox);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
|
||||
});
|
||||
};
|
||||
// OpenCode owns its own project/sandbox registry. It records a worktree as a
|
||||
// sandbox itself when an instance boots for that directory, and filters entries
|
||||
// whose directory no longer exists when reading them back. OpenChamber used to
|
||||
// write that state directly into OpenCode's storage JSON and SQLite database,
|
||||
// behind the back of the running process: the row changed but the server was
|
||||
// never told, so a worktree created while OpenCode was running stayed unknown
|
||||
// to it until a restart. Registration is not ours to perform.
|
||||
|
||||
const isAttachedGitWorktreeDirectory = async (directory) => {
|
||||
try {
|
||||
@@ -1605,14 +1667,6 @@ const cleanupFailedFastWorktreeCreate = async (context, candidate) => {
|
||||
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
|
||||
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
|
||||
|
||||
if (!isAttached) {
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInsideWorktreeRoot || isAttached) {
|
||||
return;
|
||||
}
|
||||
@@ -1662,9 +1716,9 @@ const queueWorktreeBootstrap = (args) => {
|
||||
ensureRemoteUrl,
|
||||
startCommand,
|
||||
} = args;
|
||||
setTimeout(() => {
|
||||
const run = async () => {
|
||||
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
|
||||
const task = new Promise((resolve) => setTimeout(resolve, 0))
|
||||
.then(async () => {
|
||||
await populateWorktreeWithLockRecovery(directory);
|
||||
if (setUpstream) {
|
||||
await applyUpstreamConfiguration({
|
||||
primaryWorktree,
|
||||
@@ -1679,21 +1733,31 @@ const queueWorktreeBootstrap = (args) => {
|
||||
console.warn('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
setWorktreeBootstrapState(
|
||||
directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
WORKTREE_BOOTSTRAP_PHASE_GIT_READY
|
||||
);
|
||||
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
|
||||
console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
|
||||
};
|
||||
|
||||
void run().catch((error) => {
|
||||
setWorktreeBootstrapState(
|
||||
directory,
|
||||
WORKTREE_BOOTSTRAP_READY,
|
||||
WORKTREE_BOOTSTRAP_PHASE_SETUP_READY
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setWorktreeBootstrapState(
|
||||
directory,
|
||||
WORKTREE_BOOTSTRAP_FAILED,
|
||||
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
console.warn('Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}, 0);
|
||||
|
||||
trackWorktreeBootstrapTask(directory, task);
|
||||
};
|
||||
|
||||
const ensureRemoteWithUrl = async (primaryWorktree, remoteName, remoteUrl) => {
|
||||
@@ -1821,7 +1885,7 @@ export async function isGitRepository(directory) {
|
||||
}
|
||||
|
||||
export async function getGlobalIdentity() {
|
||||
const git = await createGit();
|
||||
const git = await createGitForGlobalConfig();
|
||||
|
||||
try {
|
||||
const userName = await git.getConfig('user.name', 'global').catch(() => null);
|
||||
@@ -1899,7 +1963,7 @@ export async function hasLocalIdentity(directory) {
|
||||
}
|
||||
|
||||
export async function setLocalIdentity(directory, profile) {
|
||||
const git = await createGit(directory);
|
||||
const git = await createGit(directory, { allowUnsafeSshCommand: true });
|
||||
|
||||
try {
|
||||
|
||||
@@ -1909,12 +1973,12 @@ export async function setLocalIdentity(directory, profile) {
|
||||
const authType = profile.authType || 'ssh';
|
||||
|
||||
if (authType === 'ssh' && profile.sshKey) {
|
||||
await git.addConfig(
|
||||
await git.raw([
|
||||
'config',
|
||||
'--local',
|
||||
'core.sshCommand',
|
||||
buildSshCommand(profile.sshKey),
|
||||
false,
|
||||
'local'
|
||||
);
|
||||
buildSshCommand(profile.sshKey)
|
||||
]);
|
||||
await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
|
||||
} else if (authType === 'token' && profile.host) {
|
||||
await git.addConfig(
|
||||
@@ -1941,9 +2005,19 @@ export async function setLocalIdentity(directory, profile) {
|
||||
|
||||
export async function getStatus(directory, options = {}) {
|
||||
const lightMode = options.mode === 'light';
|
||||
const normalizedDirectory = normalizeDirectoryPath(directory);
|
||||
if (typeof normalizedDirectory !== 'string' || !normalizedDirectory.trim()) {
|
||||
throw new Error('directory is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const { directoryPath, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
// Prefer an explicit non-repo check before simple-git status so a missing
|
||||
// repository never depends on process.cwd() or an opaque GitError shape.
|
||||
if (!(await isGitRepository(normalizedDirectory))) {
|
||||
throw new Error('fatal: not a git repository (or any of the parent directories): .git');
|
||||
}
|
||||
|
||||
const { directoryPath, repoRoot, git } = await createRepositoryGitContext(normalizedDirectory);
|
||||
|
||||
// Use -uall to show all untracked files individually, not just directories
|
||||
const status = await git.status(['-uall']);
|
||||
@@ -2197,9 +2271,12 @@ export async function getStatus(directory, options = {}) {
|
||||
rebaseInProgress,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isNotGitRepositoryError(error) && !isMissingDirectoryError(error)) {
|
||||
console.error('Failed to get Git status:', error);
|
||||
if (isNotGitRepositoryError(error) || isMissingDirectoryError(error)) {
|
||||
// Re-throw a plain Error so route/session callers can match reliably and
|
||||
// continue enumerating other projects instead of treating GitError as 500.
|
||||
throw new Error('fatal: not a git repository (or any of the parent directories): .git');
|
||||
}
|
||||
console.error('Failed to get Git status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2240,6 +2317,20 @@ export async function getDiff(directory, { path: filePath, staged = false, conte
|
||||
await git.raw(['ls-files', '--error-unmatch', '--', fileContext.repoPath]);
|
||||
return diff;
|
||||
} catch {
|
||||
if (fileContext.isSymbolicLink) {
|
||||
const target = await fsp.readlink(fileContext.absolutePath);
|
||||
return [
|
||||
`diff --git a/${fileContext.repoPath} b/${fileContext.repoPath}`,
|
||||
'new file mode 120000',
|
||||
'--- /dev/null',
|
||||
`+++ b/${fileContext.repoPath}`,
|
||||
'@@ -0,0 +1 @@',
|
||||
`+${target}`,
|
||||
'\\ No newline at end of file',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const noIndexArgs = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
|
||||
@@ -2262,6 +2353,78 @@ export async function getDiff(directory, { path: filePath, staged = false, conte
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual untracked file paths, honoring ignore rules.
|
||||
*
|
||||
* Deliberately not `--directory`: collapsed directory entries end in a slash
|
||||
* and are not valid inputs to the per-file diff helpers, so a caller would
|
||||
* silently lose every file inside a new directory. Listing files costs more
|
||||
* entries but each one is usable.
|
||||
*
|
||||
* Callers that only need this list should not pay for `getStatus`, which also
|
||||
* computes ahead/behind, diff stats, and merge state — an order of magnitude
|
||||
* more work for an answer they throw away.
|
||||
*/
|
||||
export async function listUntrackedPaths(directory) {
|
||||
const { repoRoot } = await createRepositoryGitContext(directory);
|
||||
const result = await runGitCommand(repoRoot, [
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
]);
|
||||
if (!result.success) return [];
|
||||
return String(result.stdout || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diffs for untracked files, produced against an empty tree.
|
||||
*
|
||||
* `getDiff` re-resolves the repository context on every call, which costs an
|
||||
* extra `rev-parse` per file; a walkthrough of a branch with thirty new files
|
||||
* pays that thirty times. This resolves once and reuses it, with a bounded pool
|
||||
* so a repository full of new files cannot flood the process table.
|
||||
*
|
||||
* Returns one entry per input path, in order; unreadable paths yield `''`
|
||||
* rather than failing the batch.
|
||||
*/
|
||||
export async function getUntrackedDiffs(directory, filePaths = [], { concurrency = 8, contextLines = 3 } = {}) {
|
||||
const paths = (Array.isArray(filePaths) ? filePaths : []).filter((value) => typeof value === 'string' && value);
|
||||
if (paths.length === 0) return [];
|
||||
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const results = new Array(paths.length).fill('');
|
||||
let cursor = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (cursor < paths.length) {
|
||||
const index = cursor++;
|
||||
try {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, paths[index], repoRoot);
|
||||
const args = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
args.push('--no-index', '--', '/dev/null', fileContext.repoPath);
|
||||
try {
|
||||
results[index] = await git.raw(args);
|
||||
} catch (error) {
|
||||
// `git diff --no-index` exits 1 whenever there are differences, which
|
||||
// for a new file is always.
|
||||
results[index] = error?.exitCode === 1 && error?.message ? error.message : '';
|
||||
}
|
||||
} catch {
|
||||
results[index] = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, paths.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
@@ -2283,6 +2446,27 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Not every repository has an `origin`. When the base names a branch that
|
||||
// exists only on another remote, a bare name does not resolve — git looks in
|
||||
// refs/heads, not across remotes — and the diff fails with "ambiguous
|
||||
// argument". Fall back to whichever remote actually carries it.
|
||||
if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) {
|
||||
const resolvesLocally = await git
|
||||
.raw(['rev-parse', '--verify', `refs/heads/${baseRef}`])
|
||||
.then((value) => Boolean(String(value || '').trim()))
|
||||
.catch(() => false);
|
||||
|
||||
if (!resolvesLocally) {
|
||||
const remoteMatch = await git
|
||||
.raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`])
|
||||
.then((value) => String(value || '').trim())
|
||||
.catch(() => '');
|
||||
if (remoteMatch) {
|
||||
resolvedBase = remoteMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const args = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
@@ -2437,9 +2621,9 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const isImage = isImageFile(filePath);
|
||||
const mimeType = isImage ? getImageMimeType(filePath) : null;
|
||||
const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
const { absolutePath, repoPath, isSymbolicLink } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
|
||||
if (!isImage) {
|
||||
if (!isImage && !isSymbolicLink) {
|
||||
const isBinaryBySniff = await looksBinaryBySniff(absolutePath);
|
||||
const isBinary = isBinaryBySniff || (await isBinaryDiff(repoRoot, repoPath, staged));
|
||||
if (isBinary) {
|
||||
@@ -2493,8 +2677,18 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
||||
modified = await git.show([`:${repoPath}`]);
|
||||
}
|
||||
} else {
|
||||
const stat = await fsp.stat(absolutePath);
|
||||
if (stat.isFile()) {
|
||||
if (isSymbolicLink) {
|
||||
modified = await fsp.readlink(absolutePath);
|
||||
} else {
|
||||
const stat = await fsp.stat(absolutePath);
|
||||
if (!stat.isFile()) {
|
||||
return {
|
||||
original: typeof original === 'string' ? original.replace(/\r\n/g, '\n') : original,
|
||||
modified: '',
|
||||
path: filePath,
|
||||
isBinary: false,
|
||||
};
|
||||
}
|
||||
if (isImage) {
|
||||
// For images, read as binary and convert to data URL
|
||||
const buffer = await fsp.readFile(absolutePath);
|
||||
@@ -3194,6 +3388,7 @@ export async function getBranches(directory) {
|
||||
const allBranches = result.all;
|
||||
const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/'));
|
||||
const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches);
|
||||
const defaultBranches = await getRemoteDefaultBranches(git);
|
||||
|
||||
const filteredAll = [
|
||||
...allBranches.filter(branch => !branch.startsWith('remotes/')),
|
||||
@@ -3203,7 +3398,8 @@ export async function getBranches(directory) {
|
||||
return {
|
||||
all: filteredAll,
|
||||
current: result.current,
|
||||
branches: result.branches
|
||||
branches: result.branches,
|
||||
defaultBranches,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get branches:', error);
|
||||
@@ -3211,11 +3407,72 @@ export async function getBranches(directory) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteDefaultBranches(git) {
|
||||
let defaults = {};
|
||||
|
||||
try {
|
||||
const refs = await git.raw([
|
||||
'for-each-ref',
|
||||
'--format=%(refname) %(symref)',
|
||||
'refs/remotes',
|
||||
]);
|
||||
defaults = Object.fromEntries(
|
||||
refs.trim().split('\n').flatMap((line) => {
|
||||
const [ref, symbolicRef] = line.split(' ');
|
||||
const match = ref.match(/^refs\/remotes\/([^/]+)\/HEAD$/);
|
||||
const prefix = match ? `refs/remotes/${match[1]}/` : '';
|
||||
return match && typeof symbolicRef === 'string' && symbolicRef.startsWith(prefix)
|
||||
? [[match[1], symbolicRef.slice(prefix.length)]]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
defaults = {};
|
||||
}
|
||||
|
||||
// `remote/HEAD` is written by clone and by `git remote set-head`; a remote
|
||||
// added by hand may never have one. Without this the caller falls back to
|
||||
// guessing main/master/develop, which is exactly the guess this data exists
|
||||
// to replace — so ask the remote itself, but only for the remotes that are
|
||||
// actually missing an answer.
|
||||
try {
|
||||
const remotes = await git.getRemotes();
|
||||
const missing = remotes.filter((remote) => remote?.name && !defaults[remote.name]);
|
||||
if (missing.length === 0) return defaults;
|
||||
|
||||
const resolved = await Promise.all(missing.map(async (remote) => {
|
||||
try {
|
||||
const output = await git.raw(['ls-remote', '--symref', remote.name, 'HEAD']);
|
||||
const match = String(output || '').match(/^ref:\s+refs\/heads\/(.+?)\s+HEAD$/m);
|
||||
return match ? [remote.name, match[1]] : null;
|
||||
} catch {
|
||||
// Unreachable or refusing: no answer is better than a guessed one.
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
for (const entry of resolved) {
|
||||
if (entry) defaults[entry[0]] = entry[1];
|
||||
}
|
||||
} catch {
|
||||
// Remote list unavailable; the local symrefs are still valid.
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
async function filterActiveRemoteBranches(git, remoteBranches) {
|
||||
try {
|
||||
const remotes = await git.getRemotes();
|
||||
const branchesByRemote = new Map();
|
||||
|
||||
// A remote that did not answer says nothing about its branches. Dropping
|
||||
// them would turn "we could not ask" into "these branches are gone", and
|
||||
// callers use this list to decide whether a base branch exists at all — so
|
||||
// offline would silently remove comparisons that work perfectly well
|
||||
// against the local remote-tracking refs.
|
||||
const unreachableRemotes = new Set();
|
||||
|
||||
await Promise.all(remotes.map(async (remote) => {
|
||||
try {
|
||||
const lsRemoteResult = await git.raw(['ls-remote', '--heads', remote.name]);
|
||||
@@ -3229,7 +3486,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
|
||||
}
|
||||
branchesByRemote.set(remote.name, actualRemoteBranches);
|
||||
} catch {
|
||||
// Skip remotes that fail (e.g., unreachable)
|
||||
unreachableRemotes.add(remote.name);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -3238,6 +3495,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
|
||||
if (!match) return false;
|
||||
const remoteName = remoteBranch.split('/')[1];
|
||||
const branchName = match[1];
|
||||
if (unreachableRemotes.has(remoteName)) return true;
|
||||
return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -3678,22 +3936,15 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
||||
|
||||
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const shouldSetUpstream = Boolean(input?.setUpstream);
|
||||
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
||||
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
||||
|
||||
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
|
||||
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
|
||||
status: WORKTREE_BOOTSTRAP_PENDING,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
|
||||
);
|
||||
|
||||
queueWorktreeBootstrap({
|
||||
directory: candidate.directory,
|
||||
@@ -3744,31 +3995,26 @@ export async function createWorktree(directory, input = {}) {
|
||||
if (input?.returnAfterDirectoryCreated === true) {
|
||||
await fsp.mkdir(candidate.directory, { recursive: false });
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
|
||||
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
|
||||
status: WORKTREE_BOOTSTRAP_PENDING,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
|
||||
);
|
||||
const localBranch = mode === 'existing'
|
||||
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
|
||||
: candidate.branch;
|
||||
|
||||
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => {
|
||||
const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
|
||||
setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_FAILED,
|
||||
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
void cleanupFailedFastWorktreeCreate(context, candidate);
|
||||
await cleanupFailedFastWorktreeCreate(context, candidate);
|
||||
console.warn('Background worktree creation failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
trackWorktreeBootstrapTask(candidate.directory, task);
|
||||
|
||||
return {
|
||||
head: '',
|
||||
@@ -3794,11 +4040,10 @@ export async function getWorktreeBootstrapStatus(directory) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
status: WORKTREE_BOOTSTRAP_READY,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return createWorktreeBootstrapState(
|
||||
WORKTREE_BOOTSTRAP_READY,
|
||||
WORKTREE_BOOTSTRAP_PHASE_SETUP_READY
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeWorktree(directory, input = {}) {
|
||||
@@ -3807,6 +4052,8 @@ export async function removeWorktree(directory, input = {}) {
|
||||
throw new Error('Worktree directory is required');
|
||||
}
|
||||
|
||||
await waitForActiveWorktreeBootstrap(targetDirectory);
|
||||
|
||||
const context = await resolveWorktreeProjectContext(directory);
|
||||
const deleteLocalBranch = input?.deleteLocalBranch === true;
|
||||
|
||||
@@ -3840,12 +4087,6 @@ export async function removeWorktree(directory, input = {}) {
|
||||
await fsp.rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(targetDirectory);
|
||||
|
||||
return true;
|
||||
@@ -3868,12 +4109,6 @@ export async function removeWorktree(directory, input = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(matchedEntry.worktree);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -9,17 +9,24 @@ import {
|
||||
checkoutCommit,
|
||||
cherryPick,
|
||||
createWorktree,
|
||||
getWorktreeBootstrapStatus,
|
||||
getBranches,
|
||||
getRangeDiff,
|
||||
getStatus,
|
||||
isGitRepository,
|
||||
populateWorktreeWithLockRecovery,
|
||||
removeWorktree,
|
||||
resolvePrimaryWorktreeRoot,
|
||||
resolveWorktreeTopLevel,
|
||||
resetToCommit,
|
||||
resolveBaseRefForLog,
|
||||
revertCommit,
|
||||
setLocalIdentity,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
applyHunk,
|
||||
getDiff,
|
||||
getFileDiff,
|
||||
} from './service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -42,6 +49,28 @@ const runGit = (cwd, args) =>
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
/**
|
||||
* A repository on `next` whose only remote publishes `defaultBranch` and has it
|
||||
* recorded as that remote's HEAD — the shape of every repository whose default
|
||||
* branch is not one of the conventional names.
|
||||
*/
|
||||
const createRepositoryWithRemote = ({ remoteName = 'origin', defaultBranch = 'react' } = {}) => {
|
||||
const remote = createTempDir();
|
||||
const repository = createTempDir();
|
||||
runGit(remote, ['init', '--bare', `--initial-branch=${defaultBranch}`]);
|
||||
runGit(repository, ['init', '-b', 'next']);
|
||||
runGit(repository, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(repository, ['config', 'user.name', 'Test']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
|
||||
runGit(repository, ['add', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'init']);
|
||||
runGit(repository, ['remote', 'add', remoteName, remote]);
|
||||
runGit(repository, ['push', remoteName, `HEAD:${defaultBranch}`]);
|
||||
runGit(repository, ['fetch', remoteName]);
|
||||
runGit(repository, ['remote', 'set-head', remoteName, '--auto']);
|
||||
return { remote, repository };
|
||||
};
|
||||
|
||||
const canRunGit = () => {
|
||||
try {
|
||||
execFileSync('git', ['--version'], { stdio: 'ignore' });
|
||||
@@ -125,6 +154,23 @@ describe('git index path validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('setLocalIdentity', () => {
|
||||
it('configures the local SSH command with the targeted simple-git opt-in', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
|
||||
await setLocalIdentity(tmpDir, {
|
||||
userName: 'SSH User',
|
||||
userEmail: 'ssh@example.com',
|
||||
authType: 'ssh',
|
||||
sshKey: '/tmp/test key',
|
||||
});
|
||||
|
||||
expect(runGit(tmpDir, ['config', '--local', '--get', 'core.sshCommand']).trim()).toBe(
|
||||
"ssh -i '/tmp/test key' -o IdentitiesOnly=yes"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyHunk (per-hunk stage / unstage / discard)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -262,6 +308,26 @@ describe('applyHunk', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('symlink diffs', () => {
|
||||
it('treats an untracked directory symlink as a link in patch and split diffs', async () => {
|
||||
if (!canRunGit() || process.platform === 'win32') return;
|
||||
const { tmpDir } = await createTempRepo();
|
||||
fs.mkdirSync(path.join(tmpDir, 'source'));
|
||||
fs.symlinkSync('source', path.join(tmpDir, 'linked-source'));
|
||||
|
||||
const patch = await getDiff(tmpDir, { path: 'linked-source' });
|
||||
const split = await getFileDiff(tmpDir, { path: 'linked-source' });
|
||||
|
||||
expect(patch).toContain('new file mode 120000');
|
||||
expect(patch).toContain('+source');
|
||||
expect(split).toMatchObject({
|
||||
original: '',
|
||||
modified: 'source',
|
||||
isBinary: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -280,6 +346,84 @@ describe('getStatus', () => {
|
||||
|
||||
await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' });
|
||||
});
|
||||
|
||||
it('rejects a non-git folder without using process.cwd()', async () => {
|
||||
if (!canRunGit()) return;
|
||||
|
||||
const nonGit = createTempDir();
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(nonGit);
|
||||
try {
|
||||
await expect(getStatus(nonGit)).rejects.toThrow(/not a git repository/i);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads status for a git repo when process.cwd() is elsewhere', async () => {
|
||||
if (!canRunGit()) return;
|
||||
|
||||
const repo = createTempDir();
|
||||
const neutralCwd = createTempDir();
|
||||
runGit(repo, ['init', '-b', 'main']);
|
||||
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(repo, ['config', 'user.name', 'Test User']);
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
||||
runGit(repo, ['add', 'README.md']);
|
||||
runGit(repo, ['commit', '-m', 'Initial commit']);
|
||||
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(neutralCwd);
|
||||
try {
|
||||
await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main', isClean: true });
|
||||
await expect(isGitRepository(repo)).resolves.toBe(true);
|
||||
await expect(isGitRepository(neutralCwd)).resolves.toBe(false);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports a folder with nested git repositories from a foreign cwd', async () => {
|
||||
if (!canRunGit()) return;
|
||||
|
||||
const parent = createTempDir();
|
||||
const nested = path.join(parent, 'nested');
|
||||
const neutralCwd = createTempDir();
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
|
||||
runGit(parent, ['init', '-b', 'main']);
|
||||
runGit(parent, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(parent, ['config', 'user.name', 'Test User']);
|
||||
fs.writeFileSync(path.join(parent, 'README.md'), '# Parent\n');
|
||||
runGit(parent, ['add', 'README.md']);
|
||||
runGit(parent, ['commit', '-m', 'Parent commit']);
|
||||
|
||||
runGit(nested, ['init', '-b', 'feature']);
|
||||
runGit(nested, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(nested, ['config', 'user.name', 'Test User']);
|
||||
fs.writeFileSync(path.join(nested, 'nested.txt'), 'nested\n');
|
||||
runGit(nested, ['add', 'nested.txt']);
|
||||
runGit(nested, ['commit', '-m', 'Nested commit']);
|
||||
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(neutralCwd);
|
||||
try {
|
||||
await expect(getStatus(parent)).resolves.toMatchObject({ current: 'main' });
|
||||
await expect(getStatus(nested)).resolves.toMatchObject({ current: 'feature' });
|
||||
// Enumeration must continue when one path is not a repo.
|
||||
const results = await Promise.allSettled([
|
||||
getStatus(parent),
|
||||
getStatus(neutralCwd),
|
||||
getStatus(nested),
|
||||
]);
|
||||
expect(results[0].status).toBe('fulfilled');
|
||||
expect(results[1].status).toBe('rejected');
|
||||
expect(results[1].reason?.message || String(results[1].reason)).toMatch(/not a git repository/i);
|
||||
expect(results[2].status).toBe('fulfilled');
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -321,6 +465,157 @@ describe('worktree root resolution', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createWorktree', () => {
|
||||
it('returns ready/setup-ready when no bootstrap state is recorded', async () => {
|
||||
const directory = path.join(createTempDir(), 'missing-worktree');
|
||||
|
||||
await expect(getWorktreeBootstrapStatus(directory)).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
phase: 'setup-ready',
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports directory, Git, and setup bootstrap phases while preserving legacy status', async () => {
|
||||
if (!canRunGit()) return;
|
||||
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
const dataHome = createTempDir();
|
||||
const setupMarker = path.join(dataHome, 'setup-started');
|
||||
const setupScript = path.join(dataHome, 'setup-phase.cjs');
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
fs.writeFileSync(
|
||||
setupScript,
|
||||
`require('node:fs').writeFileSync(${JSON.stringify(setupMarker)}, 'started'); setTimeout(() => {}, 1000);\n`,
|
||||
);
|
||||
|
||||
try {
|
||||
const repo = createTempDir();
|
||||
runGit(repo, ['init', '-b', 'main']);
|
||||
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(repo, ['config', 'user.name', 'Test User']);
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
||||
runGit(repo, ['add', 'README.md']);
|
||||
runGit(repo, ['commit', '-m', 'Initial commit']);
|
||||
|
||||
const created = await createWorktree(repo, {
|
||||
mode: 'new',
|
||||
branchName: 'feature/bootstrap-phases',
|
||||
worktreeName: 'bootstrap-phases',
|
||||
returnAfterDirectoryCreated: true,
|
||||
startCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(setupScript)}`,
|
||||
});
|
||||
|
||||
expect(created.bootstrapStatus).toMatchObject({
|
||||
status: 'pending',
|
||||
phase: 'directory-created',
|
||||
error: null,
|
||||
});
|
||||
|
||||
await expect.poll(() => fs.existsSync(setupMarker), { timeout: 5_000 }).toBe(true);
|
||||
await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({
|
||||
status: 'pending',
|
||||
phase: 'git-ready',
|
||||
error: null,
|
||||
});
|
||||
|
||||
await expect.poll(
|
||||
async () => (await getWorktreeBootstrapStatus(created.path)).phase,
|
||||
{ timeout: 5_000 },
|
||||
).toBe('setup-ready');
|
||||
await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
phase: 'setup-ready',
|
||||
error: null,
|
||||
});
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for active bootstrap work before removing a worktree', async () => {
|
||||
if (!canRunGit()) return;
|
||||
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
const dataHome = createTempDir();
|
||||
const setupStarted = path.join(dataHome, 'remove-race-started');
|
||||
const setupCompleted = path.join(dataHome, 'remove-race-completed');
|
||||
const setupScript = path.join(dataHome, 'remove-race.cjs');
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
fs.writeFileSync(
|
||||
setupScript,
|
||||
`const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(setupStarted)}, 'started'); setTimeout(() => fs.writeFileSync(${JSON.stringify(setupCompleted)}, 'completed'), 300);\n`,
|
||||
);
|
||||
|
||||
try {
|
||||
const repo = createTempDir();
|
||||
runGit(repo, ['init', '-b', 'main']);
|
||||
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(repo, ['config', 'user.name', 'Test User']);
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
||||
runGit(repo, ['add', 'README.md']);
|
||||
runGit(repo, ['commit', '-m', 'Initial commit']);
|
||||
|
||||
const created = await createWorktree(repo, {
|
||||
mode: 'new',
|
||||
branchName: 'feature/remove-bootstrap-race',
|
||||
worktreeName: 'remove-bootstrap-race',
|
||||
returnAfterDirectoryCreated: true,
|
||||
startCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(setupScript)}`,
|
||||
});
|
||||
|
||||
await expect.poll(() => fs.existsSync(setupStarted), { timeout: 5_000 }).toBe(true);
|
||||
let removalCompleted = false;
|
||||
const removal = removeWorktree(repo, { directory: created.path }).then(() => {
|
||||
removalCompleted = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
expect(removalCompleted).toBe(false);
|
||||
await removal;
|
||||
|
||||
expect(fs.existsSync(setupCompleted)).toBe(true);
|
||||
expect(fs.existsSync(created.path)).toBe(false);
|
||||
await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
phase: 'setup-ready',
|
||||
});
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('recovers from an unchanged stale index lock while populating a worktree', async () => {
|
||||
if (!canRunGit()) return;
|
||||
|
||||
const repo = createTempDir();
|
||||
const worktree = createTempDir();
|
||||
runGit(repo, ['init', '-b', 'main']);
|
||||
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(repo, ['config', 'user.name', 'Test User']);
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
||||
runGit(repo, ['add', 'README.md']);
|
||||
runGit(repo, ['commit', '-m', 'Initial commit']);
|
||||
fs.rmSync(worktree, { recursive: true, force: true });
|
||||
runGit(repo, ['worktree', 'add', '--no-checkout', '-b', 'feature/stale-lock', worktree, 'HEAD']);
|
||||
|
||||
const lockPath = runGit(worktree, ['rev-parse', '--git-path', 'index.lock']).trim();
|
||||
fs.writeFileSync(lockPath, 'stale');
|
||||
|
||||
await expect(populateWorktreeWithLockRecovery(worktree)).resolves.toBeUndefined();
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
expect(fs.readFileSync(path.join(worktree, 'README.md'), 'utf8')).toBe('# Test\n');
|
||||
});
|
||||
|
||||
it('preflights fast create branch-in-use failures before creating the candidate directory', async () => {
|
||||
if (!canRunGit()) return;
|
||||
|
||||
@@ -717,3 +1012,50 @@ describe('hash validation', () => {
|
||||
).rejects.not.toThrow('Invalid commit hash');
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getBranches', () => {
|
||||
it('returns a remote default branch whose name is not a conventional fallback', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
|
||||
|
||||
await expect(getBranches(repository)).resolves.toMatchObject({
|
||||
defaultBranches: { origin: 'react' },
|
||||
});
|
||||
});
|
||||
|
||||
it('asks the remote when no local remote/HEAD exists', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
|
||||
// A hand-added remote can end up without this ref; the branch it points at
|
||||
// is still knowable, and guessing instead is the bug this data replaces.
|
||||
runGit(repository, ['remote', 'set-head', 'origin', '--delete']);
|
||||
|
||||
await expect(getBranches(repository)).resolves.toMatchObject({
|
||||
defaultBranches: { origin: 'react' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the branches of a remote that cannot be reached', async () => {
|
||||
const { repository, remote } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
|
||||
fs.rmSync(remote, { recursive: true, force: true });
|
||||
|
||||
const branches = await getBranches(repository);
|
||||
|
||||
// "We could not ask" is not "the branch is gone": callers read this list to
|
||||
// decide whether a base branch exists at all.
|
||||
expect(branches.all).toContain('remotes/origin/react');
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
it('resolves a base that exists only on a remote other than origin', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
|
||||
// Only refs/remotes/upstream/react carries the base — git cannot resolve the
|
||||
// bare name, so an unqualified `react...next` fails with "ambiguous argument".
|
||||
fs.writeFileSync(path.join(repository, 'feature.txt'), 'work\n');
|
||||
runGit(repository, ['add', 'feature.txt']);
|
||||
runGit(repository, ['commit', '-m', 'feature']);
|
||||
|
||||
const diff = await getRangeDiff(repository, { base: 'react', head: 'next' });
|
||||
|
||||
expect(diff).toContain('feature.txt');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
- Closed or merged PR -> stop regular polling.
|
||||
- Hidden tab -> skip polling.
|
||||
- Non-forced refreshes use a `90s` TTL.
|
||||
- Failed non-forced attempts also observe the `90s` TTL so transient server or rate-limit failures cannot retry on every sidebar update. Forced user/action refreshes bypass this guard.
|
||||
|
||||
## Background tracking rules
|
||||
|
||||
|
||||
@@ -17,9 +17,60 @@ const timeoutFetch = (url, options = {}) => {
|
||||
return fetch(url, { ...options, signal: AbortSignal.timeout(OCTOKIT_REQUEST_TIMEOUT_MS) });
|
||||
};
|
||||
|
||||
/** Create an Octokit instance with a per-request timeout applied. */
|
||||
// Conditional-request cache for GET calls: GitHub serves 304 Not Modified for
|
||||
// matching If-None-Match WITHOUT counting the request against the REST rate
|
||||
// limit, so polling unchanged PRs/checks becomes rate-limit-free. Keyed by
|
||||
// token+URL so different identities never share responses.
|
||||
const ETAG_CACHE_MAX_ENTRIES = 300;
|
||||
const etagCache = new Map();
|
||||
|
||||
const rememberEtag = (key, etag, body, headers) => {
|
||||
etagCache.delete(key);
|
||||
etagCache.set(key, { etag, body, headers });
|
||||
if (etagCache.size > ETAG_CACHE_MAX_ENTRIES) {
|
||||
const oldest = etagCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
etagCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createConditionalFetch = (token) => async (url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
if (method !== 'GET') {
|
||||
return timeoutFetch(url, options);
|
||||
}
|
||||
|
||||
const cacheKey = `${token}\n${url}`;
|
||||
const cached = etagCache.get(cacheKey);
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (cached?.etag) {
|
||||
headers['if-none-match'] = cached.etag;
|
||||
}
|
||||
|
||||
const response = await timeoutFetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 304 && cached) {
|
||||
// Touch for LRU and replay the cached success response.
|
||||
rememberEtag(cacheKey, cached.etag, cached.body, cached.headers);
|
||||
return new Response(cached.body, { status: 200, headers: cached.headers });
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const etag = response.headers.get('etag');
|
||||
if (etag) {
|
||||
const body = await response.arrayBuffer();
|
||||
rememberEtag(cacheKey, etag, body, response.headers);
|
||||
return new Response(body, { status: response.status, headers: response.headers });
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/** Create an Octokit instance with per-request timeout + ETag revalidation. */
|
||||
export function createOctokit(token) {
|
||||
return new Octokit({ auth: token, request: { fetch: timeoutFetch } });
|
||||
return new Octokit({ auth: token, request: { fetch: createConditionalFetch(token) } });
|
||||
}
|
||||
|
||||
export function getOctokitOrNull() {
|
||||
|
||||
@@ -325,6 +325,59 @@ const safeListPulls = async (octokit, options) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Repo-level pull list, shared across every branch resolution. Ten worktree
|
||||
// branches of one repo need ONE pulls.list per state per TTL window, not ten
|
||||
// per-branch query fans. In-flight requests coalesce so concurrent branch
|
||||
// resolutions share a single GitHub call.
|
||||
const REPO_PULLS_CACHE_TTL_MS = 45_000;
|
||||
const repoPullsCache = new Map();
|
||||
|
||||
export const invalidateRepoPullsCache = (owner, repo) => {
|
||||
const prefix = `${normalizeText(owner)}/${normalizeText(repo)}::`;
|
||||
for (const key of repoPullsCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
repoPullsCache.delete(key);
|
||||
}
|
||||
}
|
||||
// A just-created PR must also clear remembered search misses for this repo.
|
||||
const repoNameLower = normalizeText(repo).toLowerCase();
|
||||
for (const key of _searchMissCache.keys()) {
|
||||
const [repoPart] = key.split('::');
|
||||
if (repoPart && repoPart.split(',').includes(repoNameLower)) {
|
||||
_searchMissCache.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getRepoPulls = (octokit, repo, state, { force = false } = {}) => {
|
||||
const key = `${normalizeText(repo.owner)}/${normalizeText(repo.repo)}::${state}`;
|
||||
const cached = repoPullsCache.get(key);
|
||||
if (cached?.promise) {
|
||||
return cached.promise;
|
||||
}
|
||||
if (!force && cached && Date.now() - cached.fetchedAt < REPO_PULLS_CACHE_TTL_MS) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
const promise = safeListPulls(octokit, {
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
state,
|
||||
per_page: 100,
|
||||
}).then((prs) => {
|
||||
// `complete` means the first page held everything, so a miss is
|
||||
// authoritative: this repo has no PR in this state for any branch.
|
||||
const entry = { fetchedAt: Date.now(), prs, complete: prs.length < 100 };
|
||||
repoPullsCache.set(key, entry);
|
||||
return entry;
|
||||
}).catch((error) => {
|
||||
repoPullsCache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
repoPullsCache.set(key, { promise });
|
||||
return promise;
|
||||
};
|
||||
|
||||
const parseRepoFromApiUrl = (value) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
@@ -351,6 +404,24 @@ const parseRepoFromApiUrl = (value) => {
|
||||
const _searchApiDisabledRepos = new Map();
|
||||
const SEARCH_API_RETRY_MS = 5 * 60 * 1000; // retry after 5 minutes
|
||||
|
||||
// The Search API has its own tiny quota (30/min). A branch that has no PR
|
||||
// would otherwise re-search on every poll; a miss is extremely unlikely to
|
||||
// change within minutes, so remember it per repo+branch and back off.
|
||||
const SEARCH_MISS_RETRY_MS = 10 * 60 * 1000;
|
||||
const SEARCH_MISS_CACHE_MAX_ENTRIES = 500;
|
||||
const _searchMissCache = new Map();
|
||||
|
||||
const rememberSearchMiss = (key) => {
|
||||
_searchMissCache.delete(key);
|
||||
_searchMissCache.set(key, Date.now());
|
||||
if (_searchMissCache.size > SEARCH_MISS_CACHE_MAX_ENTRIES) {
|
||||
const oldest = _searchMissCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
_searchMissCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
|
||||
// Build a repo key to check/store 403 status per-repo
|
||||
const repoKey = [...repoNames].sort().join(',').toLowerCase();
|
||||
@@ -361,6 +432,12 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const missKey = `${repoKey}::${normalizeText(branch)}`;
|
||||
const missedAt = _searchMissCache.get(missKey);
|
||||
if (missedAt && Date.now() - missedAt < SEARCH_MISS_RETRY_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedRepoNames = new Set(repoNames.map((name) => normalizeLower(name)).filter(Boolean));
|
||||
|
||||
for (const state of ['open', 'closed']) {
|
||||
@@ -420,10 +497,11 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
|
||||
}
|
||||
}
|
||||
|
||||
rememberSearchMiss(missKey);
|
||||
return null;
|
||||
};
|
||||
|
||||
const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates }) => {
|
||||
const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates, force = false, coverage = null }) => {
|
||||
const matcher = buildSourceMatcher(sourceCandidates);
|
||||
const sourceOwners = [];
|
||||
sourceCandidates.forEach((candidate) => pushUnique(sourceOwners, candidate.repo?.owner));
|
||||
@@ -434,6 +512,27 @@ const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates }
|
||||
.sort((left, right) => matcher.compare(left, right, target.repo.repo))[0] ?? null;
|
||||
|
||||
for (const state of ['open', 'closed']) {
|
||||
// Shared per-repo list first: one pulls.list answers every branch of the
|
||||
// repo within the TTL. A miss in a complete list is authoritative — skip
|
||||
// the per-branch query fan entirely.
|
||||
let listWasComplete = false;
|
||||
try {
|
||||
const listEntry = await getRepoPulls(octokit, target.repo, state, { force });
|
||||
const fromList = pickPreferred(listEntry.prs);
|
||||
if (fromList) {
|
||||
return fromList;
|
||||
}
|
||||
listWasComplete = listEntry.complete;
|
||||
} catch {
|
||||
// fall through to the precise per-branch queries
|
||||
}
|
||||
if (listWasComplete) {
|
||||
continue;
|
||||
}
|
||||
if (coverage) {
|
||||
coverage.authoritative = false;
|
||||
}
|
||||
|
||||
for (const owner of sourceOwners) {
|
||||
const directCandidates = await safeListPulls(octokit, {
|
||||
owner: target.repo.owner,
|
||||
@@ -447,23 +546,12 @@ const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates }
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCandidates = await safeListPulls(octokit, {
|
||||
owner: target.repo.owner,
|
||||
repo: target.repo.repo,
|
||||
state,
|
||||
per_page: 100,
|
||||
});
|
||||
const fallback = pickPreferred(fallbackCandidates);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName }) {
|
||||
export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName, force = false }) {
|
||||
// A deleted worktree can still have a session in the sidebar that keeps
|
||||
// requesting its PR status. Bail before touching git or GitHub for a
|
||||
// directory that no longer exists — otherwise every poll spends a git call
|
||||
@@ -506,6 +594,9 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote
|
||||
}
|
||||
|
||||
const sourceCandidates = resolvedTargets.slice();
|
||||
// When every consulted repo list was complete, a no-PR result is
|
||||
// authoritative and the expensive Search API fallback is pointless.
|
||||
const coverage = { authoritative: true };
|
||||
|
||||
let fallbackRepo = resolvedTargets[0].repo;
|
||||
let fallbackRemoteName = resolvedTargets[0].remoteName;
|
||||
@@ -530,6 +621,8 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote
|
||||
target,
|
||||
branch: candidateBranch,
|
||||
sourceCandidates,
|
||||
force,
|
||||
coverage,
|
||||
});
|
||||
if (pr) {
|
||||
return {
|
||||
@@ -543,6 +636,9 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote
|
||||
}
|
||||
|
||||
for (const candidateBranch of branchCandidates) {
|
||||
if (coverage.authoritative) {
|
||||
break;
|
||||
}
|
||||
const fallbackSearch = await searchFallbackPr({
|
||||
octokit,
|
||||
branch: candidateBranch,
|
||||
|
||||
@@ -7,6 +7,100 @@ const PR_STATUS_CACHE_MAX_ENTRIES = 200;
|
||||
// status on error, and a later poll fills it in.
|
||||
const PR_STATUS_RESOLVE_TIMEOUT_MS = 12_000;
|
||||
const prStatusCache = new Map();
|
||||
let resolvedAuthLoginPromise = null;
|
||||
const PR_CONTEXT_CACHE_TTL_MS = 30_000;
|
||||
const PR_CONTEXT_CACHE_MAX_ENTRIES = 50;
|
||||
const prContextCache = new Map();
|
||||
|
||||
function invalidatePrContextCache(directory, number) {
|
||||
for (const key of prContextCache.keys()) {
|
||||
try {
|
||||
const [cachedDirectory, cachedNumber] = JSON.parse(key);
|
||||
if (cachedDirectory === directory && (number == null || cachedNumber === number)) {
|
||||
prContextCache.delete(key);
|
||||
}
|
||||
} catch {
|
||||
prContextCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate check runs into the summary shape shared by pr/status and
|
||||
// pulls/context. Keeps `pending` as queued+in_progress+unconcluded for
|
||||
// existing consumers while exposing the split and the earliest start time so
|
||||
// the UI can show live "running for N minutes" state.
|
||||
// A re-run leaves the previous completed check run in the listForRef payload
|
||||
// alongside the new in-progress one. GitHub's UI shows only the latest run
|
||||
// per (app, name); mirror that so counts match what users see on github.com.
|
||||
function dedupeCheckRuns(checkRuns) {
|
||||
const byName = new Map();
|
||||
for (const run of checkRuns) {
|
||||
const key = `${run?.app?.id ?? run?.app?.slug ?? ''}::${run?.name ?? ''}`;
|
||||
const previous = byName.get(key);
|
||||
if (!previous) {
|
||||
byName.set(key, run);
|
||||
continue;
|
||||
}
|
||||
const previousStartedAt = Date.parse(previous?.started_at || '') || 0;
|
||||
const startedAt = Date.parse(run?.started_at || '') || 0;
|
||||
if (startedAt > previousStartedAt
|
||||
|| (startedAt === previousStartedAt && (run?.id ?? 0) > (previous?.id ?? 0))) {
|
||||
byName.set(key, run);
|
||||
}
|
||||
}
|
||||
return Array.from(byName.values());
|
||||
}
|
||||
|
||||
function summarizeCheckRuns(checkRuns) {
|
||||
const counts = { success: 0, failure: 0, pending: 0, inProgress: 0, queued: 0 };
|
||||
let startedAt = null;
|
||||
for (const run of checkRuns) {
|
||||
const status = run?.status;
|
||||
const conclusion = run?.conclusion;
|
||||
if (status === 'in_progress') {
|
||||
counts.pending += 1;
|
||||
counts.inProgress += 1;
|
||||
const runStartedAt = typeof run?.started_at === 'string' ? run.started_at : null;
|
||||
if (runStartedAt && (!startedAt || runStartedAt < startedAt)) {
|
||||
startedAt = runStartedAt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (status === 'queued') {
|
||||
counts.pending += 1;
|
||||
counts.queued += 1;
|
||||
continue;
|
||||
}
|
||||
if (!conclusion) {
|
||||
counts.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||
counts.success += 1;
|
||||
} else {
|
||||
counts.failure += 1;
|
||||
}
|
||||
}
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
return { state, total, ...counts, ...(startedAt ? { startedAt } : {}) };
|
||||
}
|
||||
|
||||
function summarizeCombinedStatuses(statuses) {
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
statuses.forEach((s) => {
|
||||
if (s.state === 'success') counts.success += 1;
|
||||
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
||||
else if (s.state === 'pending') counts.pending += 1;
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
return { state, total, ...counts, inProgress: counts.pending, queued: 0 };
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs, label) {
|
||||
let timer;
|
||||
@@ -435,7 +529,13 @@ export function registerGitHubRoutes(app) {
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (data) => {
|
||||
if (data && data.connected === true) {
|
||||
setPrStatusCache(cacheKey, data, Date.now());
|
||||
// Freshness stamp travels with the payload (and survives cache
|
||||
// serves) so clients can refuse to overwrite newer data with a
|
||||
// stale cached response.
|
||||
if (typeof data.fetchedAt !== 'number') {
|
||||
data.fetchedAt = Date.now();
|
||||
}
|
||||
setPrStatusCache(cacheKey, data, data.fetchedAt);
|
||||
}
|
||||
return originalJson(data);
|
||||
};
|
||||
@@ -453,6 +553,7 @@ export function registerGitHubRoutes(app) {
|
||||
directory,
|
||||
branch,
|
||||
remoteName: remote,
|
||||
force,
|
||||
}),
|
||||
PR_STATUS_RESOLVE_TIMEOUT_MS,
|
||||
'resolveGitHubPrStatus',
|
||||
@@ -484,31 +585,9 @@ export function registerGitHubRoutes(app) {
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
||||
const checkRuns = dedupeCheckRuns(Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []);
|
||||
if (checkRuns.length > 0) {
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
for (const run of checkRuns) {
|
||||
const status = run?.status;
|
||||
const conclusion = run?.conclusion;
|
||||
if (status === 'queued' || status === 'in_progress') {
|
||||
counts.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (!conclusion) {
|
||||
counts.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||
counts.success += 1;
|
||||
} else {
|
||||
counts.failure += 1;
|
||||
}
|
||||
}
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
checks = { state, total, ...counts };
|
||||
checks = summarizeCheckRuns(checkRuns);
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall back
|
||||
@@ -522,17 +601,7 @@ export function registerGitHubRoutes(app) {
|
||||
ref: sha,
|
||||
});
|
||||
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
statuses.forEach((s) => {
|
||||
if (s.state === 'success') counts.success += 1;
|
||||
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
||||
else if (s.state === 'pending') counts.pending += 1;
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
checks = { state, total, ...counts };
|
||||
checks = summarizeCombinedStatuses(statuses);
|
||||
} catch {
|
||||
checks = null;
|
||||
}
|
||||
@@ -543,7 +612,20 @@ export function registerGitHubRoutes(app) {
|
||||
let canMerge = false;
|
||||
try {
|
||||
const auth = getGitHubAuth();
|
||||
const username = auth?.user?.login;
|
||||
// gh-CLI tokens have no persisted user record; resolve the login from
|
||||
// the API once (memoized) so permissions still resolve for them.
|
||||
let username = auth?.user?.login;
|
||||
if (!username) {
|
||||
if (!resolvedAuthLoginPromise) {
|
||||
resolvedAuthLoginPromise = octokit.rest.users.getAuthenticated()
|
||||
.then((resp) => resp?.data?.login || null)
|
||||
.catch(() => {
|
||||
resolvedAuthLoginPromise = null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
username = await resolvedAuthLoginPromise;
|
||||
}
|
||||
if (username) {
|
||||
const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: searchRepo.owner,
|
||||
@@ -790,6 +872,10 @@ export function registerGitHubRoutes(app) {
|
||||
const headBranch = head.includes(':') ? head.split(':')[1] || head : head;
|
||||
const createCacheKey = `${directory}::${headBranch}::${remote}`;
|
||||
prStatusCache.delete(createCacheKey);
|
||||
if (repo?.owner && repo?.repo) {
|
||||
const { invalidateRepoPullsCache } = await import('./pr-status.js');
|
||||
invalidateRepoPullsCache(repo.owner, repo.repo);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
number: pr.number,
|
||||
@@ -881,6 +967,7 @@ export function registerGitHubRoutes(app) {
|
||||
return res.status(500).json({ error: 'Failed to update PR' });
|
||||
}
|
||||
|
||||
invalidatePrContextCache(directory, number);
|
||||
return res.json({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
@@ -928,6 +1015,9 @@ export function registerGitHubRoutes(app) {
|
||||
pull_number: number,
|
||||
merge_method: method,
|
||||
});
|
||||
invalidatePrContextCache(directory, number);
|
||||
const { invalidateRepoPullsCache } = await import('./pr-status.js');
|
||||
invalidateRepoPullsCache(repo.owner, repo.repo);
|
||||
return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
|
||||
} catch (error) {
|
||||
if (error?.status === 403) {
|
||||
@@ -986,6 +1076,11 @@ export function registerGitHubRoutes(app) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
invalidatePrContextCache(directory, number);
|
||||
{
|
||||
const { invalidateRepoPullsCache } = await import('./pr-status.js');
|
||||
invalidateRepoPullsCache(repo.owner, repo.repo);
|
||||
}
|
||||
return res.json({ ready: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to mark PR ready:', error);
|
||||
@@ -1468,6 +1563,41 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
|
||||
// Short response cache: the checks view, comments view, and the
|
||||
// send-to-chat actions request the same context within seconds of each
|
||||
// other. Detail-inclusive responses satisfy detail-free requests.
|
||||
const contextCacheKey = JSON.stringify([
|
||||
directory,
|
||||
number,
|
||||
includeDiff,
|
||||
requestedRepo ? `${requestedRepo.owner}/${requestedRepo.repo}` : null,
|
||||
]);
|
||||
const cachedContext = prContextCache.get(contextCacheKey);
|
||||
if (cachedContext
|
||||
&& Date.now() - cachedContext.fetchedAt < PR_CONTEXT_CACHE_TTL_MS
|
||||
&& (cachedContext.includeCheckDetails || !includeCheckDetails)) {
|
||||
return res.json(cachedContext.data);
|
||||
}
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (data) => {
|
||||
if (data && data.pr) {
|
||||
if (typeof data.fetchedAt !== 'number') {
|
||||
data.fetchedAt = Date.now();
|
||||
}
|
||||
prContextCache.delete(contextCacheKey);
|
||||
prContextCache.set(contextCacheKey, { data, includeCheckDetails, fetchedAt: data.fetchedAt });
|
||||
if (prContextCache.size > PR_CONTEXT_CACHE_MAX_ENTRIES) {
|
||||
const oldest = prContextCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
prContextCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, pr: null });
|
||||
@@ -1564,7 +1694,7 @@ export function registerGitHubRoutes(app) {
|
||||
if (sha) {
|
||||
try {
|
||||
const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 });
|
||||
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
||||
const checkRuns = dedupeCheckRuns(Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []);
|
||||
if (checkRuns.length > 0) {
|
||||
const parsedJobs = new Map();
|
||||
const parsedAnnotations = new Map();
|
||||
@@ -1665,6 +1795,7 @@ export function registerGitHubRoutes(app) {
|
||||
jobId: picked.id,
|
||||
url: picked.html_url,
|
||||
name: picked.name,
|
||||
workflowName: picked.workflow_name || undefined,
|
||||
conclusion: picked.conclusion,
|
||||
steps: Array.isArray(picked.steps)
|
||||
? picked.steps.map((s) => ({
|
||||
@@ -1686,6 +1817,8 @@ export function registerGitHubRoutes(app) {
|
||||
return {
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
startedAt: run.started_at || undefined,
|
||||
completedAt: run.completed_at || undefined,
|
||||
app: run.app
|
||||
? {
|
||||
name: run.app.name || undefined,
|
||||
@@ -1718,27 +1851,7 @@ export function registerGitHubRoutes(app) {
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
for (const run of checkRuns) {
|
||||
const status = run?.status;
|
||||
const conclusion = run?.conclusion;
|
||||
if (status === 'queued' || status === 'in_progress') {
|
||||
counts.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (!conclusion) {
|
||||
counts.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||
counts.success += 1;
|
||||
} else {
|
||||
counts.failure += 1;
|
||||
}
|
||||
}
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
checks = { state, total, ...counts };
|
||||
checks = summarizeCheckRuns(checkRuns);
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall back
|
||||
@@ -1747,15 +1860,7 @@ export function registerGitHubRoutes(app) {
|
||||
try {
|
||||
const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha });
|
||||
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
statuses.forEach((s) => {
|
||||
if (s.state === 'success') counts.success += 1;
|
||||
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
||||
else if (s.state === 'pending') counts.pending += 1;
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
checks = { state, total, ...counts };
|
||||
checks = summarizeCombinedStatuses(statuses);
|
||||
} catch {
|
||||
checks = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Sanitize environment objects inherited by user-facing child processes.
|
||||
*
|
||||
* Linux AppImage runtimes export `ARGV0` as the AppImage path before launching
|
||||
* the packaged app. zsh treats an exported `ARGV0` as the argv[0] for every
|
||||
* external command it spawns, which corrupts Python venv detection and any
|
||||
* other program that reads argv[0]/$0 while leaving `/proc/self/exe` correct.
|
||||
*
|
||||
* See openchamber/openchamber#2588 and pingdotgg/t3code#2509.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const LINUX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
|
||||
|
||||
/**
|
||||
* Remove AppImage `ARGV0` from a mutable env object (or `process.env`).
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined> | null | undefined} env
|
||||
* @returns {typeof env}
|
||||
*/
|
||||
export function stripAppImageArgv0Leak(env) {
|
||||
if (!env || typeof env !== 'object') return env;
|
||||
if (Object.prototype.hasOwnProperty.call(env, 'ARGV0')) {
|
||||
delete env.ARGV0;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear AppImage `ARGV0` from this process.
|
||||
*
|
||||
* Bun keeps a native environ that `bun-pty` inherits even after
|
||||
* `delete process.env.ARGV0`. On Linux under Bun we also call libc `unsetenv`.
|
||||
*/
|
||||
export function clearAppImageArgv0FromProcessEnv() {
|
||||
delete process.env.ARGV0;
|
||||
if (process.platform !== 'linux' || typeof Bun === 'undefined') return;
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { dlopen } = require('bun:ffi');
|
||||
const libc = dlopen('libc.so.6', {
|
||||
unsetenv: { args: ['cstring'], returns: 'i32' },
|
||||
});
|
||||
libc.symbols.unsetenv(Buffer.from('ARGV0\0'));
|
||||
} catch {
|
||||
// Node/Electron and environments without bun:ffi rely on explicit child envs.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Linux PTY launch that drops native `ARGV0` before the shell starts.
|
||||
*
|
||||
* `bun-pty` merges the OS environ into the child, so deleting `ARGV0` from the
|
||||
* JS env object alone is not enough. Wrapping with `env -u ARGV0` unsets it
|
||||
* before execing the real shell. No-op on non-Linux platforms.
|
||||
*
|
||||
* @param {string} executable
|
||||
* @param {string[]} args
|
||||
* @returns {{ executable: string, args: string[] }}
|
||||
*/
|
||||
export function resolveLinuxPtyLaunch(executable, args = []) {
|
||||
if (process.platform !== 'linux') {
|
||||
return { executable, args };
|
||||
}
|
||||
const envBinary = LINUX_ENV_BINARIES.find((candidate) => existsSync(candidate));
|
||||
if (!envBinary) {
|
||||
return { executable, args };
|
||||
}
|
||||
return {
|
||||
executable: envBinary,
|
||||
args: ['-u', 'ARGV0', executable, ...args],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clearAppImageArgv0FromProcessEnv,
|
||||
resolveLinuxPtyLaunch,
|
||||
stripAppImageArgv0Leak,
|
||||
} from './inherited-env.js';
|
||||
|
||||
describe('stripAppImageArgv0Leak', () => {
|
||||
it('removes ARGV0 from a child env object', () => {
|
||||
const env = {
|
||||
PATH: '/usr/bin',
|
||||
ARGV0: '/path/to/OpenChamber-1.17.2-linux-x86_64.AppImage',
|
||||
SHELL: '/bin/zsh',
|
||||
};
|
||||
|
||||
expect(stripAppImageArgv0Leak(env)).toBe(env);
|
||||
expect(env).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op when ARGV0 is absent', () => {
|
||||
const env = { PATH: '/usr/bin', SHELL: '/bin/bash' };
|
||||
stripAppImageArgv0Leak(env);
|
||||
expect(env).toEqual({ PATH: '/usr/bin', SHELL: '/bin/bash' });
|
||||
});
|
||||
|
||||
it('tolerates nullish env values', () => {
|
||||
expect(stripAppImageArgv0Leak(null)).toBeNull();
|
||||
expect(stripAppImageArgv0Leak(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAppImageArgv0FromProcessEnv', () => {
|
||||
it('removes ARGV0 from process.env', () => {
|
||||
const previous = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
|
||||
try {
|
||||
clearAppImageArgv0FromProcessEnv();
|
||||
expect(process.env.ARGV0).toBeUndefined();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLinuxPtyLaunch', () => {
|
||||
it('wraps the shell with env -u ARGV0 on Linux', () => {
|
||||
if (process.platform !== 'linux') return;
|
||||
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
|
||||
executable: expect.stringMatching(/\/env$/),
|
||||
args: ['-u', 'ARGV0', '/bin/zsh', '-l'],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-Linux launches unchanged', () => {
|
||||
if (process.platform === 'linux') return;
|
||||
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
|
||||
executable: '/bin/zsh',
|
||||
args: ['-l'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,11 @@ device token of a server sees the same badge.
|
||||
|
||||
Server (`apns-runtime.js`):
|
||||
- `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT`
|
||||
(`sandbox` default / `production`). The signing keypair is auto-generated — nothing to set.
|
||||
(optional override forcing every send to `sandbox` or `production`; normally unset — each
|
||||
token is delivered to the environment it registered with: the iOS shell reads the
|
||||
`aps-environment` entitlement from the embedded provisioning profile and reports it at
|
||||
registration, so Xcode dev builds go to sandbox and TestFlight/App Store to production).
|
||||
The signing keypair is auto-generated — nothing to set.
|
||||
- Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8`
|
||||
(or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ This module provides notification message preparation utilities for the web serv
|
||||
- Returned API:
|
||||
- `maybeSendPushForTrigger(payload)`
|
||||
- Owns:
|
||||
- completion/error/question/permission trigger routing
|
||||
- completion/error/question/permission trigger routing; permission suppression consults the authoritative permission-auto-accept runtime
|
||||
- session parent cache for subtask suppression
|
||||
- template resolution and fallback behavior
|
||||
- native notification fanout and web push payload fanout
|
||||
@@ -67,12 +67,12 @@ This module provides notification message preparation utilities for the web serv
|
||||
### APNs runtime API (apns-runtime.js)
|
||||
- `createApnsRuntime(dependencies)`: creates runtime for native iOS APNs push and device-token state. Dependencies: `fsPromises`, `path`, `crypto`, `http2`, `APNS_TOKENS_FILE_PATH`, `readSettingsFromDiskMigrated`, `writeSettingsToDisk` (persists the auto-generated relay signing keypair).
|
||||
- Returned API:
|
||||
- `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`).
|
||||
- `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent, platform, environment)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`). `environment` is the APNs environment the token was minted for (`sandbox` for Xcode/dev-signed installs, `production` otherwise — reported by the client at registration); delivery groups tokens by it.
|
||||
- `removeApnsToken(uiSessionToken, deviceToken)`
|
||||
- `removeApnsTokenFromAllSessions(deviceToken)`
|
||||
- `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`.
|
||||
- `resolveApnsConfig()`
|
||||
- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production`).
|
||||
- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (optional override forcing every send to `sandbox` or `production`; when unset, each token is delivered to the environment it registered with, defaulting to `production` for tokens without one).
|
||||
|
||||
### Emitter runtime API (emitter-runtime.js)
|
||||
- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
|
||||
// generic, model-based text (no session content) — see APNS.md.
|
||||
|
||||
import {
|
||||
getOrCreateRelaySigningKeypair,
|
||||
signRelayMessage as signRelayMessageShared,
|
||||
} from '../relay/signing-key.js';
|
||||
|
||||
const APNS_TOKENS_VERSION = 1;
|
||||
const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
|
||||
const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
|
||||
@@ -37,6 +42,8 @@ export const createApnsRuntime = (deps) => {
|
||||
APNS_TOKENS_FILE_PATH,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
// Strict settings reader gating identity regeneration (see signing-key.js).
|
||||
readSettingsStrict,
|
||||
} = deps;
|
||||
|
||||
let persistLock = Promise.resolve();
|
||||
@@ -51,27 +58,15 @@ export const createApnsRuntime = (deps) => {
|
||||
// device token alone can't be used to push. Zero-config: the keypair generates on first use.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Key access lives in lib/relay/signing-key.js now (shared with the private
|
||||
// relay identity — same keypair, same storage, same serverId derivation).
|
||||
const getOrCreateRelayKeypair = async () => {
|
||||
if (cachedRelayKey) return cachedRelayKey;
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.relaySigningKey;
|
||||
if (existing && existing.privateJwk && existing.publicJwk) {
|
||||
cachedRelayKey = {
|
||||
privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }),
|
||||
publicJwk: existing.publicJwk,
|
||||
};
|
||||
return cachedRelayKey;
|
||||
}
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const privateJwk = privateKey.export({ format: 'jwk' });
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } });
|
||||
cachedRelayKey = { privateKey, publicJwk };
|
||||
cachedRelayKey = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
|
||||
return cachedRelayKey;
|
||||
};
|
||||
|
||||
const signRelayMessage = (privateKey, message) =>
|
||||
crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
const signRelayMessage = (privateKey, message) => signRelayMessageShared({ crypto }, privateKey, message);
|
||||
|
||||
// Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash).
|
||||
const relayPublicJwk = (publicJwk) => ({
|
||||
@@ -154,6 +149,11 @@ export const createApnsRuntime = (deps) => {
|
||||
userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
|
||||
// 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
|
||||
platform: entry.platform === 'android' ? 'android' : 'ios',
|
||||
// APNs delivery environment for this token. Xcode/dev-signed installs produce
|
||||
// sandbox tokens, TestFlight/App Store produce production ones; the client reports
|
||||
// which at registration. Older entries without one default to production (matches
|
||||
// released builds).
|
||||
environment: entry.environment === 'sandbox' ? 'sandbox' : 'production',
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
@@ -163,10 +163,13 @@ export const createApnsRuntime = (deps) => {
|
||||
// was the only registrant before Android/FCM existed.
|
||||
const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
|
||||
|
||||
const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => {
|
||||
const normalizeEnvironment = (environment) => (environment === 'sandbox' ? 'sandbox' : 'production');
|
||||
|
||||
const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, environment) => {
|
||||
if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
|
||||
const token = deviceToken.trim();
|
||||
const tokenPlatform = normalizePlatform(platform);
|
||||
const tokenEnvironment = normalizeEnvironment(environment);
|
||||
const now = Date.now();
|
||||
|
||||
await persistTokenUpdate((current) => {
|
||||
@@ -179,6 +182,7 @@ export const createApnsRuntime = (deps) => {
|
||||
lastSeenAt: now,
|
||||
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
||||
platform: tokenPlatform,
|
||||
environment: tokenEnvironment,
|
||||
});
|
||||
tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
|
||||
return { version: APNS_TOKENS_VERSION, tokensBySession };
|
||||
@@ -261,7 +265,9 @@ export const createApnsRuntime = (deps) => {
|
||||
teamId,
|
||||
p8,
|
||||
bundleId: bundleId || DEFAULT_BUNDLE_ID,
|
||||
environment: environment === 'production' ? 'production' : 'sandbox',
|
||||
// Explicit env/settings value forces every send to that environment; when unset (null),
|
||||
// each token is delivered to the environment it registered with.
|
||||
environment: environment === 'sandbox' ? 'sandbox' : environment === 'production' ? 'production' : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -375,17 +381,17 @@ export const createApnsRuntime = (deps) => {
|
||||
const resolveRelayConfig = () => {
|
||||
if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
|
||||
const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
|
||||
const override = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase();
|
||||
return {
|
||||
url,
|
||||
registerUrl: url.replace(/\/send$/, '/register-token'),
|
||||
environment:
|
||||
(trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
|
||||
? 'production'
|
||||
: 'sandbox',
|
||||
// Explicit OPENCHAMBER_APNS_ENVIRONMENT forces every send to that environment; when
|
||||
// unset (null), each token is delivered to the environment it registered with.
|
||||
environment: override === 'sandbox' ? 'sandbox' : override === 'production' ? 'production' : null,
|
||||
};
|
||||
};
|
||||
|
||||
const sendViaRelay = async (deviceTokens, payload, relay) => {
|
||||
const sendViaRelay = async (deviceTokens, payload, relay, environment) => {
|
||||
const tokens = deviceTokens.slice(0, 100);
|
||||
const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
|
||||
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
||||
@@ -398,7 +404,7 @@ export const createApnsRuntime = (deps) => {
|
||||
body: typeof payload?.body === 'string' ? payload.body : '',
|
||||
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
|
||||
collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined,
|
||||
env: relay.environment,
|
||||
env: environment,
|
||||
data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
|
||||
publicKeyJwk: relayPublicJwk(publicJwk),
|
||||
ts,
|
||||
@@ -426,7 +432,7 @@ export const createApnsRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendViaDirectApns = async (deviceTokens, payload) => {
|
||||
const sendViaDirectApns = async (tokenGroups, payload) => {
|
||||
const config = await resolveApnsConfig();
|
||||
if (!config) {
|
||||
if (!warnedUnconfigured) {
|
||||
@@ -438,39 +444,45 @@ export const createApnsRuntime = (deps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX;
|
||||
const jwt = getJwt(config);
|
||||
const body = buildBody(payload);
|
||||
const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined };
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = http2.connect(host);
|
||||
} catch (error) {
|
||||
console.warn('[APNs] connect failed:', error?.message ?? error);
|
||||
return;
|
||||
}
|
||||
// One HTTP/2 session per APNs environment; a sandbox token sent to the production host
|
||||
// (or vice versa) gets BadDeviceToken and would be wrongly dropped as dead.
|
||||
for (const [environment, deviceTokens] of tokenGroups) {
|
||||
const effectiveEnvironment = config.environment ?? environment;
|
||||
const host = effectiveEnvironment === 'sandbox' ? APNS_HOST_SANDBOX : APNS_HOST_PRODUCTION;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
client.on('error', (error) => {
|
||||
console.warn('[APNs] session error:', error?.message ?? error);
|
||||
finish();
|
||||
let client;
|
||||
try {
|
||||
client = http2.connect(host);
|
||||
} catch (error) {
|
||||
console.warn('[APNs] connect failed:', error?.message ?? error);
|
||||
continue;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
client.on('error', (error) => {
|
||||
console.warn('[APNs] session error:', error?.message ?? error);
|
||||
finish();
|
||||
});
|
||||
Promise.all(
|
||||
deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)),
|
||||
).finally(finish);
|
||||
});
|
||||
Promise.all(
|
||||
deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)),
|
||||
).finally(finish);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
|
||||
@@ -480,24 +492,30 @@ export const createApnsRuntime = (deps) => {
|
||||
// capacitor.config) — so there is no notification when the app is active, with no race.
|
||||
const sendApnsToAllUiSessions = async (payload, _options = {}) => {
|
||||
const store = await readTokensFromDisk();
|
||||
const deviceTokens = [];
|
||||
// Tokens are grouped by their registered APNs environment so each batch goes to the
|
||||
// endpoint that actually knows the token (Xcode builds → sandbox, TestFlight/App Store
|
||||
// → production). Mixing them gets BadDeviceToken and the token wrongly dropped as dead.
|
||||
const tokensByEnvironment = new Map();
|
||||
const seen = new Set();
|
||||
for (const record of Object.values(store.tokensBySession || {})) {
|
||||
for (const entry of normalizeTokens(record)) {
|
||||
if (!seen.has(entry.deviceToken)) {
|
||||
seen.add(entry.deviceToken);
|
||||
deviceTokens.push(entry.deviceToken);
|
||||
}
|
||||
if (seen.has(entry.deviceToken)) continue;
|
||||
seen.add(entry.deviceToken);
|
||||
const group = tokensByEnvironment.get(entry.environment) || [];
|
||||
group.push(entry.deviceToken);
|
||||
tokensByEnvironment.set(entry.environment, group);
|
||||
}
|
||||
}
|
||||
if (deviceTokens.length === 0) return;
|
||||
if (seen.size === 0) return;
|
||||
|
||||
const relay = resolveRelayConfig();
|
||||
if (relay) {
|
||||
await sendViaRelay(deviceTokens, payload, relay);
|
||||
for (const [environment, deviceTokens] of tokensByEnvironment) {
|
||||
await sendViaRelay(deviceTokens, payload, relay, relay.environment ?? environment);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await sendViaDirectApns(deviceTokens, payload);
|
||||
await sendViaDirectApns(tokensByEnvironment, payload);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -69,6 +69,7 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.OPENCHAMBER_PUSH_RELAY_URL;
|
||||
delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
|
||||
delete process.env.OPENCHAMBER_APNS_ENVIRONMENT;
|
||||
});
|
||||
|
||||
describe('apns runtime relay mode (default)', () => {
|
||||
@@ -116,6 +117,7 @@ describe('apns runtime relay mode (default)', () => {
|
||||
expect(sent.title).toBe('Agent response is ready');
|
||||
expect(sent.body).toBe('My session');
|
||||
expect(sent.badge).toBe(3);
|
||||
expect(sent.env).toBe('production');
|
||||
expect(sent.data).toEqual({ sessionId: 'sess1' });
|
||||
expect(sent.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
|
||||
const sendMessage = `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.title}`;
|
||||
@@ -144,6 +146,40 @@ describe('apns runtime relay mode (default)', () => {
|
||||
expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors an explicit sandbox environment override for every token', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
||||
process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox';
|
||||
|
||||
const runtime = createApnsRuntime(makeDeps());
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenA', undefined, 'ios', 'production');
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
||||
|
||||
const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
|
||||
expect(sent.env).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('routes each token to its registered environment (dev build sandbox, release production)', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
||||
|
||||
const runtime = createApnsRuntime(makeDeps());
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenXcode', undefined, 'ios', 'sandbox');
|
||||
await runtime.addOrUpdateApnsToken('s2', 'tokenStore', undefined, 'ios', 'production');
|
||||
await runtime.addOrUpdateApnsToken('s3', 'tokenLegacy'); // no environment → production
|
||||
|
||||
fetchMock.mockClear();
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
||||
|
||||
const sends = fetchMock.mock.calls.filter(isSend).map(([, init]) => JSON.parse(init.body));
|
||||
expect(sends).toHaveLength(2);
|
||||
const byEnv = Object.fromEntries(sends.map((s) => [s.env, new Set(s.tokens)]));
|
||||
expect(byEnv.sandbox).toEqual(new Set(['tokenXcode']));
|
||||
expect(byEnv.production).toEqual(new Set(['tokenStore', 'tokenLegacy']));
|
||||
});
|
||||
|
||||
it('no-ops (no relay call) when no tokens are registered', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
@@ -154,6 +190,56 @@ describe('apns runtime relay mode (default)', () => {
|
||||
});
|
||||
|
||||
describe('apns runtime direct fallback (relay disabled)', () => {
|
||||
it('leaves direct APNs environment unset without an explicit override (per-token routing)', async () => {
|
||||
const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG;
|
||||
const runtime = createApnsRuntime(
|
||||
makeDeps({ readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }),
|
||||
);
|
||||
|
||||
await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: null });
|
||||
});
|
||||
|
||||
it('sends each token to the APNs host of its registered environment', async () => {
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
|
||||
const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG;
|
||||
const hosts = [];
|
||||
const http2 = {
|
||||
connect: (host) => {
|
||||
const targeted = [];
|
||||
hosts.push({ host, targeted });
|
||||
return {
|
||||
on: () => {},
|
||||
close: () => {},
|
||||
request: (headers) => {
|
||||
targeted.push(String(headers[':path']).replace('/3/device/', ''));
|
||||
const listeners = {};
|
||||
const req = {
|
||||
on: (event, cb) => { listeners[event] = cb; return req; },
|
||||
setEncoding: () => req,
|
||||
end: () => {
|
||||
queueMicrotask(() => {
|
||||
listeners.response?.({ ':status': '200' });
|
||||
listeners.end?.();
|
||||
});
|
||||
},
|
||||
};
|
||||
return req;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const runtime = createApnsRuntime(
|
||||
makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }),
|
||||
);
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenXcode', undefined, 'ios', 'sandbox');
|
||||
await runtime.addOrUpdateApnsToken('s2', 'tokenStore', undefined, 'ios', 'production');
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' });
|
||||
|
||||
const byHost = Object.fromEntries(hosts.map(({ host, targeted }) => [host, targeted]));
|
||||
expect(byHost['https://api.sandbox.push.apple.com']).toEqual(['tokenXcode']);
|
||||
expect(byHost['https://api.push.apple.com']).toEqual(['tokenStore']);
|
||||
});
|
||||
|
||||
it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => {
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
|
||||
const targeted = [];
|
||||
|
||||
@@ -162,8 +162,11 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
}
|
||||
|
||||
const platform = req.body?.platform === 'android' ? 'android' : 'ios';
|
||||
// APNs environment the token belongs to: Xcode/dev-signed installs report 'sandbox',
|
||||
// TestFlight/App Store report 'production'. Absent (older clients, Android) → production.
|
||||
const environment = req.body?.environment === 'sandbox' ? 'sandbox' : 'production';
|
||||
if (typeof addOrUpdateApnsToken === 'function') {
|
||||
await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform);
|
||||
await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform, environment);
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
} = deps;
|
||||
let getIsSessionAutoAccepting = deps.getIsSessionAutoAccepting;
|
||||
const setGetIsSessionAutoAccepting = (resolver) => {
|
||||
getIsSessionAutoAccepting = typeof resolver === 'function' ? resolver : undefined;
|
||||
};
|
||||
|
||||
// App-icon badge for native push: the set of DISTINCT collapse-ids (the push
|
||||
// `tag`, e.g. `ready-<sessionId>` / `permission-<requestKey>`) we've sent since
|
||||
@@ -50,6 +54,9 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
error: 'Agent hit an error',
|
||||
question: 'Agent needs your input',
|
||||
permission: 'Agent needs permission',
|
||||
goal_complete: 'Goal complete',
|
||||
goal_blocked: 'Goal blocked',
|
||||
goal_budget: 'Goal reached its token budget',
|
||||
};
|
||||
|
||||
const toApnsGenericPayload = (payload) => {
|
||||
@@ -103,6 +110,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
const pushPermissionDebounceTimers = new Map();
|
||||
const notifiedPermissionRequests = new Set();
|
||||
const lastReadyNotificationAt = new Map();
|
||||
const lastErrorNotificationAt = new Map();
|
||||
|
||||
const sessionParentIdCache = new Map();
|
||||
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
|
||||
@@ -129,24 +137,26 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
return `/?session=${encodeURIComponent(sessionId)}`;
|
||||
};
|
||||
|
||||
const getCachedSessionParentId = (sessionId) => {
|
||||
const entry = sessionParentIdCache.get(sessionId);
|
||||
const getSessionParentCacheKey = (sessionId, directory) => `${directory || ''}\0${sessionId}`;
|
||||
|
||||
const getCachedSessionParentId = (sessionId, directory) => {
|
||||
const cacheKey = getSessionParentCacheKey(sessionId, directory);
|
||||
const entry = sessionParentIdCache.get(cacheKey);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) {
|
||||
sessionParentIdCache.delete(sessionId);
|
||||
sessionParentIdCache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
return entry.parentID;
|
||||
};
|
||||
|
||||
const setCachedSessionParentId = (sessionId, parentID) => {
|
||||
if (!parentID) return;
|
||||
sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() });
|
||||
const setCachedSessionParentId = (sessionId, directory, parentID) => {
|
||||
sessionParentIdCache.set(getSessionParentCacheKey(sessionId, directory), { parentID: parentID ?? null, at: Date.now() });
|
||||
};
|
||||
|
||||
const getParentIdFromPayload = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
if (payload.type !== 'session.created' && payload.type !== 'session.updated') return null;
|
||||
if (!payload || typeof payload !== 'object') return undefined;
|
||||
if (payload.type !== 'session.created' && payload.type !== 'session.updated') return undefined;
|
||||
const parentID = payload.properties?.info?.parentID ?? null;
|
||||
return typeof parentID === 'string' && parentID.length > 0 ? parentID : null;
|
||||
};
|
||||
@@ -154,20 +164,22 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
const maybeCacheSessionParentFromPayload = (payload) => {
|
||||
const sessionId = extractSessionIdFromPayload(payload);
|
||||
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
||||
const directory = extractDirectoryFromPayload(payload);
|
||||
const parentID = getParentIdFromPayload(payload);
|
||||
if (parentID) {
|
||||
setCachedSessionParentId(sessionId, parentID);
|
||||
}
|
||||
if (parentID === undefined) return;
|
||||
setCachedSessionParentId(sessionId, directory, parentID);
|
||||
};
|
||||
|
||||
const fetchSessionParentId = async (sessionId) => {
|
||||
const fetchSessionParentId = async (sessionId, directory) => {
|
||||
if (!sessionId) return undefined;
|
||||
|
||||
const cached = getCachedSessionParentId(sessionId);
|
||||
const cached = getCachedSessionParentId(sessionId, directory);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(buildOpenCodeUrl('/session', ''), {
|
||||
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
||||
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -178,21 +190,15 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
const sessions = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data?.data)
|
||||
? data.data
|
||||
: null;
|
||||
if (!sessions) {
|
||||
const session = await response.json().catch(() => null);
|
||||
if (!session || typeof session !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = sessions.find((session) => session && typeof session === 'object' && session.id === sessionId);
|
||||
const parentID = match?.parentID ?? null;
|
||||
setCachedSessionParentId(sessionId, parentID);
|
||||
const parentID = typeof session.parentID === 'string' && session.parentID.length > 0
|
||||
? session.parentID
|
||||
: null;
|
||||
setCachedSessionParentId(sessionId, directory, parentID);
|
||||
return parentID;
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -201,14 +207,14 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
|
||||
// Mirrors client-side autoRespondsPermission: a session auto-accepts if it
|
||||
// OR any ancestor is flagged. Walks the parent chain via fetchSessionParentId.
|
||||
const isSessionAutoAccepting = async (sessionId) => {
|
||||
const isSessionAutoAccepting = async (sessionId, directory) => {
|
||||
if (!sessionId || autoAcceptingSessions.size === 0) return false;
|
||||
let current = sessionId;
|
||||
const seen = new Set();
|
||||
while (current && !seen.has(current)) {
|
||||
if (autoAcceptingSessions.has(current)) return true;
|
||||
seen.add(current);
|
||||
const parent = await fetchSessionParentId(current);
|
||||
const parent = await fetchSessionParentId(current, directory);
|
||||
if (!parent) return false;
|
||||
current = parent;
|
||||
}
|
||||
@@ -272,6 +278,28 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// A session with an ACTIVE goal suppresses per-turn ready notifications;
|
||||
// the session-goal runtime sends its own notification when the goal
|
||||
// settles. Fetch failures fall through to normal notification behavior.
|
||||
const hasActiveSessionGoal = async (sessionId, directory) => {
|
||||
if (!sessionId) return false;
|
||||
try {
|
||||
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
||||
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const session = await response.json().catch(() => null);
|
||||
const goal = session?.metadata?.openchamber?.goal;
|
||||
return Boolean(goal && typeof goal === 'object' && goal.status === 'active');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const maybeSendPushForTrigger = async (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return;
|
||||
@@ -281,6 +309,27 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
|
||||
const sessionId = extractSessionIdFromPayload(payload);
|
||||
const notificationDirectory = extractDirectoryFromPayload(payload);
|
||||
if ((payload.type === 'session.idle' || payload.type === 'session.error') && sessionId) {
|
||||
const error = payload.properties?.error;
|
||||
const errorText = typeof error?.message === 'string'
|
||||
? error.message
|
||||
: typeof error === 'string' ? error : '';
|
||||
await maybeSendPushForTrigger({
|
||||
...payload,
|
||||
type: 'message.updated',
|
||||
properties: {
|
||||
...payload.properties,
|
||||
info: {
|
||||
sessionID: sessionId,
|
||||
role: 'assistant',
|
||||
finish: payload.type === 'session.error' ? 'error' : 'stop',
|
||||
...(errorText ? { parts: [{ type: 'text', text: errorText }] } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'message.updated') {
|
||||
const info = payload.properties?.info;
|
||||
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
|
||||
@@ -290,9 +339,9 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
const parentIDFromPayload = getParentIdFromPayload(payload);
|
||||
const parentID = parentIDFromPayload
|
||||
? parentIDFromPayload
|
||||
: await fetchSessionParentId(sessionId);
|
||||
: await fetchSessionParentId(sessionId, notificationDirectory);
|
||||
|
||||
if (parentID) {
|
||||
if (parentID !== null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -301,6 +350,13 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// While a goal drives the session, per-turn "ready" notifications are
|
||||
// noise produced by the goal loop itself — the goal's own settle
|
||||
// notification (complete/blocked/budget) is the final word instead.
|
||||
if (await hasActiveSessionGoal(sessionId, notificationDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
||||
return;
|
||||
}
|
||||
@@ -318,7 +374,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
|
||||
try {
|
||||
const templates = settings.notificationTemplates || {};
|
||||
const isSubtask = await fetchSessionParentId(sessionId);
|
||||
const isSubtask = await fetchSessionParentId(sessionId, notificationDirectory);
|
||||
const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false
|
||||
? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' })
|
||||
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
||||
@@ -379,6 +435,11 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
const settings = await readSettingsFromDisk();
|
||||
if (settings.notifyOnError === false) return;
|
||||
|
||||
const now = Date.now();
|
||||
const lastAt = lastErrorNotificationAt.get(sessionId) ?? 0;
|
||||
if (now - lastAt < PUSH_READY_COOLDOWN_MS) return;
|
||||
lastErrorNotificationAt.set(sessionId, now);
|
||||
|
||||
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
||||
return;
|
||||
}
|
||||
@@ -552,7 +613,8 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
// Client may be in Permission Auto-Accept for this session (or any
|
||||
// ancestor). Skip the whole notification path — the client responds
|
||||
// directly and the user has opted out of approval prompts.
|
||||
if (await isSessionAutoAccepting(sessionId)) {
|
||||
if (await (getIsSessionAutoAccepting?.(sessionId, notificationDirectory)
|
||||
?? isSessionAutoAccepting(sessionId, notificationDirectory))) {
|
||||
if (requestKey) notifiedPermissionRequests.add(requestKey);
|
||||
return;
|
||||
}
|
||||
@@ -565,7 +627,8 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
const timer = setTimeout(async () => {
|
||||
pushPermissionDebounceTimers.delete(sessionId);
|
||||
|
||||
if (await isSessionAutoAccepting(sessionId)) {
|
||||
if (await (getIsSessionAutoAccepting?.(sessionId, notificationDirectory)
|
||||
?? isSessionAutoAccepting(sessionId, notificationDirectory))) {
|
||||
if (requestKey) notifiedPermissionRequests.add(requestKey);
|
||||
return;
|
||||
}
|
||||
@@ -644,10 +707,49 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Goal settle push: same fanout as the trigger paths (web-push with the
|
||||
// full text; APNs with the generic per-type title and the session name as
|
||||
// body, so the relay never sees content).
|
||||
const sendGoalSettlePush = async ({ sessionId, directory, status, title, body }) => {
|
||||
let sessionName = '';
|
||||
try {
|
||||
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
||||
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (response.ok) {
|
||||
const session = await response.json().catch(() => null);
|
||||
if (typeof session?.title === 'string') sessionName = session.title.trim();
|
||||
}
|
||||
} catch {
|
||||
// Session name is presentation sugar for the mobile push — never block on it.
|
||||
}
|
||||
const type = status === 'complete' ? 'goal_complete' : (status === 'budgetLimited' ? 'goal_budget' : 'goal_blocked');
|
||||
await fanoutPush(
|
||||
{
|
||||
title,
|
||||
body,
|
||||
tag: `goal-${sessionId}`,
|
||||
data: {
|
||||
url: buildSessionDeepLinkUrl(sessionId),
|
||||
sessionId,
|
||||
sessionName,
|
||||
type,
|
||||
},
|
||||
},
|
||||
{ requireNoSse: true },
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
maybeSendPushForTrigger,
|
||||
setAutoAcceptSession,
|
||||
setGetIsWindowFocused,
|
||||
setGetIsSessionAutoAccepting,
|
||||
clearPendingPushBadge,
|
||||
sendGoalSettlePush,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# OpenChamber Control Service
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the typed control contract shared by the OpenChamber CLI and
|
||||
the managed OpenCode `openchamber` tool. Both adapters delegate to
|
||||
`createOpenChamberControlService()`; neither adapter may call or spawn the
|
||||
other.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- `service.js` validates and executes the fixed project, model, session, and
|
||||
scheduled-task action allowlist. `actions.js` marks CLI-only actions with
|
||||
`agentExposed: false` (currently `schedule.status`); the agent tool consumes
|
||||
the filtered `OPENCHAMBER_AGENT_TOOL_*` exports. `schedule.toggle` requires
|
||||
the `disabled` boolean and replaces separate enable/disable actions;
|
||||
`schedule.list` also returns scheduler status as `scheduler`.
|
||||
- `routes.js` is the authenticated CLI HTTP adapter. It forwards one action,
|
||||
preserves service status and partial-result details, and propagates request
|
||||
cancellation.
|
||||
- `../agent-tool/runtime.js` is the managed-tool adapter. It wraps service
|
||||
results in the versioned native-tool envelope and uses a separate ephemeral
|
||||
loopback credential.
|
||||
- `../openchamber-sessions/routes.js` and `../scheduled-tasks/service.js` own
|
||||
their domain operations and are composed into this service.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Session status and messages come from official directory-scoped OpenCode
|
||||
APIs. Message output includes only ordered `text` parts.
|
||||
- Wait never treats an initial idle response as completion after dispatch. It
|
||||
requires observed activity or a newly completed assistant message.
|
||||
- Timeout and cancellation are failures, never authoritative idle results.
|
||||
- Validation that protects side effects runs before session creation or
|
||||
dispatch. An explicitly requested model, agent, or variant is checked against
|
||||
the directory's own OpenCode agent and provider lists before any session,
|
||||
worktree, or goal is created, because `prompt_async` accepts an unusable
|
||||
selection and then fails only on the event stream. A failed or empty lookup
|
||||
never turns a valid selection into a rejection.
|
||||
- `promptDispatched` reports an observed dispatch, never an accepted request.
|
||||
After `prompt_async` the service confirms a new user message reached the
|
||||
session; when it does not, the result reports `promptDispatched: false` with
|
||||
`promptError` instead of claiming success.
|
||||
- Send and fork dispatches without an explicit model/agent/variant reuse the
|
||||
target session's last user-message selection before falling back to the
|
||||
configured defaults; only session creation resolves defaults directly.
|
||||
- Usage errors name the missing or conflicting input so CLI and agent-tool
|
||||
callers can correct an invalid request without an upfront usage manual.
|
||||
- Explicit `projectId` or `directory` scope takes precedence over the managed
|
||||
tool's current-session directory fallback; the fallback never creates a
|
||||
conflicting second scope.
|
||||
- One failed directory status lookup produces `unknown` for only that
|
||||
directory and does not erase other session results.
|
||||
- Destructive session/worktree deletion and project-path registration are not
|
||||
part of the action contract.
|
||||
@@ -0,0 +1,28 @@
|
||||
export const OPENCHAMBER_CONTROL_ACTION_DEFINITIONS = Object.freeze([
|
||||
{ action: 'projects.list', title: 'List configured projects', description: 'List configured projects; no parameters' },
|
||||
{ action: 'models.list', title: 'Show model preferences', description: 'Show default, favorite, and recent model preferences; no parameters' },
|
||||
{ action: 'session.list', title: 'List sessions', description: 'List sessions; optional directory, limit (default 10), all, or withStatus' },
|
||||
{ action: 'session.create', title: 'Create a session', description: 'Create a session in the current directory by default; prompt is optional' },
|
||||
{ action: 'session.send', title: 'Send a prompt', description: 'Send a new prompt to sessionId; scope with projectId or directory' },
|
||||
{ action: 'session.fork', title: 'Fork a session', description: 'Fork sessionId; messageId selects the boundary; prompt is optional' },
|
||||
{ action: 'session.status', title: 'Check session status', description: 'Check sessionId status; directory defaults to the current session' },
|
||||
{ action: 'session.messages', title: 'Read session messages', description: 'Read text-only messages and current sessionStatus for sessionId; directory and limit 10 are defaults' },
|
||||
{ action: 'schedule.status', title: 'Check scheduler status', description: 'Check scheduler status; no parameters', agentExposed: false },
|
||||
{ action: 'schedule.list', title: 'List scheduled tasks', description: 'List tasks and scheduler status; scope with projectId or directory' },
|
||||
{ action: 'schedule.create', title: 'Create a scheduled task', description: 'Create task; requires name, prompt, model, and one schedule selector' },
|
||||
{ action: 'schedule.run', title: 'Run a scheduled task', description: 'Run taskId; scope with projectId or directory' },
|
||||
{ action: 'schedule.delete', title: 'Delete a scheduled task', description: 'Delete taskId; scope with projectId or directory' },
|
||||
{ action: 'schedule.toggle', title: 'Enable or disable a scheduled task', description: 'Enable or disable taskId; requires the disabled boolean' },
|
||||
]);
|
||||
|
||||
export const OPENCHAMBER_CONTROL_ACTIONS = Object.freeze(
|
||||
OPENCHAMBER_CONTROL_ACTION_DEFINITIONS.map(({ action }) => action),
|
||||
);
|
||||
|
||||
export const OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS = Object.freeze(
|
||||
OPENCHAMBER_CONTROL_ACTION_DEFINITIONS.filter(({ agentExposed }) => agentExposed !== false),
|
||||
);
|
||||
|
||||
export const OPENCHAMBER_AGENT_TOOL_ACTIONS = Object.freeze(
|
||||
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action }) => action),
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
export class OpenChamberControlError extends Error {
|
||||
constructor(message, statusCode = 500, details = {}) {
|
||||
super(message);
|
||||
this.name = 'OpenChamberControlError';
|
||||
this.statusCode = statusCode;
|
||||
Object.assign(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
export const asControlError = (error, fallbackMessage, fallbackStatus = 500) => {
|
||||
if (error instanceof OpenChamberControlError) return error;
|
||||
const message = error instanceof Error ? error.message : fallbackMessage;
|
||||
return new OpenChamberControlError(message || fallbackMessage, Number(error?.statusCode) || fallbackStatus, {
|
||||
...(error?.goalConfigured === true ? { goalConfigured: true } : {}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import express from 'express';
|
||||
import { asControlError } from './error.js';
|
||||
|
||||
export const registerOpenChamberControlRoutes = (app, { controlService }) => {
|
||||
app.post('/api/openchamber/control', express.json({ limit: '1mb' }), async (req, res) => {
|
||||
const controller = new AbortController();
|
||||
const abortOnDisconnect = () => {
|
||||
if (!res.writableEnded) controller.abort();
|
||||
};
|
||||
req.once('aborted', abortOnDisconnect);
|
||||
res.once('close', abortOnDisconnect);
|
||||
try {
|
||||
const action = typeof req.body?.action === 'string' ? req.body.action : '';
|
||||
const requestInput = req.body?.input;
|
||||
const input = requestInput && typeof requestInput === 'object' && !Array.isArray(requestInput)
|
||||
? requestInput
|
||||
: {};
|
||||
const data = await controlService.execute(action, input, req.body?.contextDirectory, { signal: controller.signal });
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
const controlError = asControlError(error, 'OpenChamber control action failed');
|
||||
return res.status(controlError.statusCode).json({
|
||||
error: controlError.message,
|
||||
...(controlError.partial === true ? {
|
||||
partial: true,
|
||||
partialAction: controlError.partialAction,
|
||||
sessionId: controlError.sessionId,
|
||||
directory: controlError.directory,
|
||||
} : {}),
|
||||
});
|
||||
} finally {
|
||||
req.off('aborted', abortOnDisconnect);
|
||||
res.off('close', abortOnDisconnect);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OpenChamberControlError } from './error.js';
|
||||
import { registerOpenChamberControlRoutes } from './routes.js';
|
||||
|
||||
const createApp = (execute) => {
|
||||
const app = express();
|
||||
registerOpenChamberControlRoutes(app, { controlService: { execute } });
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('OpenChamber control route', () => {
|
||||
it('is a thin adapter over the control service', async () => {
|
||||
const execute = vi.fn(async () => ({ projects: [] }));
|
||||
const response = await request(createApp(execute))
|
||||
.post('/api/openchamber/control')
|
||||
.send({ action: 'projects.list', input: {}, contextDirectory: '/repo' })
|
||||
.expect(200);
|
||||
expect(response.body).toEqual({ projects: [] });
|
||||
expect(execute).toHaveBeenCalledWith('projects.list', {}, '/repo', expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
it('preserves service status and partial-result details', async () => {
|
||||
const execute = vi.fn(async () => {
|
||||
throw new OpenChamberControlError('dispatch failed', 500, {
|
||||
partial: true,
|
||||
partialAction: 'fork-created',
|
||||
sessionId: 'ses_fork',
|
||||
directory: '/repo',
|
||||
});
|
||||
});
|
||||
const response = await request(createApp(execute))
|
||||
.post('/api/openchamber/control')
|
||||
.send({ action: 'session.fork', input: {} })
|
||||
.expect(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'dispatch failed',
|
||||
partial: true,
|
||||
partialAction: 'fork-created',
|
||||
sessionId: 'ses_fork',
|
||||
directory: '/repo',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import path from 'node:path';
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import { OpenChamberControlError, asControlError } from './error.js';
|
||||
import { OPENCHAMBER_CONTROL_ACTIONS } from './actions.js';
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_SECONDS = 600;
|
||||
const MAX_WAIT_TIMEOUT_SECONDS = 86_400;
|
||||
const WAIT_POLL_INTERVAL_MS = 500;
|
||||
const CONTROL_ACTIONS = new Set(OPENCHAMBER_CONTROL_ACTIONS);
|
||||
const SCHEDULE_TASK_ID_ACTIONS = new Set([
|
||||
'schedule.run',
|
||||
'schedule.delete',
|
||||
'schedule.toggle',
|
||||
]);
|
||||
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const positiveInteger = (value, fallback, field) => {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < 1) {
|
||||
throw new OpenChamberControlError(`${field} must be a positive integer`, 400);
|
||||
}
|
||||
return number;
|
||||
};
|
||||
|
||||
const normalizeWaitTimeoutMs = (value) => {
|
||||
const seconds = value === undefined || value === null ? DEFAULT_WAIT_TIMEOUT_SECONDS : Number(value);
|
||||
if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > MAX_WAIT_TIMEOUT_SECONDS) {
|
||||
throw new OpenChamberControlError(`timeout must be from 1 to ${MAX_WAIT_TIMEOUT_SECONDS} seconds`, 400);
|
||||
}
|
||||
return seconds * 1000;
|
||||
};
|
||||
|
||||
const extractTextMessages = (messages, role = 'all') => {
|
||||
const result = [];
|
||||
for (const record of Array.isArray(messages) ? messages : []) {
|
||||
const info = record?.info;
|
||||
const messageRole = info?.role;
|
||||
if ((messageRole !== 'user' && messageRole !== 'assistant') || (role !== 'all' && role !== messageRole)) continue;
|
||||
const text = Array.isArray(record?.parts)
|
||||
? record.parts.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('').trim()
|
||||
: '';
|
||||
if (!text) continue;
|
||||
const providerID = asNonEmptyString(info.providerID);
|
||||
const modelID = asNonEmptyString(info.modelID);
|
||||
result.push({
|
||||
id: asNonEmptyString(info.id) || '',
|
||||
role: messageRole,
|
||||
createdAt: Number.isFinite(info?.time?.created) ? info.time.created : null,
|
||||
completedAt: Number.isFinite(info?.time?.completed) ? info.time.completed : null,
|
||||
model: providerID && modelID ? `${providerID}/${modelID}` : null,
|
||||
text,
|
||||
});
|
||||
}
|
||||
return result.sort((left, right) => (left.createdAt || 0) - (right.createdAt || 0));
|
||||
};
|
||||
|
||||
const parseModel = (value) => {
|
||||
const model = asNonEmptyString(value);
|
||||
if (!model) throw new OpenChamberControlError('model is required', 400);
|
||||
const slashIndex = model.indexOf('/');
|
||||
if (slashIndex <= 0 || slashIndex === model.length - 1) {
|
||||
throw new OpenChamberControlError('model must be in provider/model format', 400);
|
||||
}
|
||||
return { providerID: model.slice(0, slashIndex), modelID: model.slice(slashIndex + 1) };
|
||||
};
|
||||
|
||||
const parseWeekdays = (value) => {
|
||||
const raw = asNonEmptyString(value);
|
||||
if (!raw) throw new OpenChamberControlError('weekly is required', 400);
|
||||
const weekdays = raw.split(',').map((entry) => Number.parseInt(entry.trim(), 10));
|
||||
if (weekdays.some((entry) => !Number.isInteger(entry) || entry < 0 || entry > 6)) {
|
||||
throw new OpenChamberControlError('weekly must contain weekdays from 0 to 6', 400);
|
||||
}
|
||||
return Array.from(new Set(weekdays)).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
const buildSchedule = (input) => {
|
||||
const daily = asNonEmptyString(input.daily);
|
||||
const weekly = asNonEmptyString(input.weekly);
|
||||
const once = asNonEmptyString(input.once);
|
||||
const cron = asNonEmptyString(input.cron);
|
||||
const selectors = [daily, weekly, once, cron].filter(Boolean);
|
||||
if (selectors.length !== 1) {
|
||||
throw new OpenChamberControlError('Provide exactly one of daily, weekly, once, or cron', 400);
|
||||
}
|
||||
const timezone = asNonEmptyString(input.timezone);
|
||||
if (daily) return { kind: 'daily', times: [daily], ...(timezone ? { timezone } : {}) };
|
||||
if (weekly) {
|
||||
const time = asNonEmptyString(input.time);
|
||||
if (!time) throw new OpenChamberControlError('time is required with weekly', 400);
|
||||
return { kind: 'weekly', weekdays: parseWeekdays(weekly), times: [time], ...(timezone ? { timezone } : {}) };
|
||||
}
|
||||
if (once) {
|
||||
const time = asNonEmptyString(input.time);
|
||||
if (!time) throw new OpenChamberControlError('time is required with once', 400);
|
||||
return { kind: 'once', date: once, time, ...(timezone ? { timezone } : {}) };
|
||||
}
|
||||
return { kind: 'cron', cron, ...(timezone ? { timezone } : {}) };
|
||||
};
|
||||
|
||||
const buildScheduledTask = (input) => {
|
||||
const name = asNonEmptyString(input.name);
|
||||
const prompt = asNonEmptyString(input.prompt);
|
||||
if (!name) throw new OpenChamberControlError('name is required', 400);
|
||||
if (!prompt) throw new OpenChamberControlError('prompt is required', 400);
|
||||
const model = parseModel(input.model);
|
||||
const goalTokenBudget = input.goalTokenBudget;
|
||||
if (goalTokenBudget !== undefined && input.goal !== true) {
|
||||
throw new OpenChamberControlError('goalTokenBudget requires goal', 400);
|
||||
}
|
||||
if (goalTokenBudget !== undefined && (!Number.isSafeInteger(goalTokenBudget) || goalTokenBudget < 1000 || goalTokenBudget > 100_000_000)) {
|
||||
throw new OpenChamberControlError('goalTokenBudget must be from 1000 to 100000000', 400);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
enabled: input.disabled !== true,
|
||||
schedule: buildSchedule(input),
|
||||
execution: {
|
||||
prompt,
|
||||
...model,
|
||||
...(asNonEmptyString(input.agent) ? { agent: input.agent.trim() } : {}),
|
||||
...(asNonEmptyString(input.variant) ? { variant: input.variant.trim() } : {}),
|
||||
...(input.goal === true ? { goalEnabled: true } : {}),
|
||||
...(goalTokenBudget !== undefined ? { goalTokenBudget } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createOpenChamberControlService = (dependencies) => {
|
||||
const {
|
||||
readSettingsFromDiskMigrated,
|
||||
sanitizeProjects,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
waitForOpenCodeReady,
|
||||
sessionService,
|
||||
scheduledTaskService,
|
||||
createClient = createOpencodeClient,
|
||||
sleep = (duration) => new Promise((resolve) => setTimeout(resolve, duration)),
|
||||
now = Date.now,
|
||||
} = dependencies;
|
||||
|
||||
const wait = (duration, signal) => {
|
||||
if (!signal) return sleep(duration);
|
||||
if (signal.aborted) return Promise.reject(new OpenChamberControlError('OpenChamber action was cancelled', 499));
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(new OpenChamberControlError('OpenChamber action was cancelled', 499));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
sleep(duration).then(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, (error) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getClient = async () => {
|
||||
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
|
||||
return createClient({
|
||||
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
||||
headers: getOpenCodeAuthHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
const projects = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
return sanitizeProjects(settings?.projects || []).map((project) => ({
|
||||
id: project.id,
|
||||
path: path.resolve(project.path),
|
||||
label: asNonEmptyString(project.label) || path.basename(project.path) || project.path,
|
||||
}));
|
||||
};
|
||||
|
||||
const models = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
return {
|
||||
defaultModel: asNonEmptyString(settings?.defaultModel),
|
||||
defaultVariant: asNonEmptyString(settings?.defaultVariant),
|
||||
defaultAgent: asNonEmptyString(settings?.defaultAgent),
|
||||
favoriteModels: Array.isArray(settings?.favoriteModels) ? settings.favoriteModels : [],
|
||||
recentModels: Array.isArray(settings?.recentModels) ? settings.recentModels : [],
|
||||
};
|
||||
};
|
||||
|
||||
const sessionStatus = async (client, sessionID, directory) => {
|
||||
const response = await client.session.status({ directory });
|
||||
const statuses = response?.data;
|
||||
if (!statuses || typeof statuses !== 'object' || Array.isArray(statuses)) {
|
||||
throw new OpenChamberControlError('Invalid session status response', 500);
|
||||
}
|
||||
return statuses[sessionID] || { type: 'idle' };
|
||||
};
|
||||
|
||||
const sessionMessages = async (client, sessionID, directory, role, limit) => {
|
||||
const fetchLimit = limit === undefined ? undefined : Math.max(100, limit * 4);
|
||||
let response = await client.session.messages({ sessionID, directory, ...(fetchLimit ? { limit: fetchLimit } : {}) });
|
||||
let raw = Array.isArray(response?.data) ? response.data : [];
|
||||
let messages = extractTextMessages(raw, role);
|
||||
if (limit !== undefined && messages.length < limit && raw.length >= fetchLimit) {
|
||||
response = await client.session.messages({ sessionID, directory });
|
||||
raw = Array.isArray(response?.data) ? response.data : [];
|
||||
messages = extractTextMessages(raw, role);
|
||||
}
|
||||
return limit === undefined ? messages : messages.slice(-limit);
|
||||
};
|
||||
|
||||
const waitForIdle = async ({ client, sessionID, directory, timeoutMs, requireActivity, baselineMessageID, startedAt, signal }) => {
|
||||
const deadline = now() + timeoutMs;
|
||||
let observedActivity = false;
|
||||
while (true) {
|
||||
if (signal?.aborted) throw new OpenChamberControlError('OpenChamber action was cancelled', 499);
|
||||
const status = await sessionStatus(client, sessionID, directory);
|
||||
if (status.type === 'busy' || status.type === 'retry') {
|
||||
observedActivity = true;
|
||||
} else if (!requireActivity || observedActivity) {
|
||||
return status;
|
||||
} else {
|
||||
const messages = await sessionMessages(client, sessionID, directory, 'assistant', 1);
|
||||
const message = messages[0];
|
||||
if (message?.completedAt && (baselineMessageID ? message.id !== baselineMessageID : message.completedAt >= startedAt)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
const remaining = deadline - now();
|
||||
if (remaining <= 0) {
|
||||
throw new OpenChamberControlError(`Session did not become idle within ${Math.ceil(timeoutMs / 1000)} seconds`, 500);
|
||||
}
|
||||
await wait(Math.min(WAIT_POLL_INTERVAL_MS, remaining), signal);
|
||||
}
|
||||
};
|
||||
|
||||
const executeSessionAction = async (action, input, contextDirectory, signal) => {
|
||||
if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400);
|
||||
if (input.lastAssistant === true && input.wait !== true) throw new OpenChamberControlError('lastAssistant requires wait', 400);
|
||||
const directory = asNonEmptyString(input.directory) || (!input.projectId ? asNonEmptyString(contextDirectory) : null);
|
||||
const payload = {
|
||||
...(directory ? { directory } : {}),
|
||||
...(asNonEmptyString(input.projectId) ? { projectId: input.projectId.trim() } : {}),
|
||||
...(asNonEmptyString(input.title) ? { title: input.title.trim() } : {}),
|
||||
...(asNonEmptyString(input.prompt) ? { prompt: input.prompt.trim() } : {}),
|
||||
...(asNonEmptyString(input.model) ? { model: input.model.trim() } : {}),
|
||||
...(asNonEmptyString(input.agent) ? { agent: input.agent.trim() } : {}),
|
||||
...(asNonEmptyString(input.variant) ? { variant: input.variant.trim() } : {}),
|
||||
...(input.goal === true ? { goal: true } : {}),
|
||||
...(input.goalTokenBudget !== undefined ? { goalTokenBudget: input.goalTokenBudget } : {}),
|
||||
...(asNonEmptyString(input.worktree) ? { worktree: {
|
||||
name: input.worktree.trim(),
|
||||
...(asNonEmptyString(input.branch) ? { branchName: input.branch.trim() } : {}),
|
||||
...(asNonEmptyString(input.startRef) ? { startRef: input.startRef.trim() } : {}),
|
||||
} } : {}),
|
||||
...(typeof input.setUpstream === 'boolean' ? { setUpstream: input.setUpstream } : {}),
|
||||
...(asNonEmptyString(input.messageId) ? { messageId: input.messageId.trim() } : {}),
|
||||
};
|
||||
const sessionID = asNonEmptyString(input.sessionId);
|
||||
const startedAt = now();
|
||||
let result;
|
||||
if (action === 'session.create') {
|
||||
result = await sessionService.create(payload);
|
||||
} else {
|
||||
if (!sessionID) throw new OpenChamberControlError('sessionId is required', 400);
|
||||
if (action === 'session.send') {
|
||||
result = await sessionService.send(sessionID, payload);
|
||||
} else {
|
||||
result = await sessionService.fork(sessionID, payload);
|
||||
}
|
||||
}
|
||||
if (input.wait !== true) {
|
||||
const publicResult = { ...result };
|
||||
delete publicResult.baselineAssistantMessageId;
|
||||
return publicResult;
|
||||
}
|
||||
const client = await getClient();
|
||||
const status = await waitForIdle({
|
||||
client,
|
||||
sessionID: result.sessionId,
|
||||
directory: result.directory,
|
||||
timeoutMs: normalizeWaitTimeoutMs(input.timeout),
|
||||
requireActivity: result.promptDispatched === true,
|
||||
baselineMessageID: result.baselineAssistantMessageId,
|
||||
startedAt,
|
||||
signal,
|
||||
});
|
||||
const publicResult = { ...result, sessionStatus: status };
|
||||
delete publicResult.baselineAssistantMessageId;
|
||||
if (input.lastAssistant === true) {
|
||||
publicResult.lastAssistantMessage = (await sessionMessages(client, result.sessionId, result.directory, 'assistant', 1))[0] || null;
|
||||
}
|
||||
return publicResult;
|
||||
};
|
||||
|
||||
const execute = async (action, input = {}, contextDirectory, options = {}) => {
|
||||
try {
|
||||
if (!CONTROL_ACTIONS.has(action)) {
|
||||
throw new OpenChamberControlError(`Unsupported OpenChamber action: ${action || 'missing'}`, 400);
|
||||
}
|
||||
if (action === 'projects.list') return { projects: await projects() };
|
||||
if (action === 'models.list') return models();
|
||||
if (action === 'schedule.status') return scheduledTaskService.status();
|
||||
if (action.startsWith('schedule.')) {
|
||||
const taskID = asNonEmptyString(input.taskId);
|
||||
if (SCHEDULE_TASK_ID_ACTIONS.has(action) && !taskID) {
|
||||
throw new OpenChamberControlError('taskId is required', 400);
|
||||
}
|
||||
const explicitProjectID = asNonEmptyString(input.projectId);
|
||||
const explicitDirectory = asNonEmptyString(input.directory);
|
||||
const contextDirectoryFallback = explicitProjectID
|
||||
? undefined
|
||||
: asNonEmptyString(contextDirectory) || undefined;
|
||||
const projectID = await scheduledTaskService.resolveProjectID({
|
||||
projectId: explicitProjectID || undefined,
|
||||
directory: explicitDirectory || contextDirectoryFallback,
|
||||
});
|
||||
switch (action) {
|
||||
case 'schedule.list':
|
||||
return { scheduler: await scheduledTaskService.status(), tasks: await scheduledTaskService.list(projectID) };
|
||||
case 'schedule.create': {
|
||||
const result = await scheduledTaskService.upsert(projectID, buildScheduledTask(input));
|
||||
return { task: result.task, created: result.created };
|
||||
}
|
||||
case 'schedule.run':
|
||||
return scheduledTaskService.run(projectID, taskID);
|
||||
case 'schedule.delete':
|
||||
return { deleted: true, tasks: await scheduledTaskService.remove(projectID, taskID) };
|
||||
case 'schedule.toggle': {
|
||||
if (typeof input.disabled !== 'boolean') {
|
||||
throw new OpenChamberControlError('disabled is required for schedule.toggle', 400);
|
||||
}
|
||||
const enabled = input.disabled === false;
|
||||
return { task: await scheduledTaskService.setEnabled(projectID, taskID, enabled), enabled };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action === 'session.create' || action === 'session.send' || action === 'session.fork') {
|
||||
return executeSessionAction(action, input, contextDirectory, options.signal);
|
||||
}
|
||||
if (action.startsWith('session.')) {
|
||||
const directory = asNonEmptyString(input.directory) || asNonEmptyString(contextDirectory);
|
||||
const sessionID = asNonEmptyString(input.sessionId);
|
||||
const client = await getClient();
|
||||
if (action === 'session.list') {
|
||||
const limit = positiveInteger(input.limit, 10, 'limit');
|
||||
const response = await client.session.list(directory ? { directory } : {});
|
||||
let sessions = Array.isArray(response?.data) ? response.data : [];
|
||||
if (input.all !== true) sessions = sessions.filter((session) => !session?.time?.archived);
|
||||
sessions = sessions.slice(0, limit);
|
||||
if (input.withStatus === true) {
|
||||
const cache = new Map();
|
||||
sessions = await Promise.all(sessions.map(async (session) => {
|
||||
const sessionDirectory = asNonEmptyString(session?.directory);
|
||||
if (!sessionDirectory) return { ...session, status: { type: 'unknown' } };
|
||||
if (!cache.has(sessionDirectory)) {
|
||||
const statusRequest = client.session.status({ directory: sessionDirectory }).catch(() => null);
|
||||
cache.set(sessionDirectory, statusRequest);
|
||||
}
|
||||
const statusResponse = await cache.get(sessionDirectory);
|
||||
return { ...session, status: statusResponse?.data?.[session.id] || (statusResponse ? { type: 'idle' } : { type: 'unknown' }) };
|
||||
}));
|
||||
}
|
||||
return { sessions, limit, directory, archived: input.all === true ? 'included' : 'excluded' };
|
||||
}
|
||||
if (!sessionID) throw new OpenChamberControlError('sessionId is required', 400);
|
||||
if (!directory) throw new OpenChamberControlError('directory is required', 400);
|
||||
if (action === 'session.status') {
|
||||
return { sessionId: sessionID, directory, sessionStatus: await sessionStatus(client, sessionID, directory) };
|
||||
}
|
||||
if (action === 'session.messages') {
|
||||
if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400);
|
||||
const role = input.lastAssistant === true ? 'assistant' : (asNonEmptyString(input.role) || 'all');
|
||||
if (!['all', 'user', 'assistant'].includes(role)) throw new OpenChamberControlError('role must be all, user, or assistant', 400);
|
||||
const last = input.last === true || input.lastAssistant === true;
|
||||
if (input.all === true && (last || input.limit !== undefined)) throw new OpenChamberControlError('all cannot be combined with last or limit', 400);
|
||||
if (last && input.limit !== undefined) throw new OpenChamberControlError('last cannot be combined with limit', 400);
|
||||
const currentStatus = input.wait === true
|
||||
? await waitForIdle({ client, sessionID, directory, timeoutMs: normalizeWaitTimeoutMs(input.timeout), requireActivity: false, startedAt: now(), signal: options.signal })
|
||||
: await sessionStatus(client, sessionID, directory);
|
||||
const limit = input.all === true ? undefined : (last ? 1 : positiveInteger(input.limit, 10, 'limit'));
|
||||
return { sessionId: sessionID, directory, role, sessionStatus: currentStatus, messages: await sessionMessages(client, sessionID, directory, role, limit) };
|
||||
}
|
||||
}
|
||||
throw new OpenChamberControlError(`Unsupported OpenChamber action: ${action || 'missing'}`, 400);
|
||||
} catch (error) {
|
||||
throw asControlError(error, `Failed to execute ${action || 'OpenChamber action'}`);
|
||||
}
|
||||
};
|
||||
|
||||
return { execute };
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createOpenChamberControlService } from './service.js';
|
||||
|
||||
const createService = (overrides = {}) => {
|
||||
const client = {
|
||||
session: {
|
||||
list: vi.fn(async () => ({ data: [] })),
|
||||
status: vi.fn(async () => ({ data: {} })),
|
||||
messages: vi.fn(async () => ({ data: [] })),
|
||||
},
|
||||
};
|
||||
const sessionService = {
|
||||
create: vi.fn(async () => ({ sessionId: 'ses_1', directory: '/repo', promptDispatched: false })),
|
||||
send: vi.fn(),
|
||||
fork: vi.fn(),
|
||||
};
|
||||
const scheduledTaskService = {
|
||||
status: vi.fn(async () => ({ enabledScheduledTasksCount: 0 })),
|
||||
resolveProjectID: vi.fn(async () => 'project-1'),
|
||||
list: vi.fn(async () => []),
|
||||
upsert: vi.fn(),
|
||||
run: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
};
|
||||
const service = createOpenChamberControlService({
|
||||
readSettingsFromDiskMigrated: vi.fn(async () => ({
|
||||
projects: [{ id: 'project-1', path: '/repo', label: 'Repo' }],
|
||||
defaultModel: 'provider/model',
|
||||
favoriteModels: [],
|
||||
recentModels: [],
|
||||
})),
|
||||
sanitizeProjects: (projects) => projects,
|
||||
buildOpenCodeUrl: () => 'http://127.0.0.1:4096/',
|
||||
getOpenCodeAuthHeaders: () => ({ authorization: 'Basic test' }),
|
||||
waitForOpenCodeReady: vi.fn(),
|
||||
createClient: vi.fn(() => client),
|
||||
sessionService,
|
||||
scheduledTaskService,
|
||||
...overrides,
|
||||
});
|
||||
return { service, client, sessionService, scheduledTaskService };
|
||||
};
|
||||
|
||||
describe('OpenChamber control service', () => {
|
||||
it('serves project and model projections without an HTTP or CLI round trip', async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.execute('projects.list')).resolves.toEqual({
|
||||
projects: [{ id: 'project-1', path: '/repo', label: 'Repo' }],
|
||||
});
|
||||
await expect(service.execute('models.list')).resolves.toEqual(expect.objectContaining({
|
||||
defaultModel: 'provider/model',
|
||||
favoriteModels: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('maps schedule creation into the shared scheduled-task service', async () => {
|
||||
const { service, scheduledTaskService } = createService();
|
||||
scheduledTaskService.upsert.mockResolvedValue({ task: { id: 'task-1' }, created: true });
|
||||
await expect(service.execute('schedule.create', {
|
||||
directory: '/repo',
|
||||
name: 'Daily',
|
||||
prompt: 'Run checks',
|
||||
model: 'provider/model',
|
||||
daily: ' 09:00 ',
|
||||
goal: true,
|
||||
goalTokenBudget: 5000,
|
||||
})).resolves.toEqual({ task: { id: 'task-1' }, created: true });
|
||||
expect(scheduledTaskService.resolveProjectID).toHaveBeenCalledWith({ projectId: undefined, directory: '/repo' });
|
||||
expect(scheduledTaskService.upsert).toHaveBeenCalledWith('project-1', expect.objectContaining({
|
||||
name: 'Daily',
|
||||
schedule: { kind: 'daily', times: ['09:00'] },
|
||||
execution: expect.objectContaining({ providerID: 'provider', modelID: 'model', goalEnabled: true, goalTokenBudget: 5000 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not combine an explicit schedule project with the tool context directory', async () => {
|
||||
const { service, scheduledTaskService } = createService();
|
||||
await service.execute('schedule.list', { projectId: ' project-1 ' }, '/current-session');
|
||||
expect(scheduledTaskService.resolveProjectID).toHaveBeenCalledWith({ projectId: 'project-1', directory: undefined });
|
||||
});
|
||||
|
||||
it('includes scheduler status alongside listed tasks', async () => {
|
||||
const { service, scheduledTaskService } = createService();
|
||||
scheduledTaskService.list.mockResolvedValue([{ id: 'task-1' }]);
|
||||
await expect(service.execute('schedule.list', {}, '/repo')).resolves.toEqual({
|
||||
scheduler: { enabledScheduledTasksCount: 0 },
|
||||
tasks: [{ id: 'task-1' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles a scheduled task through the required disabled boolean', async () => {
|
||||
const { service, scheduledTaskService } = createService();
|
||||
scheduledTaskService.setEnabled.mockResolvedValue({ id: 'task-1', enabled: false });
|
||||
await expect(service.execute('schedule.toggle', { taskId: 'task-1' }, '/repo')).rejects.toThrow('disabled is required for schedule.toggle');
|
||||
await expect(service.execute('schedule.toggle', { taskId: 'task-1', disabled: true }, '/repo')).resolves.toEqual({
|
||||
task: { id: 'task-1', enabled: false },
|
||||
enabled: false,
|
||||
});
|
||||
expect(scheduledTaskService.setEnabled).toHaveBeenCalledWith('project-1', 'task-1', false);
|
||||
});
|
||||
|
||||
it('returns an actionable taskId error before resolving schedule scope', async () => {
|
||||
const { service, scheduledTaskService } = createService();
|
||||
await expect(service.execute('schedule.run', {}, '/repo')).rejects.toThrow('taskId is required');
|
||||
expect(scheduledTaskService.resolveProjectID).not.toHaveBeenCalled();
|
||||
expect(scheduledTaskService.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates wait modifiers before creating a session', async () => {
|
||||
const { service, sessionService } = createService();
|
||||
await expect(service.execute('session.create', { directory: '/repo', timeout: 30 })).rejects.toThrow('timeout requires wait');
|
||||
expect(sessionService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the tool context directory for session actions', async () => {
|
||||
const { service, sessionService } = createService();
|
||||
await service.execute('session.create', { title: 'From tool' }, '/repo');
|
||||
expect(sessionService.create).toHaveBeenCalledWith({ directory: '/repo', title: 'From tool' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['session.send', 'send'],
|
||||
['session.fork', 'fork'],
|
||||
])('delegates %s directly to the session service', async (action, method) => {
|
||||
const { service, sessionService } = createService();
|
||||
sessionService[method].mockResolvedValue({ sessionId: 'ses_1', directory: '/repo' });
|
||||
|
||||
await service.execute(action, { sessionId: 'ses_1', directory: '/repo', prompt: 'Continue' });
|
||||
|
||||
expect(sessionService[method]).toHaveBeenCalledWith('ses_1', { directory: '/repo', prompt: 'Continue' });
|
||||
});
|
||||
|
||||
it('waits past initial idle until a completed assistant result appears', async () => {
|
||||
let timestamp = 1000;
|
||||
const { service, client, sessionService } = createService({
|
||||
now: () => timestamp,
|
||||
sleep: async (duration) => { timestamp += duration; },
|
||||
});
|
||||
sessionService.create.mockResolvedValue({
|
||||
sessionId: 'ses_1',
|
||||
directory: '/repo',
|
||||
promptDispatched: true,
|
||||
baselineAssistantMessageId: 'msg_old',
|
||||
});
|
||||
client.session.status.mockResolvedValue({ data: { ses_1: { type: 'idle' } } });
|
||||
client.session.messages
|
||||
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_old', role: 'assistant', time: { completed: 900 } }, parts: [{ type: 'text', text: 'old' }] }] })
|
||||
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_new', role: 'assistant', time: { completed: 1500 } }, parts: [{ type: 'text', text: 'done' }] }] })
|
||||
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_new', role: 'assistant', time: { completed: 1500 } }, parts: [{ type: 'text', text: 'done' }] }] });
|
||||
|
||||
await expect(service.execute('session.create', {
|
||||
directory: '/repo',
|
||||
prompt: 'work',
|
||||
wait: true,
|
||||
lastAssistant: true,
|
||||
timeout: 2,
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
sessionStatus: { type: 'idle' },
|
||||
lastAssistantMessage: expect.objectContaining({ id: 'msg_new', text: 'done' }),
|
||||
}));
|
||||
expect(client.session.status).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('filters archived sessions and adds directory-scoped statuses', async () => {
|
||||
const { service, client } = createService();
|
||||
client.session.list.mockResolvedValue({ data: [
|
||||
{ id: 'ses_active', directory: '/repo', time: {} },
|
||||
{ id: 'ses_archived', directory: '/repo', time: { archived: 100 } },
|
||||
{ id: 'ses_other', directory: '/other', time: {} },
|
||||
] });
|
||||
client.session.status
|
||||
.mockResolvedValueOnce({ data: { ses_active: { type: 'busy' } } })
|
||||
.mockRejectedValueOnce(new Error('unavailable'));
|
||||
|
||||
await expect(service.execute('session.list', { limit: 10, withStatus: true })).resolves.toEqual({
|
||||
sessions: [
|
||||
{ id: 'ses_active', directory: '/repo', time: {}, status: { type: 'busy' } },
|
||||
{ id: 'ses_other', directory: '/other', time: {}, status: { type: 'unknown' } },
|
||||
],
|
||||
limit: 10,
|
||||
directory: null,
|
||||
archived: 'excluded',
|
||||
});
|
||||
});
|
||||
|
||||
it('names limit in positive-integer validation errors', async () => {
|
||||
const { service, client } = createService();
|
||||
await expect(service.execute('session.list', { limit: 0 })).rejects.toThrow('limit must be a positive integer');
|
||||
expect(client.session.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('projects only ordered text parts from session messages', async () => {
|
||||
const { service, client } = createService();
|
||||
client.session.messages.mockResolvedValue({ data: [
|
||||
{
|
||||
info: { id: 'msg_assistant', role: 'assistant', providerID: 'openai', modelID: 'gpt-5.4-mini', time: { created: 20, completed: 30 } },
|
||||
parts: [{ type: 'reasoning', text: 'hidden' }, { type: 'text', text: 'First ' }, { type: 'tool' }, { type: 'text', text: 'answer' }],
|
||||
},
|
||||
{ info: { id: 'msg_user', role: 'user', time: { created: 10 } }, parts: [{ type: 'text', text: 'Question' }] },
|
||||
{ info: { id: 'msg_tool', role: 'assistant', time: { created: 15 } }, parts: [{ type: 'tool' }] },
|
||||
] });
|
||||
|
||||
await expect(service.execute('session.messages', {
|
||||
sessionId: 'ses_1',
|
||||
directory: '/repo',
|
||||
role: 'all',
|
||||
all: true,
|
||||
})).resolves.toEqual({
|
||||
sessionId: 'ses_1',
|
||||
directory: '/repo',
|
||||
role: 'all',
|
||||
sessionStatus: { type: 'idle' },
|
||||
messages: [
|
||||
{ id: 'msg_user', role: 'user', createdAt: 10, completedAt: null, model: null, text: 'Question' },
|
||||
{ id: 'msg_assistant', role: 'assistant', createdAt: 20, completedAt: 30, model: 'openai/gpt-5.4-mini', text: 'First answer' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects actions outside the fixed contract', async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.execute('session.delete')).rejects.toThrow('Unsupported OpenChamber action');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,827 @@
|
||||
import express from 'express';
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import { createWorktree } from '../git/index.js';
|
||||
import { expandSnippets } from '../opencode/snippets.js';
|
||||
import { expandCommandGoalObjective, parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
|
||||
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
|
||||
import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js';
|
||||
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const splitModel = (value) => {
|
||||
const model = asNonEmptyString(value);
|
||||
if (!model) return null;
|
||||
const slashIndex = model.indexOf('/');
|
||||
if (slashIndex <= 0 || slashIndex === model.length - 1) return null;
|
||||
return {
|
||||
providerID: model.slice(0, slashIndex),
|
||||
modelID: model.slice(slashIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveRequestedModel = (payload) => {
|
||||
const model = splitModel(payload?.model);
|
||||
if (model) return model;
|
||||
|
||||
const providerID = asNonEmptyString(payload?.providerID);
|
||||
const modelID = asNonEmptyString(payload?.modelID);
|
||||
return providerID && modelID ? { providerID, modelID } : null;
|
||||
};
|
||||
|
||||
const FALLBACK_PROVIDER_ID = 'opencode';
|
||||
const FALLBACK_MODEL_ID = 'big-pickle';
|
||||
const MIN_GOAL_TOKEN_BUDGET = 1_000;
|
||||
const MAX_GOAL_TOKEN_BUDGET = 100_000_000;
|
||||
|
||||
const resolveGoalInput = (payload, prompt) => {
|
||||
const enabled = payload?.goal === true;
|
||||
if (payload?.goalTokenBudget !== undefined && !enabled) {
|
||||
return { ok: false, error: 'goalTokenBudget requires goal' };
|
||||
}
|
||||
if (enabled && !prompt) {
|
||||
return { ok: false, error: 'prompt is required when goal is enabled' };
|
||||
}
|
||||
if (payload?.goalTokenBudget === undefined) {
|
||||
return { ok: true, enabled, tokenBudget: null };
|
||||
}
|
||||
const tokenBudget = payload.goalTokenBudget;
|
||||
if (!Number.isSafeInteger(tokenBudget)
|
||||
|| tokenBudget < MIN_GOAL_TOKEN_BUDGET
|
||||
|| tokenBudget > MAX_GOAL_TOKEN_BUDGET) {
|
||||
return { ok: false, error: `goalTokenBudget must be an integer from ${MIN_GOAL_TOKEN_BUDGET} to ${MAX_GOAL_TOKEN_BUDGET}` };
|
||||
}
|
||||
return { ok: true, enabled, tokenBudget };
|
||||
};
|
||||
|
||||
const isPrimaryAgentMode = (mode) => !mode || mode === 'primary' || mode === 'all';
|
||||
|
||||
const providerModels = (provider) => {
|
||||
if (Array.isArray(provider?.models)) return provider.models;
|
||||
if (provider?.models && typeof provider.models === 'object') return Object.values(provider.models);
|
||||
return [];
|
||||
};
|
||||
|
||||
const hasProviderModel = (providers, providerID, modelID) => {
|
||||
return providers.some((provider) => provider?.id === providerID
|
||||
&& providerModels(provider).some((model) => model?.id === modelID));
|
||||
};
|
||||
|
||||
const resolveVariant = (providers, providerID, modelID, variant) => {
|
||||
const normalized = asNonEmptyString(variant);
|
||||
if (!normalized) return undefined;
|
||||
const provider = providers.find((entry) => entry?.id === providerID);
|
||||
const model = providerModels(provider).find((entry) => entry?.id === modelID);
|
||||
return model?.variants && Object.prototype.hasOwnProperty.call(model.variants, normalized)
|
||||
? normalized
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const parseConfigModel = (value) => splitModel(value);
|
||||
|
||||
const buildDirectoryHeaders = (directory) => ({
|
||||
...(directory ? { 'x-opencode-directory': directory } : {}),
|
||||
});
|
||||
|
||||
const fetchJson = async (url, authHeaders, fallback, directory) => {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { ...authHeaders, ...buildDirectoryHeaders(directory), accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return fallback;
|
||||
return response.json().catch(() => fallback);
|
||||
};
|
||||
|
||||
const fetchSelectionInputs = async ({ buildOpenCodeUrl, authHeaders, directory, readSettingsFromDiskMigrated }) => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const providersUrl = new URL(buildOpenCodeUrl('/config/providers', ''));
|
||||
providersUrl.searchParams.set('directory', directory);
|
||||
const agentsUrl = new URL(buildOpenCodeUrl('/agent', ''));
|
||||
agentsUrl.searchParams.set('directory', directory);
|
||||
const configUrl = new URL(buildOpenCodeUrl('/config', ''));
|
||||
configUrl.searchParams.set('directory', directory);
|
||||
|
||||
const [providersBody, agentsBody, configBody] = await Promise.all([
|
||||
fetchJson(providersUrl, authHeaders, { providers: [] }, directory),
|
||||
fetchJson(agentsUrl, authHeaders, [], directory),
|
||||
fetchJson(configUrl, authHeaders, {}, directory),
|
||||
]);
|
||||
|
||||
return {
|
||||
settings,
|
||||
providers: Array.isArray(providersBody?.providers) ? providersBody.providers : [],
|
||||
agents: Array.isArray(agentsBody) ? agentsBody : [],
|
||||
opencodeDefaultAgent: asNonEmptyString(configBody?.default_agent) || asNonEmptyString(configBody?.defaultAgent),
|
||||
opencodeDefaultModel: asNonEmptyString(configBody?.model),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveDefaultSelection = ({ agents, providers, settings, opencodeDefaultAgent, opencodeDefaultModel }) => {
|
||||
const primaryAgents = agents.filter((agent) => isPrimaryAgentMode(agent?.mode) && agent?.hidden !== true);
|
||||
let resolvedAgent = null;
|
||||
const settingsDefaultAgent = asNonEmptyString(settings?.defaultAgent);
|
||||
if (settingsDefaultAgent) {
|
||||
resolvedAgent = agents.find((agent) => agent?.name === settingsDefaultAgent) || null;
|
||||
}
|
||||
if (!resolvedAgent && opencodeDefaultAgent) {
|
||||
const candidate = agents.find((agent) => agent?.name === opencodeDefaultAgent) || null;
|
||||
if (candidate && isPrimaryAgentMode(candidate.mode) && candidate.hidden !== true) {
|
||||
resolvedAgent = candidate;
|
||||
}
|
||||
}
|
||||
if (!resolvedAgent) {
|
||||
resolvedAgent = primaryAgents.find((agent) => agent?.name === 'build') || primaryAgents[0] || agents[0] || null;
|
||||
}
|
||||
|
||||
let model = null;
|
||||
let variant;
|
||||
const settingsDefaultModel = parseConfigModel(settings?.defaultModel);
|
||||
if (settingsDefaultModel && hasProviderModel(providers, settingsDefaultModel.providerID, settingsDefaultModel.modelID)) {
|
||||
model = settingsDefaultModel;
|
||||
variant = resolveVariant(providers, model.providerID, model.modelID, settings?.defaultVariant);
|
||||
}
|
||||
|
||||
if (!model && resolvedAgent?.model?.providerID && resolvedAgent?.model?.modelID
|
||||
&& hasProviderModel(providers, resolvedAgent.model.providerID, resolvedAgent.model.modelID)) {
|
||||
model = { providerID: resolvedAgent.model.providerID, modelID: resolvedAgent.model.modelID };
|
||||
variant = resolveVariant(providers, model.providerID, model.modelID, resolvedAgent.variant);
|
||||
}
|
||||
|
||||
const opencodeModel = parseConfigModel(opencodeDefaultModel);
|
||||
if (!model && opencodeModel && hasProviderModel(providers, opencodeModel.providerID, opencodeModel.modelID)) {
|
||||
model = opencodeModel;
|
||||
}
|
||||
|
||||
if (!model && hasProviderModel(providers, FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
|
||||
model = { providerID: FALLBACK_PROVIDER_ID, modelID: FALLBACK_MODEL_ID };
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
const provider = providers[0];
|
||||
const firstModel = providerModels(provider)[0];
|
||||
if (provider?.id && firstModel?.id) {
|
||||
model = { providerID: provider.id, modelID: firstModel.id };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: resolvedAgent?.name,
|
||||
model,
|
||||
variant,
|
||||
};
|
||||
};
|
||||
|
||||
const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, directory, payload }) => {
|
||||
const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
|
||||
promptUrl.searchParams.set('directory', directory);
|
||||
const response = await fetch(promptUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeaders,
|
||||
...buildDirectoryHeaders(directory),
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const createSession = async ({ baseUrl, authHeaders, directory, title }) => {
|
||||
const sessionUrl = new URL(`${baseUrl}/session`);
|
||||
sessionUrl.searchParams.set('directory', directory);
|
||||
const response = await fetch(sessionUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeaders,
|
||||
...buildDirectoryHeaders(directory),
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ directory, ...(title ? { title } : {}) }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`session create failed (${response.status})${body ? `: ${body}` : ''}`);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
const sessionID = body?.id || body?.data?.id;
|
||||
if (!sessionID) {
|
||||
throw new Error('failed to create session');
|
||||
}
|
||||
return sessionID;
|
||||
};
|
||||
|
||||
const forkSession = async ({ client, sessionID, directory, messageID }) => {
|
||||
const response = await client.session.fork({
|
||||
sessionID,
|
||||
directory,
|
||||
...(messageID ? { messageID } : {}),
|
||||
});
|
||||
const session = response?.data;
|
||||
if (!session?.id) {
|
||||
throw new Error('failed to fork session');
|
||||
}
|
||||
return session;
|
||||
};
|
||||
|
||||
const latestCompletedAssistantMessageID = async ({ client, sessionID, directory }) => {
|
||||
let response;
|
||||
try {
|
||||
response = await client.session.messages({ sessionID, directory, limit: 100 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const messages = Array.isArray(response?.data) ? response.data : [];
|
||||
let latest = null;
|
||||
for (const message of messages) {
|
||||
const info = message?.info;
|
||||
if (info?.role !== 'assistant' || !Number.isFinite(info?.time?.completed)) continue;
|
||||
if (!latest || (info.time.created || 0) >= (latest.time?.created || 0)) latest = info;
|
||||
}
|
||||
return asNonEmptyString(latest?.id);
|
||||
};
|
||||
|
||||
const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => {
|
||||
const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID);
|
||||
if (projectID) {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const projects = sanitizeProjects(settings?.projects || []);
|
||||
const project = projects.find((entry) => entry.id === projectID) || null;
|
||||
if (!project?.path) {
|
||||
return { ok: false, status: 404, error: 'Project not found' };
|
||||
}
|
||||
const validated = await validateDirectoryPath(project.path);
|
||||
return validated.ok
|
||||
? { ok: true, directory: validated.directory, projectId: projectID }
|
||||
: { ok: false, status: 400, error: validated.error || 'Invalid project directory' };
|
||||
}
|
||||
|
||||
const directory = asNonEmptyString(payload?.directory);
|
||||
const validated = await validateDirectoryPath(directory);
|
||||
return validated.ok
|
||||
? { ok: true, directory: validated.directory }
|
||||
: { ok: false, status: 400, error: validated.error || 'Invalid directory' };
|
||||
};
|
||||
|
||||
const PROMPT_LANDED_TIMEOUT_MS = 5_000;
|
||||
const PROMPT_LANDED_POLL_MS = 150;
|
||||
|
||||
const latestUserMessageID = async ({ client, sessionID, directory }) => {
|
||||
let response;
|
||||
try {
|
||||
response = await client.session.messages({ sessionID, directory, limit: 100 });
|
||||
} catch {
|
||||
return { ok: false, messageID: null };
|
||||
}
|
||||
const messages = Array.isArray(response?.data) ? response.data : [];
|
||||
let latest = null;
|
||||
for (const message of messages) {
|
||||
const info = message?.info;
|
||||
if (info?.role !== 'user') continue;
|
||||
if (!latest || (info.time?.created || 0) >= (latest.time?.created || 0)) latest = info;
|
||||
}
|
||||
return { ok: true, messageID: asNonEmptyString(latest?.id) };
|
||||
};
|
||||
|
||||
// `prompt_async` answers 204 as soon as OpenCode forks the run, and every later
|
||||
// failure is reported only on the session event stream. Confirm the prompt was
|
||||
// actually recorded so `promptDispatched` never claims a dispatch that vanished.
|
||||
const waitForPromptLanded = async ({ client, sessionID, directory, baselineUserMessageID }) => {
|
||||
const deadline = Date.now() + PROMPT_LANDED_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
const latest = await latestUserMessageID({ client, sessionID, directory });
|
||||
// A failed lookup is not authoritative evidence that the prompt was lost.
|
||||
if (!latest.ok) return true;
|
||||
if (latest.messageID && latest.messageID !== baselineUserMessageID) return true;
|
||||
if (Date.now() >= deadline) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, PROMPT_LANDED_POLL_MS));
|
||||
}
|
||||
};
|
||||
|
||||
const resolveWorktreeInput = (payload) => {
|
||||
if (!payload?.worktree || typeof payload.worktree !== 'object') return null;
|
||||
const name = asNonEmptyString(payload.worktree.name);
|
||||
if (!name) return null;
|
||||
const branchName = asNonEmptyString(payload.worktree.branchName);
|
||||
const startRef = asNonEmptyString(payload.worktree.startRef);
|
||||
return {
|
||||
mode: 'new',
|
||||
name,
|
||||
...(branchName ? { branchName } : {}),
|
||||
...(startRef ? { startRef } : {}),
|
||||
...(typeof payload.setUpstream === 'boolean' ? { setUpstream: payload.setUpstream } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const createOpenChamberSessionService = (dependencies) => {
|
||||
const {
|
||||
readSettingsFromDiskMigrated,
|
||||
sanitizeProjects,
|
||||
validateDirectoryPath,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
waitForOpenCodeReady,
|
||||
emitSessionCreatedEvent,
|
||||
createSessionGoal: createSessionGoalOverride,
|
||||
} = dependencies;
|
||||
|
||||
// Last user message of an existing session, as a selection to reuse. Returns
|
||||
// null when the session has no user message carrying a model.
|
||||
const fetchLastUserSelection = async ({ client, sessionID, directory }) => {
|
||||
try {
|
||||
const response = await client.session.messages({ sessionID, directory, limit: 20 });
|
||||
const records = Array.isArray(response?.data) ? response.data : [];
|
||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||
const info = records[index]?.info;
|
||||
if (info?.role !== 'user') continue;
|
||||
const providerID = asNonEmptyString(info.model?.providerID);
|
||||
const modelID = asNonEmptyString(info.model?.modelID);
|
||||
if (!providerID || !modelID) continue;
|
||||
return {
|
||||
model: { providerID, modelID },
|
||||
agent: asNonEmptyString(info.agent),
|
||||
variant: asNonEmptyString(info.model?.variant),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Explicit model/agent/variant are never checked by `prompt_async`: an unknown
|
||||
// agent makes the forked run fail silently, leaving a session with no message.
|
||||
// Reject them before any session, worktree, or goal side effect happens.
|
||||
const validateRequestedSelection = async ({ directory, requestedModel, requestedAgent, requestedVariant }) => {
|
||||
if (!requestedModel && !requestedAgent && !requestedVariant) return;
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
const { providers, agents } = await fetchSelectionInputs({
|
||||
buildOpenCodeUrl,
|
||||
authHeaders,
|
||||
directory,
|
||||
readSettingsFromDiskMigrated,
|
||||
});
|
||||
|
||||
// An empty list means the lookup failed or returned nothing authoritative;
|
||||
// it must not turn a valid selection into a rejection.
|
||||
if (requestedAgent && agents.length > 0) {
|
||||
const agent = agents.find((entry) => entry?.name === requestedAgent) || null;
|
||||
if (!agent) {
|
||||
throw new OpenChamberControlError(`Unknown agent '${requestedAgent}' for ${directory}`, 400);
|
||||
}
|
||||
if (!isPrimaryAgentMode(agent.mode)) {
|
||||
throw new OpenChamberControlError(`Agent '${requestedAgent}' is a subagent and cannot receive a prompt directly`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedModel && providers.length > 0) {
|
||||
if (!hasProviderModel(providers, requestedModel.providerID, requestedModel.modelID)) {
|
||||
throw new OpenChamberControlError(
|
||||
`Unknown model '${requestedModel.providerID}/${requestedModel.modelID}' for ${directory}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (requestedVariant
|
||||
&& !resolveVariant(providers, requestedModel.providerID, requestedModel.modelID, requestedVariant)) {
|
||||
throw new OpenChamberControlError(
|
||||
`Unknown variant '${requestedVariant}' for model '${requestedModel.providerID}/${requestedModel.modelID}'`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchPrompt = async ({
|
||||
client,
|
||||
baseUrl,
|
||||
authHeaders,
|
||||
sessionID,
|
||||
directory,
|
||||
prompt,
|
||||
goalInput,
|
||||
requestedModel,
|
||||
requestedAgent,
|
||||
requestedVariant,
|
||||
reuseSessionSelection = false,
|
||||
}) => {
|
||||
let model = requestedModel;
|
||||
let agent = requestedAgent;
|
||||
let variant = requestedVariant;
|
||||
if (reuseSessionSelection && (!model || !agent)) {
|
||||
const previous = await fetchLastUserSelection({ client, sessionID, directory });
|
||||
if (previous) {
|
||||
if (!model && previous.model) {
|
||||
model = previous.model;
|
||||
if (variant == null) variant = previous.variant ?? undefined;
|
||||
}
|
||||
if (!agent && previous.agent) agent = previous.agent;
|
||||
}
|
||||
}
|
||||
if (!model || !agent) {
|
||||
const inputs = await fetchSelectionInputs({
|
||||
buildOpenCodeUrl,
|
||||
authHeaders,
|
||||
directory,
|
||||
readSettingsFromDiskMigrated,
|
||||
});
|
||||
const defaults = resolveDefaultSelection(inputs);
|
||||
if (!model) {
|
||||
model = defaults.model;
|
||||
if (variant == null) variant = defaults.variant;
|
||||
}
|
||||
agent = agent || defaults.agent;
|
||||
}
|
||||
if (!model) {
|
||||
const error = new Error('No model is configured or available for the requested directory');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expandedPrompt = expandSnippets(prompt, directory);
|
||||
const parsedCommand = parseScheduledCommandPrompt(prompt);
|
||||
let resolvedCommand = null;
|
||||
if (parsedCommand) {
|
||||
try {
|
||||
const response = await client.command.list({ directory });
|
||||
const commands = Array.isArray(response?.data) ? response.data : [];
|
||||
const command = commands.find((candidate) => candidate?.name === parsedCommand.command);
|
||||
if (command) resolvedCommand = { ...parsedCommand, template: command.template };
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (goalInput.enabled) {
|
||||
const commandObjective = resolvedCommand
|
||||
? expandCommandGoalObjective(resolvedCommand.template, resolvedCommand.arguments)
|
||||
: null;
|
||||
await (createSessionGoalOverride || createSessionGoal)({
|
||||
baseUrl,
|
||||
authHeaders,
|
||||
sessionID,
|
||||
directory,
|
||||
objective: commandObjective ?? expandedPrompt,
|
||||
tokenBudget: goalInput.tokenBudget,
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
onWarning: (message, error) => console.warn(`[OpenChamberSessions] ${message}:`, error?.message || error),
|
||||
});
|
||||
}
|
||||
|
||||
const markGoalPartial = (error) => {
|
||||
if (goalInput.enabled && error && typeof error === 'object') error.goalConfigured = true;
|
||||
return error;
|
||||
};
|
||||
|
||||
if (resolvedCommand) {
|
||||
try {
|
||||
await client.session.command({
|
||||
sessionID,
|
||||
directory,
|
||||
command: resolvedCommand.command,
|
||||
arguments: resolvedCommand.arguments,
|
||||
...(agent ? { agent } : {}),
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
...(variant ? { variant } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw markGoalPartial(error);
|
||||
}
|
||||
} else {
|
||||
const baseline = await latestUserMessageID({ client, sessionID, directory });
|
||||
try {
|
||||
await runPromptAsync({
|
||||
baseUrl,
|
||||
authHeaders,
|
||||
sessionID,
|
||||
directory,
|
||||
payload: {
|
||||
model,
|
||||
...(agent ? { agent } : {}),
|
||||
...(variant ? { variant } : {}),
|
||||
parts: [
|
||||
{ type: 'text', text: expandedPrompt },
|
||||
...(goalInput.enabled
|
||||
? [{ type: 'text', text: buildGoalIntroText(goalInput.tokenBudget), synthetic: true }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw markGoalPartial(error);
|
||||
}
|
||||
const landed = await waitForPromptLanded({
|
||||
client,
|
||||
sessionID,
|
||||
directory,
|
||||
baselineUserMessageID: baseline.messageID,
|
||||
});
|
||||
if (!landed) {
|
||||
return {
|
||||
model,
|
||||
agent,
|
||||
variant,
|
||||
promptDispatched: false,
|
||||
dispatchedAsCommand: false,
|
||||
promptError: 'OpenCode accepted the prompt but it never appeared in the session',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
|
||||
};
|
||||
|
||||
const create = async (payload = {}) => {
|
||||
const title = asNonEmptyString(payload.title);
|
||||
const prompt = asNonEmptyString(payload.prompt);
|
||||
const goalInput = resolveGoalInput(payload, prompt);
|
||||
if (!goalInput.ok) {
|
||||
throw new OpenChamberControlError(goalInput.error, 400);
|
||||
}
|
||||
const model = resolveRequestedModel(payload);
|
||||
const agent = asNonEmptyString(payload.agent);
|
||||
const variant = asNonEmptyString(payload.variant);
|
||||
|
||||
const resolvedDirectory = await resolveRequestedDirectory({
|
||||
payload,
|
||||
readSettingsFromDiskMigrated,
|
||||
sanitizeProjects,
|
||||
validateDirectoryPath,
|
||||
});
|
||||
if (!resolvedDirectory.ok) {
|
||||
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
|
||||
}
|
||||
|
||||
const worktreeInput = resolveWorktreeInput(payload);
|
||||
let worktree = null;
|
||||
let sessionDirectory = resolvedDirectory.directory;
|
||||
if (payload?.worktree && !worktreeInput) {
|
||||
throw new OpenChamberControlError('worktree.name is required when worktree is provided', 400);
|
||||
}
|
||||
|
||||
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
|
||||
|
||||
if (prompt) {
|
||||
await validateRequestedSelection({
|
||||
directory: resolvedDirectory.directory,
|
||||
requestedModel: model,
|
||||
requestedAgent: agent,
|
||||
requestedVariant: variant,
|
||||
});
|
||||
}
|
||||
|
||||
if (worktreeInput) {
|
||||
worktree = await createWorktree(resolvedDirectory.directory, worktreeInput);
|
||||
sessionDirectory = worktree.path;
|
||||
}
|
||||
|
||||
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
|
||||
const sessionID = await createSession({
|
||||
client,
|
||||
baseUrl,
|
||||
authHeaders,
|
||||
directory: sessionDirectory,
|
||||
...(title ? { title } : {}),
|
||||
});
|
||||
|
||||
let dispatch = { model, agent, variant, promptDispatched: false, dispatchedAsCommand: false };
|
||||
if (prompt) {
|
||||
dispatch = await dispatchPrompt({
|
||||
client,
|
||||
baseUrl,
|
||||
authHeaders,
|
||||
sessionID,
|
||||
directory: sessionDirectory,
|
||||
prompt,
|
||||
goalInput,
|
||||
requestedModel: model,
|
||||
requestedAgent: agent,
|
||||
requestedVariant: variant,
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
sessionId: sessionID,
|
||||
directory: sessionDirectory,
|
||||
...(resolvedDirectory.projectId ? { projectId: resolvedDirectory.projectId } : {}),
|
||||
...(title ? { title } : {}),
|
||||
...(worktree ? { worktree } : {}),
|
||||
...(prompt && dispatch.model ? { model: dispatch.model } : {}),
|
||||
...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}),
|
||||
...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}),
|
||||
promptDispatched: dispatch.promptDispatched,
|
||||
...(dispatch.promptError ? { promptError: dispatch.promptError } : {}),
|
||||
dispatchedAsCommand: dispatch.dispatchedAsCommand,
|
||||
...(goalInput.enabled ? { goalEnabled: true } : {}),
|
||||
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
emitSessionCreatedEvent?.({
|
||||
sessionID,
|
||||
directory: sessionDirectory,
|
||||
...(resolvedDirectory.projectId ? { projectID: resolvedDirectory.projectId } : {}),
|
||||
...(title ? { title } : {}),
|
||||
...(worktree ? { worktree } : {}),
|
||||
...(prompt && dispatch.model ? { model: dispatch.model } : {}),
|
||||
...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}),
|
||||
...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}),
|
||||
promptDispatched: dispatch.promptDispatched,
|
||||
dispatchedAsCommand: dispatch.dispatchedAsCommand,
|
||||
...(goalInput.enabled ? { goalEnabled: true } : {}),
|
||||
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const runExisting = async (action, sourceSessionId, payload = {}) => {
|
||||
const sourceSessionID = asNonEmptyString(sourceSessionId);
|
||||
const prompt = asNonEmptyString(payload.prompt);
|
||||
if (!sourceSessionID) throw new OpenChamberControlError('sessionId is required', 400);
|
||||
if (!prompt) throw new OpenChamberControlError('prompt is required', 400);
|
||||
const goalInput = resolveGoalInput(payload, prompt);
|
||||
if (!goalInput.ok) throw new OpenChamberControlError(goalInput.error, 400);
|
||||
const requestedModel = resolveRequestedModel(payload);
|
||||
|
||||
let targetSessionID = sourceSessionID;
|
||||
let targetSession = null;
|
||||
let directory = null;
|
||||
try {
|
||||
const resolvedDirectory = await resolveRequestedDirectory({
|
||||
payload,
|
||||
readSettingsFromDiskMigrated,
|
||||
sanitizeProjects,
|
||||
validateDirectoryPath,
|
||||
});
|
||||
if (!resolvedDirectory.ok) {
|
||||
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
|
||||
}
|
||||
directory = resolvedDirectory.directory;
|
||||
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
|
||||
|
||||
await validateRequestedSelection({
|
||||
directory,
|
||||
requestedModel,
|
||||
requestedAgent: asNonEmptyString(payload.agent),
|
||||
requestedVariant: asNonEmptyString(payload.variant),
|
||||
});
|
||||
|
||||
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
|
||||
if (action === 'fork') {
|
||||
targetSession = await forkSession({
|
||||
client,
|
||||
sessionID: sourceSessionID,
|
||||
directory,
|
||||
messageID: asNonEmptyString(payload.messageId) || undefined,
|
||||
});
|
||||
targetSessionID = targetSession.id;
|
||||
}
|
||||
|
||||
const baselineAssistantMessageId = await latestCompletedAssistantMessageID({
|
||||
client,
|
||||
sessionID: targetSessionID,
|
||||
directory,
|
||||
});
|
||||
|
||||
const dispatch = await dispatchPrompt({
|
||||
client,
|
||||
baseUrl,
|
||||
authHeaders,
|
||||
sessionID: targetSessionID,
|
||||
directory,
|
||||
prompt,
|
||||
goalInput,
|
||||
requestedModel,
|
||||
requestedAgent: asNonEmptyString(payload.agent),
|
||||
requestedVariant: asNonEmptyString(payload.variant),
|
||||
reuseSessionSelection: true,
|
||||
});
|
||||
const result = {
|
||||
action,
|
||||
sessionId: targetSessionID,
|
||||
directory,
|
||||
...(action === 'fork' ? { sourceSessionId: sourceSessionID } : {}),
|
||||
...(targetSession?.title ? { title: targetSession.title } : {}),
|
||||
...(baselineAssistantMessageId ? { baselineAssistantMessageId } : {}),
|
||||
model: dispatch.model,
|
||||
...(dispatch.agent ? { agent: dispatch.agent } : {}),
|
||||
...(dispatch.variant ? { variant: dispatch.variant } : {}),
|
||||
promptDispatched: dispatch.promptDispatched,
|
||||
...(dispatch.promptError ? { promptError: dispatch.promptError } : {}),
|
||||
dispatchedAsCommand: dispatch.dispatchedAsCommand,
|
||||
...(goalInput.enabled ? { goalEnabled: true } : {}),
|
||||
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
|
||||
};
|
||||
|
||||
if (action === 'fork') {
|
||||
try {
|
||||
emitSessionCreatedEvent?.({
|
||||
sessionID: targetSessionID,
|
||||
directory,
|
||||
sourceSessionID,
|
||||
...(targetSession?.title ? { title: targetSession.title } : {}),
|
||||
model: dispatch.model,
|
||||
...(dispatch.agent ? { agent: dispatch.agent } : {}),
|
||||
...(dispatch.variant ? { variant: dispatch.variant } : {}),
|
||||
promptDispatched: dispatch.promptDispatched,
|
||||
dispatchedAsCommand: dispatch.dispatchedAsCommand,
|
||||
...(goalInput.enabled ? { goalEnabled: true } : {}),
|
||||
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const statusCode = Number(error?.statusCode) || 500;
|
||||
const forkCreated = action === 'fork' && targetSessionID !== sourceSessionID;
|
||||
const goalConfigured = error?.goalConfigured === true;
|
||||
throw new OpenChamberControlError(
|
||||
error instanceof Error ? error.message : `Failed to ${action} session`,
|
||||
statusCode,
|
||||
{
|
||||
...(forkCreated || goalConfigured
|
||||
? {
|
||||
partial: true,
|
||||
partialAction: forkCreated ? 'fork-created' : 'goal-configured',
|
||||
sessionId: targetSessionID,
|
||||
directory,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
create,
|
||||
send: (sessionID, payload) => runExisting('send', sessionID, payload),
|
||||
fork: (sessionID, payload) => runExisting('fork', sessionID, payload),
|
||||
};
|
||||
};
|
||||
|
||||
const sendServiceError = (res, error, fallback) => {
|
||||
const controlError = asControlError(error, fallback);
|
||||
return res.status(controlError.statusCode).json({
|
||||
error: controlError.message,
|
||||
...(controlError.partial === true ? {
|
||||
partial: true,
|
||||
partialAction: controlError.partialAction,
|
||||
sessionId: controlError.sessionId,
|
||||
directory: controlError.directory,
|
||||
} : {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const registerOpenChamberSessionRoutes = (app, dependencies) => {
|
||||
const service = dependencies.sessionService || createOpenChamberSessionService(dependencies);
|
||||
|
||||
app.post('/api/openchamber/sessions', express.json({ limit: '1mb' }), async (req, res) => {
|
||||
try {
|
||||
return res.json(await service.create(req.body && typeof req.body === 'object' ? req.body : {}));
|
||||
} catch (error) {
|
||||
console.error('[OpenChamberSessions] failed to create session:', error);
|
||||
return sendServiceError(res, error, 'Failed to create session');
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/openchamber/sessions/:sessionId/send',
|
||||
express.json({ limit: '1mb' }),
|
||||
async (req, res) => {
|
||||
try {
|
||||
return res.json(await service.send(req.params.sessionId, req.body));
|
||||
} catch (error) {
|
||||
console.error('[OpenChamberSessions] failed to send session:', error);
|
||||
return sendServiceError(res, error, 'Failed to send session');
|
||||
}
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/api/openchamber/sessions/:sessionId/fork',
|
||||
express.json({ limit: '1mb' }),
|
||||
async (req, res) => {
|
||||
try {
|
||||
return res.json(await service.fork(req.params.sessionId, req.body));
|
||||
} catch (error) {
|
||||
console.error('[OpenChamberSessions] failed to fork session:', error);
|
||||
return sendServiceError(res, error, 'Failed to fork session');
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,725 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const createWorktreeMock = vi.fn(async () => ({
|
||||
head: 'abc123',
|
||||
name: 'side-task',
|
||||
branch: 'openchamber/side-task',
|
||||
path: '/repo/worktrees/side-task',
|
||||
}));
|
||||
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
|
||||
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
|
||||
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
|
||||
|
||||
let existingSessionMessages = [];
|
||||
let dispatchedUserMessageSeq = 0;
|
||||
|
||||
// The service confirms a prompt landed by watching for a new user message, so
|
||||
// the default mock behaves like OpenCode recording each dispatched prompt.
|
||||
const setSessionMessages = (messages) => {
|
||||
existingSessionMessages = messages;
|
||||
};
|
||||
|
||||
const recordedSessionMessages = async () => {
|
||||
dispatchedUserMessageSeq += 1;
|
||||
return {
|
||||
data: [
|
||||
...existingSessionMessages,
|
||||
{
|
||||
info: {
|
||||
id: `msg_dispatched_${dispatchedUserMessageSeq}`,
|
||||
role: 'user',
|
||||
time: { created: 1000 + dispatchedUserMessageSeq },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
// Selection inputs are fetched whenever a request names a model, agent, or
|
||||
// variant, so every prompt-dispatching fetch mock must answer them.
|
||||
const selectionInputResponse = (url) => {
|
||||
const text = String(url);
|
||||
if (text.includes('/config/providers')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
providers: [
|
||||
{ id: 'openai', models: [{ id: 'gpt-5.5', variants: { high: {} } }] },
|
||||
{ id: 'anthropic', models: [{ id: 'claude-sonnet-5', variants: { high: {} } }] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (text.includes('/agent')) {
|
||||
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] };
|
||||
}
|
||||
if (text.includes('/config')) return { ok: true, json: async () => ({}) };
|
||||
return null;
|
||||
};
|
||||
const sessionCommandMock = vi.fn(async () => ({ data: {} }));
|
||||
const commandListMock = vi.fn(async () => ({ data: [] }));
|
||||
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
|
||||
|
||||
let registerOpenChamberSessionRoutes;
|
||||
|
||||
vi.mock('@opencode-ai/sdk/v2', () => ({
|
||||
createOpencodeClient: () => ({
|
||||
session: {
|
||||
create: sessionCreateMock,
|
||||
fork: sessionForkMock,
|
||||
messages: sessionMessagesMock,
|
||||
command: sessionCommandMock,
|
||||
},
|
||||
command: {
|
||||
list: commandListMock,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../git/index.js', () => ({
|
||||
createWorktree: (...args) => globalThis.__openchamberCreateWorktreeMock(...args),
|
||||
}));
|
||||
|
||||
const createApp = (overrides = {}, options = {}) => {
|
||||
const app = express();
|
||||
if (options.globalJson !== false) {
|
||||
app.use(express.json());
|
||||
}
|
||||
const calls = [];
|
||||
registerOpenChamberSessionRoutes(app, {
|
||||
readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'proj_1', path: '/repo/app' }] }),
|
||||
sanitizeProjects: (projects) => projects,
|
||||
validateDirectoryPath: async (directory) => ({ ok: true, directory }),
|
||||
buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
|
||||
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test' }),
|
||||
waitForOpenCodeReady: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
});
|
||||
return { app, calls };
|
||||
};
|
||||
|
||||
describe('openchamber session routes', () => {
|
||||
beforeAll(async () => {
|
||||
({ registerOpenChamberSessionRoutes } = await import('./routes.js'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
createWorktreeMock.mockClear();
|
||||
sessionCreateMock.mockClear();
|
||||
sessionForkMock.mockClear();
|
||||
existingSessionMessages = [];
|
||||
dispatchedUserMessageSeq = 0;
|
||||
sessionMessagesMock.mockReset();
|
||||
sessionMessagesMock.mockImplementation(recordedSessionMessages);
|
||||
sessionCommandMock.mockReset();
|
||||
sessionCommandMock.mockResolvedValue({ data: {} });
|
||||
commandListMock.mockReset();
|
||||
commandListMock.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it('creates a session for a directory', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
|
||||
try {
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', title: 'Side task' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.sessionId).toBeTruthy();
|
||||
expect(response.body.sessionId).toBe('ses_123');
|
||||
expect(response.body.directory).toBe('/repo/app');
|
||||
expect(response.body.promptDispatched).toBe(false);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://opencode.test/session?directory=%2Frepo%2Fapp',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ directory: '/repo/app', title: 'Side task' }),
|
||||
}),
|
||||
);
|
||||
expect(sessionCreateMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('parses JSON body without global middleware', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
|
||||
try {
|
||||
const { app } = createApp({}, { globalJson: false });
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.sessionId).toBe('ses_123');
|
||||
expect(response.body.directory).toBe('/repo/app');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('emits a session-created event after creating a session', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const emitSessionCreatedEvent = vi.fn();
|
||||
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
|
||||
try {
|
||||
const { app } = createApp({ emitSessionCreatedEvent });
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', title: 'Side task' })
|
||||
.expect(200);
|
||||
|
||||
expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionID: 'ses_123',
|
||||
directory: '/repo/app',
|
||||
title: 'Side task',
|
||||
promptDispatched: false,
|
||||
dispatchedAsCommand: false,
|
||||
}));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves default model and agent when prompt omits them', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => {
|
||||
const text = String(url);
|
||||
if (text.includes('/prompt_async')) {
|
||||
return { ok: true, text: async () => '' };
|
||||
}
|
||||
if (text.includes('/config/providers')) {
|
||||
return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
|
||||
}
|
||||
if (text.includes('/agent')) {
|
||||
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
|
||||
}
|
||||
if (text.includes('/config')) {
|
||||
return { ok: true, json: async () => ({}) };
|
||||
}
|
||||
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
const { app } = createApp({
|
||||
readSettingsFromDiskMigrated: async () => ({
|
||||
defaultModel: 'openai/gpt-5.5',
|
||||
defaultAgent: 'build',
|
||||
projects: [{ id: 'proj_1', path: '/repo/app' }],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', prompt: 'Run this' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
|
||||
expect(response.body.agent).toBe('build');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://opencode.test/config/providers?directory=%2Frepo%2Fapp',
|
||||
expect.any(Object),
|
||||
);
|
||||
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
|
||||
expect(JSON.parse(promptCall?.[1]?.body)).toMatchObject({
|
||||
model: { providerID: 'openai', modelID: 'gpt-5.5' },
|
||||
agent: 'build',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('dispatches an initial prompt when model is provided', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => {
|
||||
if (String(url).includes('/prompt_async')) {
|
||||
return { ok: true, text: async () => '' };
|
||||
}
|
||||
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.sessionId).toBe('ses_123');
|
||||
expect(response.body.promptDispatched).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fapp',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('creates goal metadata before dispatching the initial goal prompt', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => {
|
||||
if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
|
||||
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
||||
});
|
||||
const createSessionGoal = vi.fn(async () => undefined);
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
const { app } = createApp({ createSessionGoal });
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({
|
||||
directory: '/repo/app',
|
||||
prompt: 'Finish and verify the migration',
|
||||
model: 'openai/gpt-5.5',
|
||||
goal: true,
|
||||
goalTokenBudget: 200000,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
|
||||
const promptPayload = JSON.parse(promptCall[1].body);
|
||||
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionID: 'ses_123',
|
||||
directory: '/repo/app',
|
||||
objective: 'Finish and verify the migration',
|
||||
tokenBudget: 200000,
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-5.5',
|
||||
}));
|
||||
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder.at(-1));
|
||||
expect(promptPayload.parts).toEqual([
|
||||
{ type: 'text', text: 'Finish and verify the migration' },
|
||||
expect.objectContaining({ type: 'text', synthetic: true }),
|
||||
]);
|
||||
expect(response.body).toMatchObject({ goalEnabled: true, goalTokenBudget: 200000, promptDispatched: true });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid goal requests before creating a session', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
const { app } = createApp();
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', goal: true })
|
||||
.expect(400, { error: 'prompt is required when goal is enabled' });
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', prompt: 'Run', goalTokenBudget: 200000 })
|
||||
.expect(400, { error: 'goalTokenBudget requires goal' });
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', prompt: 'Run', goal: true, goalTokenBudget: 999 })
|
||||
.expect(400, { error: 'goalTokenBudget must be an integer from 1000 to 100000000' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a worktree before creating a session', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
if (String(url).includes('/prompt_async')) {
|
||||
return { ok: true, text: async () => '' };
|
||||
}
|
||||
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
||||
});
|
||||
try {
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({
|
||||
directory: '/repo/app',
|
||||
worktree: { name: 'side-task', branchName: 'openchamber/side-task', startRef: 'main' },
|
||||
setUpstream: false,
|
||||
prompt: 'Run this',
|
||||
model: 'openai/gpt-5.5',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createWorktreeMock).toHaveBeenCalledWith('/repo/app', {
|
||||
mode: 'new',
|
||||
name: 'side-task',
|
||||
branchName: 'openchamber/side-task',
|
||||
startRef: 'main',
|
||||
setUpstream: false,
|
||||
});
|
||||
expect(response.body.directory).toBe('/repo/worktrees/side-task');
|
||||
expect(response.body.worktree.path).toBe('/repo/worktrees/side-task');
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fworktrees%2Fside-task',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('sends a goal prompt to an existing session after creating goal metadata', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
|
||||
const createSessionGoal = vi.fn(async () => undefined);
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
setSessionMessages([{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }]);
|
||||
const { app } = createApp({ createSessionGoal });
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/send')
|
||||
.send({
|
||||
directory: '/repo/app',
|
||||
prompt: 'Apply and verify the review feedback',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
variant: 'high',
|
||||
goal: true,
|
||||
goalTokenBudget: 200000,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
action: 'send',
|
||||
sessionId: 'ses_source',
|
||||
directory: '/repo/app',
|
||||
promptDispatched: true,
|
||||
goalEnabled: true,
|
||||
baselineAssistantMessageId: 'msg_before',
|
||||
});
|
||||
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionID: 'ses_source',
|
||||
directory: '/repo/app',
|
||||
objective: 'Apply and verify the review feedback',
|
||||
}));
|
||||
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
|
||||
expect(promptCall?.[0]).toBe('http://opencode.test/session/ses_source/prompt_async?directory=%2Frepo%2Fapp');
|
||||
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder.at(-1));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the expanded slash-command template as the goal objective before command dispatch', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const createSessionGoal = vi.fn(async () => undefined);
|
||||
commandListMock.mockResolvedValue({
|
||||
data: [{
|
||||
name: 'issue--to-pr',
|
||||
template: 'Take $ARGUMENTS from issue through a verified pull request. Confirm the PR covers $ARGUMENTS.',
|
||||
}],
|
||||
});
|
||||
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url));
|
||||
try {
|
||||
const { app } = createApp({ createSessionGoal });
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/send')
|
||||
.send({
|
||||
directory: '/repo/app',
|
||||
prompt: '/issue--to-pr LIN-123',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
goal: true,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
|
||||
objective: 'Take LIN-123 from issue through a verified pull request. Confirm the PR covers LIN-123.',
|
||||
}));
|
||||
expect(sessionCommandMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: 'issue--to-pr',
|
||||
arguments: 'LIN-123',
|
||||
}));
|
||||
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
|
||||
expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true });
|
||||
expect(globalThis.fetch.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the previous session selection when send omits model, agent, and variant', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
setSessionMessages([
|
||||
{
|
||||
info: {
|
||||
id: 'msg_user',
|
||||
role: 'user',
|
||||
agent: 'plan',
|
||||
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5', variant: 'high' },
|
||||
time: { created: 5 },
|
||||
},
|
||||
},
|
||||
{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } },
|
||||
]);
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/send')
|
||||
.send({ directory: '/repo/app', prompt: 'Continue where you left off' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
action: 'send',
|
||||
sessionId: 'ses_source',
|
||||
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
|
||||
agent: 'plan',
|
||||
variant: 'high',
|
||||
promptDispatched: true,
|
||||
});
|
||||
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
|
||||
const promptBody = JSON.parse(promptCall[1].body);
|
||||
expect(promptBody).toMatchObject({
|
||||
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
|
||||
agent: 'plan',
|
||||
variant: 'high',
|
||||
});
|
||||
// The default-selection inputs (config/providers/agents) must not be consulted.
|
||||
expect(fetchMock.mock.calls.every(([url]) => String(url).includes('/prompt_async'))).toBe(true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('forks from a message, dispatches the prompt, and emits the new session', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const emitSessionCreatedEvent = vi.fn();
|
||||
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
|
||||
try {
|
||||
const { app } = createApp({ emitSessionCreatedEvent });
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/fork')
|
||||
.send({
|
||||
directory: '/repo/app',
|
||||
messageId: 'msg_branch_point',
|
||||
prompt: 'Try the alternative implementation',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
variant: 'high',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(sessionForkMock).toHaveBeenCalledWith({
|
||||
sessionID: 'ses_source',
|
||||
directory: '/repo/app',
|
||||
messageID: 'msg_branch_point',
|
||||
});
|
||||
expect(response.body).toMatchObject({
|
||||
action: 'fork',
|
||||
sourceSessionId: 'ses_source',
|
||||
sessionId: 'ses_fork',
|
||||
directory: '/repo/app',
|
||||
promptDispatched: true,
|
||||
});
|
||||
expect(sessionMessagesMock).toHaveBeenCalledWith({
|
||||
sessionID: 'ses_fork',
|
||||
directory: '/repo/app',
|
||||
limit: 100,
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://opencode.test/session/ses_fork/prompt_async?directory=%2Frepo%2Fapp',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionID: 'ses_fork',
|
||||
sourceSessionID: 'ses_source',
|
||||
directory: '/repo/app',
|
||||
promptDispatched: true,
|
||||
}));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects send and fork requests without a prompt before calling OpenCode', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
const { app } = createApp();
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/send')
|
||||
.send({ directory: '/repo/app' })
|
||||
.expect(400, { error: 'prompt is required' });
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/fork')
|
||||
.send({ directory: '/repo/app' })
|
||||
.expect(400, { error: 'prompt is required' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(sessionForkMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('reports the forked session when prompt dispatch fails', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: false, status: 500, text: async () => 'dispatch failed' });
|
||||
try {
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/fork')
|
||||
.send({
|
||||
directory: '/repo/app',
|
||||
prompt: 'Try another approach',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
variant: 'high',
|
||||
})
|
||||
.expect(500);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
partial: true,
|
||||
partialAction: 'fork-created',
|
||||
sessionId: 'ses_fork',
|
||||
directory: '/repo/app',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not apply a default variant to an explicitly requested model', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => {
|
||||
const text = String(url);
|
||||
if (text.includes('/prompt_async')) return { ok: true, text: async () => '' };
|
||||
if (text.includes('/config/providers')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
providers: [
|
||||
{ id: 'openai', models: { requested: { id: 'requested' }, default: { id: 'default', variants: { high: {} } } } },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (text.includes('/agent')) return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
|
||||
if (text.includes('/config')) return { ok: true, json: async () => ({}) };
|
||||
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
const { app } = createApp({
|
||||
readSettingsFromDiskMigrated: async () => ({
|
||||
defaultModel: 'openai/default',
|
||||
defaultVariant: 'high',
|
||||
projects: [{ id: 'proj_1', path: '/repo/app' }],
|
||||
}),
|
||||
});
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/send')
|
||||
.send({ directory: '/repo/app', prompt: 'Continue', model: 'openai/requested', agent: 'build' })
|
||||
.expect(200);
|
||||
|
||||
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
|
||||
expect(JSON.parse(promptCall[1].body)).not.toHaveProperty('variant');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown agent before creating a session or worktree', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
const { app } = createApp();
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({
|
||||
directory: '/repo/app',
|
||||
prompt: 'Run this',
|
||||
agent: 'not-an-agent',
|
||||
worktree: { name: 'side-task' },
|
||||
})
|
||||
.expect(400, { error: "Unknown agent 'not-an-agent' for /repo/app" });
|
||||
|
||||
expect(createWorktreeMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url) === 'http://opencode.test/session?directory=%2Frepo%2Fapp')).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown model and an unknown variant before dispatching', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
const { app } = createApp();
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-nope' })
|
||||
.expect(400, { error: "Unknown model 'openai/gpt-nope' for /repo/app" });
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' })
|
||||
.expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" });
|
||||
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('reports promptDispatched false when the accepted prompt never reaches the session', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => {
|
||||
if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
|
||||
return selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) };
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
sessionMessagesMock.mockResolvedValue({ data: [] });
|
||||
try {
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions')
|
||||
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.sessionId).toBe('ses_123');
|
||||
expect(response.body.promptDispatched).toBe(false);
|
||||
expect(response.body.promptError).toBeTruthy();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it('does not retry a failed slash command as a normal prompt', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url) => selectionInputResponse(url));
|
||||
commandListMock.mockResolvedValue({ data: [{ name: 'review' }] });
|
||||
sessionCommandMock.mockRejectedValue(new Error('command response failed'));
|
||||
globalThis.fetch = fetchMock;
|
||||
try {
|
||||
const { app } = createApp();
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions/ses_source/send')
|
||||
.send({
|
||||
directory: '/repo/app',
|
||||
prompt: '/review fix this',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
variant: 'high',
|
||||
})
|
||||
.expect(500);
|
||||
|
||||
expect(sessionCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,9 @@ const __dirname = path.dirname(__filename);
|
||||
const PACKAGE_NAME = '@openchamber/web';
|
||||
const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/');
|
||||
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;
|
||||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md';
|
||||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md';
|
||||
const GITHUB_RELEASES_URL = 'https://github.com/openchamber/openchamber/releases';
|
||||
const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/openchamber/openchamber/releases';
|
||||
let cachedDetectedPm = null;
|
||||
|
||||
function getSpawnSyncBaseOptions() {
|
||||
@@ -29,7 +31,7 @@ function getOpenChamberConfigDir() {
|
||||
}
|
||||
|
||||
function sanitizeInstallScope(scope) {
|
||||
if (scope === 'desktop-electron' || scope === 'vscode' || scope === 'web') return scope;
|
||||
if (scope === 'desktop-electron' || scope === 'vscode' || scope === 'web' || scope === 'mobile-capacitor') return scope;
|
||||
return 'web';
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ function mapArch(value) {
|
||||
}
|
||||
|
||||
function normalizeAppType(value) {
|
||||
if (value === 'web' || value === 'desktop-electron' || value === 'vscode') return value;
|
||||
if (value === 'web' || value === 'desktop-electron' || value === 'vscode' || value === 'mobile-capacitor') return value;
|
||||
return 'web';
|
||||
}
|
||||
|
||||
@@ -75,7 +77,7 @@ function normalizeDeviceClass(value) {
|
||||
}
|
||||
|
||||
function normalizePlatform(value) {
|
||||
if (value === 'macos' || value === 'windows' || value === 'linux' || value === 'web') return value;
|
||||
if (value === 'macos' || value === 'windows' || value === 'linux' || value === 'web' || value === 'android' || value === 'ios') return value;
|
||||
return mapPlatform(process.platform);
|
||||
}
|
||||
|
||||
@@ -84,13 +86,49 @@ function normalizeArch(value) {
|
||||
return mapArch(process.arch);
|
||||
}
|
||||
|
||||
async function resolveAndroidApkUrl(version, candidateUrl) {
|
||||
if (typeof candidateUrl === 'string') {
|
||||
try {
|
||||
if (new URL(candidateUrl).pathname.toLowerCase().endsWith('.apk')) return candidateUrl;
|
||||
} catch {
|
||||
// Resolve malformed or non-APK values from the authoritative release assets below.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${GITHUB_RELEASES_API_URL}/tags/v${version}`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'openchamber-update-check',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const release = await response.json();
|
||||
const apkAssets = Array.isArray(release?.assets)
|
||||
? release.assets.filter((asset) => (
|
||||
typeof asset?.name === 'string'
|
||||
&& asset.name.toLowerCase().endsWith('.apk')
|
||||
&& typeof asset.browser_download_url === 'string'
|
||||
))
|
||||
: [];
|
||||
const canonicalAsset = apkAssets.find((asset) => /^OpenChamber-.+-android\.apk$/i.test(asset.name));
|
||||
return (canonicalAsset || apkAssets[0])?.browser_download_url;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdatesFromApi(currentVersion, options = {}) {
|
||||
try {
|
||||
const appType = normalizeAppType(options.appType);
|
||||
const hostPlatform = mapPlatform(process.platform);
|
||||
const hostArch = mapArch(process.arch);
|
||||
const platform = appType === 'vscode' ? normalizePlatform(options.platform) : hostPlatform;
|
||||
const arch = appType === 'vscode' ? normalizeArch(options.arch) : hostArch;
|
||||
const shouldTrustClientPlatform = appType === 'desktop-electron' || appType === 'vscode' || appType === 'mobile-capacitor';
|
||||
const platform = shouldTrustClientPlatform ? normalizePlatform(options.platform) : hostPlatform;
|
||||
const arch = shouldTrustClientPlatform ? normalizeArch(options.arch) : hostArch;
|
||||
const reportUsage = options.reportUsage !== false;
|
||||
const payload = {
|
||||
appType,
|
||||
deviceClass: normalizeDeviceClass(options.deviceClass),
|
||||
@@ -98,9 +136,9 @@ async function checkForUpdatesFromApi(currentVersion, options = {}) {
|
||||
arch,
|
||||
channel: 'stable',
|
||||
currentVersion,
|
||||
installId: getOrCreateInstallId(appType),
|
||||
installId: reportUsage ? (options.installId || getOrCreateInstallId(appType)) : undefined,
|
||||
instanceMode: options.instanceMode || 'unknown',
|
||||
reportUsage: options.reportUsage !== false,
|
||||
reportUsage,
|
||||
};
|
||||
|
||||
const response = await fetch(UPDATE_CHECK_URL, {
|
||||
@@ -120,11 +158,23 @@ async function checkForUpdatesFromApi(currentVersion, options = {}) {
|
||||
const versionComparison = compareVersions(data.latestVersion, currentVersion);
|
||||
if (versionComparison < 0) return null;
|
||||
|
||||
const releaseUrl = `${GITHUB_RELEASES_URL}/tag/v${data.latestVersion}`;
|
||||
const downloadUrl = typeof data.downloadUrl === 'string'
|
||||
? data.downloadUrl
|
||||
: typeof data.download?.url === 'string'
|
||||
? data.download.url
|
||||
: undefined;
|
||||
const updateAvailable = Boolean(data.updateAvailable) && versionComparison > 0;
|
||||
const mobileDownloadUrl = updateAvailable && appType === 'mobile-capacitor' && platform === 'android'
|
||||
? await resolveAndroidApkUrl(data.latestVersion, downloadUrl)
|
||||
: undefined;
|
||||
return {
|
||||
available: Boolean(data.updateAvailable) && versionComparison > 0,
|
||||
available: updateAvailable,
|
||||
version: data.latestVersion,
|
||||
currentVersion,
|
||||
body: typeof data.releaseNotes === 'string' ? data.releaseNotes : undefined,
|
||||
releaseUrl: typeof data.releaseNotesUrl === 'string' ? data.releaseNotesUrl : releaseUrl,
|
||||
downloadUrl: mobileDownloadUrl,
|
||||
nextSuggestedCheckInSec:
|
||||
typeof data.nextSuggestedCheckInSec === 'number' && Number.isFinite(data.nextSuggestedCheckInSec)
|
||||
? data.nextSuggestedCheckInSec
|
||||
@@ -721,6 +771,7 @@ export async function checkForUpdates(options = {}) {
|
||||
const currentVersion = options.currentVersion || getCurrentVersion();
|
||||
const pm = detectPackageManager();
|
||||
const appType = normalizeAppType(options.appType);
|
||||
const platform = normalizePlatform(options.platform);
|
||||
|
||||
if (currentVersion !== 'unknown') {
|
||||
const remote = await checkForUpdatesFromApi(currentVersion, options);
|
||||
@@ -751,8 +802,12 @@ export async function checkForUpdates(options = {}) {
|
||||
|
||||
const available = compareVersions(latestVersion, currentVersion) > 0;
|
||||
let changelog;
|
||||
let downloadUrl;
|
||||
if (available) {
|
||||
changelog = await fetchChangelogNotes(currentVersion, latestVersion);
|
||||
if (appType === 'mobile-capacitor' && platform === 'android') {
|
||||
downloadUrl = await resolveAndroidApkUrl(latestVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -760,6 +815,8 @@ export async function checkForUpdates(options = {}) {
|
||||
version: latestVersion,
|
||||
currentVersion,
|
||||
body: changelog,
|
||||
releaseUrl: `${GITHUB_RELEASES_URL}/tag/v${latestVersion}`,
|
||||
downloadUrl,
|
||||
packageManager: pm,
|
||||
// Show our CLI command, not raw package manager command
|
||||
updateCommand: 'openchamber update',
|
||||
|
||||
@@ -134,11 +134,79 @@ describe('checkForUpdates', () => {
|
||||
const result = await checkForUpdates({
|
||||
appType: 'desktop-electron',
|
||||
currentVersion: '1.9.10',
|
||||
installId: '4f4dfead-9688-4c4f-97d7-4607fbbfc3ab',
|
||||
platform: 'windows',
|
||||
arch: 'arm64',
|
||||
});
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe('1.10.0');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({
|
||||
installId: '4f4dfead-9688-4c4f-97d7-4607fbbfc3ab',
|
||||
platform: 'windows',
|
||||
arch: 'arm64',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an Android APK asset when the update API returns an AAB', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.10.0',
|
||||
updateAvailable: true,
|
||||
downloadUrl: 'https://github.com/openchamber/openchamber/releases/download/v1.10.0/OpenChamber-1.10.0-42-android.aab',
|
||||
}),
|
||||
})
|
||||
.when('api.github.com/repos/openchamber/openchamber/releases/tags/v1.10.0', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
assets: [
|
||||
{
|
||||
name: 'OpenChamber-1.10.0-42-android.aab',
|
||||
browser_download_url: 'https://downloads.example/OpenChamber-1.10.0-42-android.aab',
|
||||
},
|
||||
{
|
||||
name: 'app-release.apk',
|
||||
browser_download_url: 'https://downloads.example/app-release.apk',
|
||||
},
|
||||
{
|
||||
name: 'OpenChamber-1.10.0-42-android.apk',
|
||||
browser_download_url: 'https://downloads.example/OpenChamber-1.10.0-42-android.apk',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({
|
||||
appType: 'mobile-capacitor',
|
||||
platform: 'android',
|
||||
currentVersion: '1.9.10',
|
||||
});
|
||||
|
||||
expect(result.downloadUrl).toBe('https://downloads.example/OpenChamber-1.10.0-42-android.apk');
|
||||
});
|
||||
|
||||
it('keeps a direct Android APK URL from the update API', async () => {
|
||||
const apkUrl = 'https://github.com/openchamber/openchamber/releases/download/v1.10.0/OpenChamber-1.10.0-42-android.apk';
|
||||
fetchMock.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.10.0',
|
||||
updateAvailable: true,
|
||||
downloadUrl: apkUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({
|
||||
appType: 'mobile-capacitor',
|
||||
platform: 'android',
|
||||
currentVersion: '1.9.10',
|
||||
});
|
||||
|
||||
expect(result.downloadUrl).toBe(apkUrl);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns available=false when API claims update but npm is behind', async () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Permission Auto-Accept
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the authoritative permission auto-accept policy for web, desktop, and mobile runtimes. Policy is persisted in OpenChamber settings so permission handling survives UI disconnects and server restarts.
|
||||
|
||||
## Policy
|
||||
|
||||
`permissionAutoAccept.sessions` contains explicit per-session boolean policies.
|
||||
|
||||
Policy inheritance uses the nearest explicit session value. A child `false` therefore overrides a parent `true`; descendants without an explicit value inherit from their nearest configured ancestor.
|
||||
|
||||
## Runtime
|
||||
|
||||
`createPermissionAutoAcceptRuntime` loads and serializes policy writes, subscribes to the global OpenCode event hub, caches session lineage, retries transient replies, and reconciles pending permissions after startup, reconnect, and policy enablement. Enabling Auto-Accept for a session immediately accepts matching pending requests and keeps handling future requests without requiring a connected UI.
|
||||
|
||||
Unknown lineage and failed policy loads fail closed. A failed pending-permission fetch is distinct from an empty successful response and never clears policy state.
|
||||
|
||||
## Routes
|
||||
|
||||
- `GET /api/permission-auto-accept`
|
||||
- `PUT /api/permission-auto-accept/sessions/:sessionId`
|
||||
|
||||
These are normal authenticated OpenChamber runtime routes. They must not be added to browser URL-token allowlists.
|
||||
|
||||
## UI ownership
|
||||
|
||||
`packages/ui/src/stores/permissionStore.ts` is a projection of server policy and does not persist an independent policy. The server is the sole responder and the UI renders pending requests until the authoritative `permission.replied` event arrives.
|
||||
|
||||
VS Code retains its foreground-only responder because it does not run the web server runtime. Its extension host persists and broadcasts the authoritative policy across webviews, while the active UI handles live events plus startup, reconnect, and enablement reconciliation. With all OpenChamber webviews closed or suspended, permissions are not auto-accepted; this is an intentional VS Code limitation.
|
||||
|
||||
## Tests
|
||||
|
||||
`runtime.test.js` covers restart persistence, nearest explicit subagent inheritance, missing-lineage lookup, retry/deduplication, and reconnect reconciliation.
|
||||
@@ -0,0 +1,266 @@
|
||||
const SETTINGS_KEY = 'permissionAutoAccept';
|
||||
const RETRY_DELAYS_MS = [0, 250, 1000];
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
const SESSION_CACHE_LIMIT = 10000;
|
||||
|
||||
const normalizePolicy = (value) => {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const sessions = {};
|
||||
const entries = source.sessions && typeof source.sessions === 'object' && !Array.isArray(source.sessions)
|
||||
? Object.entries(source.sessions)
|
||||
: [];
|
||||
for (const [sessionId, enabled] of entries) {
|
||||
if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled;
|
||||
}
|
||||
const revision = Number.isSafeInteger(source.revision) && source.revision >= 0 ? source.revision : 0;
|
||||
return { sessions, revision };
|
||||
};
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function createPermissionAutoAcceptRuntime({
|
||||
globalEventHub,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
readSettingsFromDiskMigrated,
|
||||
persistSettings,
|
||||
broadcastGlobalUiEvent,
|
||||
fetchImpl = fetch,
|
||||
retryDelaysMs = RETRY_DELAYS_MS,
|
||||
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
||||
}) {
|
||||
let policy = normalizePolicy();
|
||||
let loaded = false;
|
||||
let loadPromise = null;
|
||||
let writePromise = Promise.resolve();
|
||||
const sessions = new Map();
|
||||
const inFlight = new Map();
|
||||
const reconcilePromises = new Map();
|
||||
|
||||
const snapshot = () => ({
|
||||
sessions: { ...policy.sessions },
|
||||
revision: policy.revision,
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (loaded) return snapshot();
|
||||
if (!loadPromise) {
|
||||
loadPromise = readSettingsFromDiskMigrated()
|
||||
.then((settings) => {
|
||||
policy = normalizePolicy(settings?.[SETTINGS_KEY]);
|
||||
loaded = true;
|
||||
return snapshot();
|
||||
})
|
||||
.finally(() => { loadPromise = null; });
|
||||
}
|
||||
return loadPromise;
|
||||
};
|
||||
|
||||
const persistUpdate = (update) => {
|
||||
writePromise = writePromise.then(async () => {
|
||||
const next = update(policy);
|
||||
await persistSettings({ [SETTINGS_KEY]: next });
|
||||
policy = next;
|
||||
loaded = true;
|
||||
broadcastGlobalUiEvent?.({
|
||||
type: 'openchamber:permission-auto-accept.updated',
|
||||
properties: snapshot(),
|
||||
});
|
||||
return snapshot();
|
||||
});
|
||||
return writePromise;
|
||||
};
|
||||
|
||||
const setSessionPolicy = async (sessionId, enabled, directory) => {
|
||||
if (typeof sessionId !== 'string' || !sessionId.trim()) throw new TypeError('sessionId is required');
|
||||
if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean');
|
||||
await load();
|
||||
const result = await persistUpdate((current) => ({
|
||||
...current,
|
||||
sessions: { ...current.sessions, [sessionId.trim()]: enabled },
|
||||
revision: current.revision + 1,
|
||||
}));
|
||||
if (enabled) await reconcilePending({ directories: [directory] });
|
||||
return result;
|
||||
};
|
||||
|
||||
const rememberSession = (info, directoryHint) => {
|
||||
if (!info || typeof info.id !== 'string' || !info.id) return;
|
||||
sessions.set(info.id, {
|
||||
parentID: typeof info.parentID === 'string' && info.parentID ? info.parentID : null,
|
||||
directory: typeof info.directory === 'string' && info.directory ? info.directory : directoryHint,
|
||||
});
|
||||
if (sessions.size > SESSION_CACHE_LIMIT) {
|
||||
sessions.delete(sessions.keys().next().value);
|
||||
}
|
||||
};
|
||||
|
||||
const request = async (path, { directory, method = 'GET', body } = {}) => {
|
||||
const url = new URL(buildOpenCodeUrl(path, ''));
|
||||
if (directory) url.searchParams.set('directory', directory);
|
||||
const response = await fetchImpl(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(requestTimeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(`OpenCode request failed (${response.status})`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return response.json().catch(() => null);
|
||||
};
|
||||
|
||||
const getSession = async (sessionId, directory) => {
|
||||
const cached = sessions.get(sessionId);
|
||||
if (cached) return cached;
|
||||
const info = await request(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
||||
rememberSession(info?.data ?? info, directory);
|
||||
return sessions.get(sessionId) ?? null;
|
||||
};
|
||||
|
||||
const isSessionAutoAccepting = async (sessionId, directory) => {
|
||||
await load();
|
||||
const seen = new Set();
|
||||
let current = sessionId;
|
||||
let currentDirectory = directory;
|
||||
while (current && !seen.has(current)) {
|
||||
if (Object.hasOwn(policy.sessions, current)) return policy.sessions[current] === true;
|
||||
seen.add(current);
|
||||
let info;
|
||||
try {
|
||||
info = await getSession(current, currentDirectory);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
current = info?.parentID ?? null;
|
||||
currentDirectory = info?.directory ?? currentDirectory;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const replyOnce = async (permission, directory) => {
|
||||
if (!permission?.id || !permission?.sessionID) return false;
|
||||
await load();
|
||||
if (!(await isSessionAutoAccepting(permission.sessionID, directory))) return false;
|
||||
await request(`/permission/${encodeURIComponent(permission.id)}/reply`, {
|
||||
directory,
|
||||
method: 'POST',
|
||||
body: { reply: 'once' },
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const processPermission = (permission, directory) => {
|
||||
if (!permission?.id) return Promise.resolve(false);
|
||||
const key = permission.id;
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const task = (async () => {
|
||||
for (const delay of retryDelaysMs) {
|
||||
if (delay > 0) await wait(delay);
|
||||
try {
|
||||
return await replyOnce(permission, directory);
|
||||
} catch (error) {
|
||||
if (error?.status === 404) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})().finally(() => inFlight.delete(key));
|
||||
inFlight.set(key, task);
|
||||
return task;
|
||||
};
|
||||
|
||||
async function reconcilePending({ directories = [] } = {}) {
|
||||
const normalizedDirectories = Array.from(new Set(
|
||||
directories.filter((directory) => typeof directory === 'string' && directory.trim()).map((directory) => directory.trim()),
|
||||
));
|
||||
const key = normalizedDirectories.length > 0 ? normalizedDirectories.join('\n') : 'all';
|
||||
const existing = reconcilePromises.get(key);
|
||||
if (existing) return existing;
|
||||
const task = (async () => {
|
||||
await load();
|
||||
const scopes = [undefined, ...normalizedDirectories];
|
||||
const pendingById = new Map();
|
||||
for (const directory of scopes) {
|
||||
let payload;
|
||||
try {
|
||||
payload = await request('/permission', { directory });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const pending = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : null;
|
||||
if (!pending) continue;
|
||||
for (const permission of pending) {
|
||||
if (!permission?.id) continue;
|
||||
pendingById.set(permission.id, { permission, directory: permission.directory ?? directory });
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from(pendingById.values()).map(({ permission, directory }) =>
|
||||
processPermission(permission, directory)));
|
||||
})().finally(() => { reconcilePromises.delete(key); });
|
||||
reconcilePromises.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
const processEvent = (event) => {
|
||||
const raw = event?.payload;
|
||||
const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
|
||||
const directory = typeof event?.directory === 'string' && event.directory !== 'global' ? event.directory : undefined;
|
||||
if (payload?.type === 'session.created' || payload?.type === 'session.updated') {
|
||||
rememberSession(payload.properties?.info, directory);
|
||||
return;
|
||||
}
|
||||
if (payload?.type === 'permission.asked') {
|
||||
void processPermission(payload.properties, directory);
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const unsubscribeEvent = globalEventHub.subscribeEvent(processEvent);
|
||||
const unsubscribeStatus = globalEventHub.subscribeStatus((status) => {
|
||||
if (status?.type === 'connect') void reconcilePending();
|
||||
});
|
||||
void load().then(() => reconcilePending()).catch((error) => {
|
||||
console.warn('[permission-auto-accept] failed to load policy:', error?.message ?? error);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeEvent();
|
||||
unsubscribeStatus();
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
load,
|
||||
setSessionPolicy,
|
||||
isSessionAutoAccepting,
|
||||
processPermission,
|
||||
reconcilePending,
|
||||
start,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerPermissionAutoAcceptRoutes(app, runtime) {
|
||||
app.get('/api/permission-auto-accept', async (_req, res) => {
|
||||
try {
|
||||
res.json(await runtime.load());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to load permission auto-accept policy' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/permission-auto-accept/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const directory = typeof req.body?.directory === 'string' ? req.body.directory : undefined;
|
||||
res.json(await runtime.setSessionPolicy(req.params.sessionId, req.body?.enabled, directory));
|
||||
} catch (error) {
|
||||
res.status(error instanceof TypeError ? 400 : 500).json({ error: error?.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createPermissionAutoAcceptRuntime } from './runtime.js';
|
||||
|
||||
const createRuntime = ({ stored, fetchImpl, retryDelaysMs = [0] } = {}) => {
|
||||
let settings = stored ?? { permissionAutoAccept: { sessions: {} } };
|
||||
let eventHandler;
|
||||
let statusHandler;
|
||||
const runtime = createPermissionAutoAcceptRuntime({
|
||||
globalEventHub: {
|
||||
subscribeEvent(handler) { eventHandler = handler; return () => {}; },
|
||||
subscribeStatus(handler) { statusHandler = handler; return () => {}; },
|
||||
},
|
||||
buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
readSettingsFromDiskMigrated: async () => settings,
|
||||
persistSettings: async (changes) => { settings = { ...settings, ...changes }; },
|
||||
fetchImpl: fetchImpl ?? vi.fn(async () => new Response('[]')),
|
||||
retryDelaysMs,
|
||||
});
|
||||
runtime.start();
|
||||
return {
|
||||
runtime,
|
||||
getSettings: () => settings,
|
||||
emit: (payload, directory = '/project') => eventHandler({ payload, directory }),
|
||||
connect: () => statusHandler({ type: 'connect' }),
|
||||
};
|
||||
};
|
||||
|
||||
const flush = async () => {
|
||||
for (let index = 0; index < 20; index += 1) await Promise.resolve();
|
||||
};
|
||||
|
||||
describe('permission auto-accept runtime', () => {
|
||||
it('persists explicit session policies across runtime restarts', async () => {
|
||||
const first = createRuntime();
|
||||
await first.runtime.setSessionPolicy('root', true);
|
||||
|
||||
const second = createRuntime({ stored: first.getSettings() });
|
||||
await expect(second.runtime.load()).resolves.toEqual({
|
||||
sessions: { root: true },
|
||||
revision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('increments the authoritative policy revision', async () => {
|
||||
const { runtime, getSettings } = createRuntime();
|
||||
|
||||
await expect(runtime.setSessionPolicy('root', true)).resolves.toMatchObject({ revision: 1 });
|
||||
await expect(runtime.setSessionPolicy('child', false)).resolves.toMatchObject({ revision: 2 });
|
||||
expect(getSettings().permissionAutoAccept.revision).toBe(2);
|
||||
});
|
||||
|
||||
it('uses nearest explicit ancestor policy for subagents', async () => {
|
||||
const { runtime, emit } = createRuntime({
|
||||
stored: { permissionAutoAccept: { sessions: { root: true, child: false } } },
|
||||
});
|
||||
emit({ type: 'session.created', properties: { info: { id: 'child', parentID: 'root' } } });
|
||||
emit({ type: 'session.created', properties: { info: { id: 'grandchild', parentID: 'child' } } });
|
||||
await expect(runtime.isSessionAutoAccepting('grandchild', '/project')).resolves.toBe(false);
|
||||
await runtime.setSessionPolicy('child', true);
|
||||
await expect(runtime.isSessionAutoAccepting('grandchild', '/project')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('fetches missing subagent lineage before replying', async () => {
|
||||
const fetchImpl = vi.fn(async (url, init = {}) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === '/permission') return new Response('[]');
|
||||
if (path === '/session/child') return Response.json({ id: 'child', parentID: 'root', directory: '/project' });
|
||||
if (init.method === 'POST') return Response.json({});
|
||||
return new Response('', { status: 404 });
|
||||
});
|
||||
const { runtime } = createRuntime({
|
||||
stored: { permissionAutoAccept: { sessions: { root: true } } },
|
||||
fetchImpl,
|
||||
});
|
||||
await expect(runtime.processPermission({ id: 'perm', sessionID: 'child' }, '/project')).resolves.toBe(true);
|
||||
expect(fetchImpl.mock.calls.some(([url, init]) => new URL(url).pathname === '/permission/perm/reply' && init.method === 'POST')).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a transient reply failure and deduplicates concurrent events', async () => {
|
||||
let replyAttempts = 0;
|
||||
const fetchImpl = vi.fn(async (url, init = {}) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === '/permission') return new Response('[]');
|
||||
if (path === '/permission/perm/reply' && init.method === 'POST') {
|
||||
replyAttempts += 1;
|
||||
return replyAttempts === 1 ? new Response('', { status: 503 }) : Response.json({});
|
||||
}
|
||||
return Response.json({ id: 'root' });
|
||||
});
|
||||
const { runtime } = createRuntime({
|
||||
stored: { permissionAutoAccept: { sessions: { root: true } } },
|
||||
fetchImpl,
|
||||
retryDelaysMs: [0, 0],
|
||||
});
|
||||
const permission = { id: 'perm', sessionID: 'root' };
|
||||
const first = runtime.processPermission(permission, '/project');
|
||||
const second = runtime.processPermission(permission, '/project');
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||
expect(replyAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('reconciles pending permissions after reconnect', async () => {
|
||||
const fetchImpl = vi.fn(async (url, init = {}) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === '/permission') return Response.json([{ id: 'pending', sessionID: 'root' }]);
|
||||
if (path === '/permission/pending/reply' && init.method === 'POST') return Response.json({});
|
||||
return Response.json({ id: 'root' });
|
||||
});
|
||||
const { connect } = createRuntime({
|
||||
stored: { permissionAutoAccept: { sessions: { root: true } } },
|
||||
fetchImpl,
|
||||
});
|
||||
connect();
|
||||
await flush();
|
||||
expect(fetchImpl.mock.calls.some(([url]) => new URL(url).pathname === '/permission/pending/reply')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts existing pending permissions when a session policy is enabled', async () => {
|
||||
const fetchImpl = vi.fn(async (url, init = {}) => {
|
||||
const parsed = new URL(url);
|
||||
const path = parsed.pathname;
|
||||
if (path === '/permission') {
|
||||
return parsed.searchParams.get('directory') === '/project'
|
||||
? Response.json([
|
||||
{ id: 'root-pending', sessionID: 'root' },
|
||||
{ id: 'other-pending', sessionID: 'other' },
|
||||
])
|
||||
: Response.json([]);
|
||||
}
|
||||
if (path === '/permission/root-pending/reply' && init.method === 'POST') return Response.json({});
|
||||
if (path === '/session/other') return Response.json({ id: 'other' });
|
||||
return new Response('', { status: 404 });
|
||||
});
|
||||
const { runtime } = createRuntime({ fetchImpl });
|
||||
|
||||
await runtime.setSessionPolicy('root', true, '/project');
|
||||
|
||||
const replyPaths = fetchImpl.mock.calls
|
||||
.filter(([, init]) => init?.method === 'POST')
|
||||
.map(([url]) => new URL(url).pathname);
|
||||
expect(replyPaths).toEqual(['/permission/root-pending/reply']);
|
||||
expect(fetchImpl.mock.calls.some(([url]) => new URL(url).searchParams.get('directory') === '/project')).toBe(true);
|
||||
expect(await runtime.load()).toEqual({ sessions: { root: true }, revision: 1 });
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ const CLIENT_TOKEN_QUERY_PARAM = 'oc_client_token';
|
||||
const URL_AUTH_TOKEN_QUERY_PARAM = 'oc_url_token';
|
||||
const PREVIEW_PASSTHROUGH_REQUEST_HEADERS = ['x-inertia', 'x-inertia-version'];
|
||||
const PREVIEW_PASSTHROUGH_RESPONSE_HEADERS = ['x-inertia', 'x-inertia-location'];
|
||||
export const PREVIEW_TARGET_ERROR_HEADER = 'x-openchamber-preview-target-error';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set([
|
||||
'localhost',
|
||||
@@ -1235,19 +1236,19 @@ export const createPreviewProxyRuntime = ({
|
||||
const match = pathname.match(/^\/api\/preview\/proxy\/([a-f0-9]{16,64})(?:\/|$)/i);
|
||||
const id = match?.[1] || '';
|
||||
if (!id) {
|
||||
return { ok: false, status: 404, error: 'Preview target not found' };
|
||||
return { ok: false, status: 404, code: 'missing', error: 'Preview target not found' };
|
||||
}
|
||||
|
||||
const entry = targets.get(id);
|
||||
if (!entry || entry.expiresAt <= now()) {
|
||||
targets.delete(id);
|
||||
return { ok: false, status: 404, error: 'Preview target expired' };
|
||||
return { ok: false, status: 404, code: 'expired', error: 'Preview target expired' };
|
||||
}
|
||||
|
||||
const cookies = parseCookieHeader(req.headers?.cookie);
|
||||
const token = parsed.searchParams.get(TOKEN_QUERY_PARAM) || cookies.get(TOKEN_COOKIE_NAME) || '';
|
||||
if (!token || token !== entry.token) {
|
||||
return { ok: false, status: 403, error: 'Preview token missing' };
|
||||
return { ok: false, status: 403, code: 'invalid-token', error: 'Preview token missing' };
|
||||
}
|
||||
|
||||
return { ok: true, id, entry, parsed };
|
||||
@@ -1451,6 +1452,9 @@ export const createPreviewProxyRuntime = ({
|
||||
proxyReq.setHeader('accept-encoding', 'identity');
|
||||
},
|
||||
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
|
||||
// This header is reserved for failures produced before proxying. Do
|
||||
// not let an upstream application response impersonate that signal.
|
||||
res.removeHeader?.(PREVIEW_TARGET_ERROR_HEADER);
|
||||
applyPreviewPassthroughResponseHeaders(proxyRes, res);
|
||||
// Per-response nonce lets the injected bridge run under the dev
|
||||
// server's CSP without dropping its script restrictions wholesale.
|
||||
@@ -1551,6 +1555,7 @@ export const createPreviewProxyRuntime = ({
|
||||
app.use('/api/preview/proxy', (req, res, next) => {
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
res.setHeader(PREVIEW_TARGET_ERROR_HEADER, resolved.code);
|
||||
return res.status(resolved.status).json({ error: resolved.error });
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -5,12 +5,77 @@ import {
|
||||
applyPreviewPassthroughResponseHeaders,
|
||||
classifyPreviewNavigation,
|
||||
classifyPreviewResourceError,
|
||||
createPreviewProxyRuntime,
|
||||
normalizeProxyTargetUrl,
|
||||
PREVIEW_TARGET_ERROR_HEADER,
|
||||
rewritePreviewBody,
|
||||
rewritePreviewCspHeader,
|
||||
rewritePreviewRedirectLocation,
|
||||
} from './proxy-runtime.js';
|
||||
|
||||
const createResponse = () => {
|
||||
const headers = new Map();
|
||||
return {
|
||||
body: null,
|
||||
statusCode: 200,
|
||||
headers,
|
||||
setHeader(name, value) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
},
|
||||
removeHeader(name) {
|
||||
headers.delete(name.toLowerCase());
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body) {
|
||||
this.body = body;
|
||||
return body;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createAttachedPreviewRuntime = () => {
|
||||
let proxyOptions;
|
||||
const postRoutes = new Map();
|
||||
const useRoutes = new Map();
|
||||
let randomByte = 0;
|
||||
const runtime = createPreviewProxyRuntime({
|
||||
crypto: {
|
||||
randomBytes(size) {
|
||||
randomByte += 1;
|
||||
return Buffer.alloc(size, randomByte);
|
||||
},
|
||||
},
|
||||
URL,
|
||||
createProxyMiddleware(options) {
|
||||
proxyOptions = options;
|
||||
const middleware = () => {};
|
||||
middleware.upgrade = () => {};
|
||||
return middleware;
|
||||
},
|
||||
responseInterceptor: (handler) => handler,
|
||||
});
|
||||
const app = {
|
||||
post(path, ...handlers) {
|
||||
postRoutes.set(path, handlers);
|
||||
},
|
||||
use(path, ...handlers) {
|
||||
useRoutes.set(path, handlers);
|
||||
},
|
||||
};
|
||||
runtime.attach(app, {
|
||||
server: { on() {} },
|
||||
express: { json: () => (_req, _res, next) => next() },
|
||||
uiAuthController: null,
|
||||
isRequestOriginAllowed: async () => true,
|
||||
rejectWebSocketUpgrade() {},
|
||||
});
|
||||
|
||||
return { postRoutes, proxyOptions: () => proxyOptions, useRoutes };
|
||||
};
|
||||
|
||||
const rewrite = (bodyText, kind) => rewritePreviewBody({
|
||||
bodyText,
|
||||
kind,
|
||||
@@ -18,6 +83,77 @@ const rewrite = (bodyText, kind) => rewritePreviewBody({
|
||||
targetOrigin: 'http://127.0.0.1:3000',
|
||||
});
|
||||
|
||||
describe('preview target failure signaling', () => {
|
||||
it('marks missing and expired targets instead of relying on the HTTP status alone', () => {
|
||||
const { useRoutes } = createAttachedPreviewRuntime();
|
||||
const [guard] = useRoutes.get('/api/preview/proxy');
|
||||
for (const [originalUrl, code, error] of [
|
||||
['/api/preview/proxy/', 'missing', 'Preview target not found'],
|
||||
[`/api/preview/proxy/${'a'.repeat(32)}/`, 'expired', 'Preview target expired'],
|
||||
]) {
|
||||
const response = createResponse();
|
||||
guard({ originalUrl, headers: {} }, response, () => {});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.headers.get(PREVIEW_TARGET_ERROR_HEADER)).toBe(code);
|
||||
expect(response.body).toEqual({ error });
|
||||
}
|
||||
});
|
||||
|
||||
it('marks invalid target tokens and accepts a registered target token', async () => {
|
||||
const { postRoutes, useRoutes } = createAttachedPreviewRuntime();
|
||||
const [, registerTarget] = postRoutes.get('/api/preview/targets');
|
||||
const [guard] = useRoutes.get('/api/preview/proxy');
|
||||
const registrationResponse = createResponse();
|
||||
await registerTarget({ body: { url: 'http://127.0.0.1:4323/' }, secure: false }, registrationResponse);
|
||||
|
||||
const { id, previewToken } = registrationResponse.body;
|
||||
const invalidResponse = createResponse();
|
||||
guard({ originalUrl: `/api/preview/proxy/${id}/`, headers: {} }, invalidResponse, () => {});
|
||||
expect(invalidResponse.statusCode).toBe(403);
|
||||
expect(invalidResponse.headers.get(PREVIEW_TARGET_ERROR_HEADER)).toBe('invalid-token');
|
||||
|
||||
const validResponse = createResponse();
|
||||
let continued = false;
|
||||
guard({
|
||||
originalUrl: `/api/preview/proxy/${id}/?oc_preview_token=${previewToken}`,
|
||||
headers: {},
|
||||
}, validResponse, () => {
|
||||
continued = true;
|
||||
});
|
||||
expect(continued).toBe(true);
|
||||
expect(validResponse.headers.has(PREVIEW_TARGET_ERROR_HEADER)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the reserved target-error marker from upstream responses', async () => {
|
||||
const { postRoutes, proxyOptions, useRoutes } = createAttachedPreviewRuntime();
|
||||
const [, registerTarget] = postRoutes.get('/api/preview/targets');
|
||||
const registrationResponse = createResponse();
|
||||
await registerTarget({ body: { url: 'http://127.0.0.1:4323/' }, secure: false }, registrationResponse);
|
||||
const { id, previewToken } = registrationResponse.body;
|
||||
const request = {
|
||||
originalUrl: `/api/preview/proxy/${id}/missing?oc_preview_token=${previewToken}`,
|
||||
headers: {},
|
||||
};
|
||||
const response = createResponse();
|
||||
response.setHeader(PREVIEW_TARGET_ERROR_HEADER, 'expired');
|
||||
|
||||
await proxyOptions().on.proxyRes(
|
||||
Buffer.from('{"error":"upstream missing"}'),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
request,
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.headers.has(PREVIEW_TARGET_ERROR_HEADER)).toBe(false);
|
||||
const [guard] = useRoutes.get('/api/preview/proxy');
|
||||
let continued = false;
|
||||
guard(request, createResponse(), () => {
|
||||
continued = true;
|
||||
});
|
||||
expect(continued).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview Inertia header passthrough', () => {
|
||||
it('forwards Inertia request headers to the preview target', () => {
|
||||
const forwarded = new Map();
|
||||
|
||||
@@ -213,6 +213,13 @@ const normalizeExecution = (value) => {
|
||||
const modelID = asNonEmptyString(value.modelID);
|
||||
const variant = asNonEmptyString(value.variant);
|
||||
const agent = asNonEmptyString(value.agent);
|
||||
const goalEnabled = value.goalEnabled === true;
|
||||
const permissionAutoAccept = value.permissionAutoAccept === true;
|
||||
const goalTokenBudget = typeof value.goalTokenBudget === 'number'
|
||||
&& Number.isFinite(value.goalTokenBudget)
|
||||
&& value.goalTokenBudget > 0
|
||||
? Math.floor(value.goalTokenBudget)
|
||||
: undefined;
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error('execution.prompt is required');
|
||||
@@ -230,6 +237,9 @@ const normalizeExecution = (value) => {
|
||||
modelID,
|
||||
...(variant ? { variant } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
...(goalEnabled ? { goalEnabled: true } : {}),
|
||||
...(goalEnabled && goalTokenBudget ? { goalTokenBudget } : {}),
|
||||
...(permissionAutoAccept ? { permissionAutoAccept: true } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| --- | --- | --- | --- |
|
||||
| `claude` | Claude | `providers/claude.js` | `anthropic`, `claude` |
|
||||
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
|
||||
| `cursor` | Cursor | `providers/cursor.js` | `CURSOR_TOKEN` / `CURSOR_ACCESS_TOKEN`, `CURSOR_REFRESH_TOKEN`, optional token files, or Cursor desktop SQLite DB |
|
||||
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
|
||||
| `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
|
||||
| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
|
||||
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
|
||||
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
@@ -29,8 +31,10 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `zhipuai-coding-plan` | Zhipu AI Coding Plan | `providers/zhipuai-coding-plan.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` |
|
||||
| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` / `providers/minimax-shared.js` | `minimax-coding-plan` |
|
||||
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Cookie file at `~/.config/ollama-quota/cookie` (raw session cookie string) |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie stored under `~/.config/openchamber/quota/` |
|
||||
| `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
|
||||
| `opencode-go` | OpenCode Go | `providers/opencode-go.js` | Manual workspace ID and auth cookie stored under `~/.config/openchamber/quota/` |
|
||||
| `neuralwatt` | NeuralWatt | `providers/neuralwatt.js` | `neuralwatt` (API key under `key` or `token`) |
|
||||
|
||||
## Internal-only provider module
|
||||
- `providers/openai.js` exists for logic parity/reuse but is intentionally not registered for dispatcher ID routing.
|
||||
@@ -44,6 +48,8 @@ All providers should return results via shared helpers to preserve API shape:
|
||||
Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`.
|
||||
`fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data.
|
||||
|
||||
OpenCode Go, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. The server validates credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database.
|
||||
|
||||
## Add a new provider (quick steps)
|
||||
1. Choose module shape based on complexity:
|
||||
- Simple providers: create `packages/web/server/lib/quota/providers/<provider>.js`.
|
||||
@@ -65,7 +71,16 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo
|
||||
- **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent.
|
||||
- **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows.
|
||||
|
||||
## Kimi for Coding field semantics
|
||||
|
||||
`GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption:
|
||||
- The weekly `usage` block returns `used` (consumed) with no `remaining` field.
|
||||
- Each `limits[].detail` rate-limit block returns `remaining` (available) with no `used` field.
|
||||
|
||||
The provider computes `usedPercent` from whichever of `used`/`remaining` is present (`used` takes precedence when both exist) rather than assuming one field name. Both `packages/web/server/lib/quota/providers/kimi.js` and `packages/vscode/src/quotaProviders.ts` (`fetchKimiQuota`) must stay in sync — the VS Code extension duplicates this parsing logic rather than importing it.
|
||||
|
||||
## Notes for contributors
|
||||
- Keep provider IDs stable; clients use them directly.
|
||||
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
|
||||
- Keep Google behavior changes isolated and review `providers/google/*` together.
|
||||
- Z.ai Coding Plan exposes separate 5-hour and weekly `TOKENS_LIMIT` entries plus a monthly `TIME_LIMIT` for MCP tools; web and VS Code must preserve all three windows.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from './store.js';
|
||||
|
||||
const clean = (value) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
|
||||
|
||||
export const normalizers = {
|
||||
'opencode-go': (value) => {
|
||||
const workspaceId = clean(value?.workspaceId);
|
||||
let authCookie = clean(value?.authCookie);
|
||||
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
|
||||
return workspaceId && authCookie ? { workspaceId, authCookie } : null;
|
||||
},
|
||||
'ollama-cloud': (value) => {
|
||||
const cookie = clean(value?.cookie);
|
||||
return cookie ? { cookie } : null;
|
||||
},
|
||||
cursor: (value) => {
|
||||
const accessToken = clean(value?.accessToken);
|
||||
const refreshToken = clean(value?.refreshToken);
|
||||
return accessToken || refreshToken ? { accessToken, refreshToken } : null;
|
||||
},
|
||||
};
|
||||
|
||||
export const readManagedCredential = (providerId) => {
|
||||
const normalize = normalizers[providerId];
|
||||
return normalize ? readQuotaCredential(providerId, normalize) : null;
|
||||
};
|
||||
|
||||
export const writeManagedCredential = (providerId, value) => {
|
||||
const credential = normalizers[providerId]?.(value);
|
||||
if (!credential) throw new Error('Invalid credential');
|
||||
writeQuotaCredential(providerId, credential);
|
||||
return getManagedCredentialStatus(providerId);
|
||||
};
|
||||
|
||||
export const getManagedCredentialStatus = (providerId) => {
|
||||
const credential = readManagedCredential(providerId);
|
||||
if (!credential) return { configured: false };
|
||||
if (providerId === 'opencode-go') return { configured: true, workspaceId: credential.workspaceId, secretMasked: '••••••••' };
|
||||
if (providerId === 'cursor') return { configured: true, hasRefreshToken: Boolean(credential.refreshToken), secretMasked: '••••••••' };
|
||||
return { configured: true, secretMasked: '••••••••' };
|
||||
};
|
||||
|
||||
export const deleteManagedCredential = (providerId) => deleteQuotaCredential(providerId);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user