Merge remote-tracking branch 'origin/main' into port-2872

This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:48:36 +03:00
796 changed files with 54847 additions and 15505 deletions
+35
View File
@@ -43,6 +43,7 @@ import {
resolveServeHost,
resolveServeUiPassword,
} from './cli.js';
import { buildWindowsStartupTaskCommand } from './lib/cli-startup.js';
async function withTempOpenChamberDataDir(fn) {
const previous = process.env.OPENCHAMBER_DATA_DIR;
@@ -1421,3 +1422,37 @@ describe('lifecycle commands with unmanaged explicit ports', () => {
});
});
});
describe('Windows startup task command builder', () => {
it('default-path length stays under 200 chars', () => {
const cmd = buildWindowsStartupTaskCommand(
'C:\\Users\\test\\.config\\openchamber\\bin\\OpenChamber.ps1'
);
expect(cmd).toMatch(/^powershell\.exe -NoProfile -ExecutionPolicy Bypass -File /);
expect(cmd.length).toBeLessThan(200);
});
it('worst-case long path stays under 261-char Task Scheduler ceiling', () => {
// Build a wrapper path >= 180 chars (simulates long OPENCHAMBER_DATA_DIR)
// Overhead = 57 chars (prefix + closing quote), so max wrapper for <261 total is 203
const longPath =
'C:\\Users\\' +
'a'.repeat(139) +
'\\.config\\openchamber\\bin\\OpenChamber.ps1';
expect(longPath.length).toBeGreaterThanOrEqual(180);
const cmd = buildWindowsStartupTaskCommand(longPath);
expect(cmd.length).toBeLessThan(261);
});
it('does NOT inline SetEnvironmentVariable (externalization invariant)', () => {
const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1');
expect(cmd).not.toContain('SetEnvironmentVariable');
});
it('uses -File form, not -Command', () => {
const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1');
expect(cmd).toContain('-File ');
expect(cmd).not.toContain('-Command ');
});
});
+12
View File
@@ -78,6 +78,18 @@ These modules hold reusable, non-presentational logic for commands.
- `cli-paths.js`
- Data, run, log, settings, tunnel profile, and managed-local config paths.
- `cli-settings-accessors.js`
- Minimal settings.json read/write for CLI contexts that must not load the
full web settings runtime (`connect-url` relay identity resolution).
- Mirrors the settings runtime's guarantees so a CLI read-modify-write can
never corrupt shared state: atomic tmp+rename writes (no concurrent reader
in the running app can observe a torn file), a strict read that throws on
corrupt/unreadable payloads, and the same `0600` file mode.
- The strict read gates relay identity regeneration exactly like the server
runtime: a swallowed read failure can never mint a replacement signing or
encryption keypair, which would change `serverId` and orphan every paired
device and push binding.
- `cli-process.js`
- PID files, instance registry files, process identity checks, runtime metadata checks, and process termination helpers.
@@ -0,0 +1,111 @@
// Minimal settings.json access for CLI contexts (connect-url, pairing
// candidate building) that must not load the full web settings runtime.
//
// The running app already treats settings.json as a shared store — the relay
// identity, tunnels, notifications, the Electron main, and ssh-manager all
// read-modify-write it. This accessor must therefore mirror the settings
// runtime's guarantees or it will corrupt or regenerate shared state:
//
// - ATOMIC writes (write tmp, rename into place). A plain writeFile can
// interleave with a concurrent reader in the running app; the reader sees
// a half-written file, its lenient read maps it to `{}`, and relay
// identity logic then mints a NEW serverId — orphaning every paired
// device. The tmp+rename below means no reader can ever observe a partial
// file.
//
// - A STRICT read that THROWS on corrupt/unreadable payloads, gating relay
// identity regeneration. Only a genuinely missing file means "no
// settings"; any other failure (corrupt JSON, EACCES, transient I/O,
// non-object payload) must propagate so callers never confuse a broken
// read with first run and mint a replacement signing/encryption keypair.
export const createSettingsAccessors = ({ fsPromises, path, dataDir, settingsFileName }) => {
const settingsPath = path.join(dataDir, settingsFileName);
const readSettingsFromDiskMigrated = async () => {
try {
return JSON.parse(await fsPromises.readFile(settingsPath, 'utf8'));
} catch {
return {};
}
};
const readSettingsStrict = async () => {
let raw;
try {
raw = await fsPromises.readFile(settingsPath, 'utf8');
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return {};
}
throw error;
}
const corruptSettingsError = (cause) =>
new Error(`Settings file is corrupt or unreadable: ${settingsPath} (fix or remove it, then retry)`, { cause });
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw corruptSettingsError(error);
}
if (!parsed || typeof parsed !== 'object') {
throw corruptSettingsError(new Error('non-object payload'));
}
return parsed;
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isTransientWindowsReplaceError = (error) => {
if (process.platform !== 'win32' || !error || typeof error !== 'object') {
return false;
}
return error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY';
};
const replaceFile = async (tmp, target) => {
const maxAttempts = process.platform === 'win32' ? 6 : 1;
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await fsPromises.rename(tmp, target);
return;
} catch (error) {
lastError = error;
if (!isTransientWindowsReplaceError(error) || attempt === maxAttempts) {
break;
}
await sleep(25 * attempt);
}
}
if (!isTransientWindowsReplaceError(lastError)) {
throw lastError;
}
// Windows can transiently reject the atomic replace while another process
// briefly holds the target open. Fall back to copying the COMPLETE tmp file
// so persistence never wedges. Note: copyFile is NOT atomic — this is a
// last-resort path confined to Windows, matching the settings runtime's
// fallback, not a substitute for the atomic rename used everywhere else.
await fsPromises.copyFile(tmp, target);
await fsPromises.rm(tmp, { force: true });
};
const writeSettingsToDisk = async (settings) => {
await fsPromises.mkdir(path.dirname(settingsPath), { recursive: true });
const tmp = `${settingsPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
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, settingsPath);
if (process.platform !== 'win32') {
await fsPromises.chmod(settingsPath, 0o600);
}
};
return { readSettingsFromDiskMigrated, readSettingsStrict, writeSettingsToDisk };
};
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import { createSettingsAccessors } from './cli-settings-accessors.js';
import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js';
const withTempDir = async (fn) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-settings-accessors-'));
try {
return await fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
};
const makeAccessors = (dir, overrides = {}) =>
createSettingsAccessors({
fsPromises: fs.promises,
path,
dataDir: dir,
settingsFileName: 'settings.json',
...overrides,
});
// Wraps writeFile so each write lands in two chunks with a pause in between —
// a stand-in for a large, slow write on a real disk (one open handle, so the
// file grows from the prefix to the full payload). With a non-atomic writer a
// concurrent reader deterministically catches the half-written file in that
// window; with the atomic tmp+rename writer the target only ever changes via a
// complete rename, so the window is never observable.
const makeSlowWriteFs = () => {
const realFs = fs.promises;
const slowWriteFile = async (filePath, data) => {
const handle = await realFs.open(filePath, 'w');
try {
const half = Math.floor(data.length / 2);
await handle.writeFile(data.slice(0, half), 'utf8');
await new Promise((resolve) => setTimeout(resolve, 30));
await handle.writeFile(data.slice(half), 'utf8');
} finally {
await handle.close();
}
};
return { slowWriteFile, fsPromises: { ...realFs, writeFile: slowWriteFile } };
};
// Runs `writer` against filePath while a concurrent reader hammers it; returns
// how many times the reader observed an unparseable (torn) payload. ENOENT
// during the very first write is not a tear and is excluded.
const countTornReads = async (filePath, writer, iterations) => {
const big = { theme: 'dark', filler: 'x'.repeat(4096) };
let torn = 0;
let stop = false;
const reader = (async () => {
while (!stop) {
try {
const parsed = JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
if (parsed && typeof parsed === 'object') {
expect(parsed.theme).toBe('dark');
}
} catch (error) {
if (error?.code !== 'ENOENT') {
torn += 1;
}
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
})();
for (let i = 0; i < iterations; i += 1) {
await writer({ ...big, n: i });
}
stop = true;
await reader;
return torn;
};
describe('cli settings accessors', () => {
it('persists the full object atomically and cleans up its tmp file', async () => {
await withTempDir(async (dir) => {
const accessors = makeAccessors(dir);
await accessors.writeSettingsToDisk({ theme: 'dark', count: 3 });
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'));
expect(raw).toEqual({ theme: 'dark', count: 3 });
const leftovers = fs.readdirSync(dir).filter((name) => name.startsWith('settings.json.tmp-'));
expect(leftovers).toEqual([]);
});
});
it('atomic writes: concurrent readers never observe a torn file, even under slow writes', async () => {
await withTempDir(async (dir) => {
const { fsPromises } = makeSlowWriteFs();
const accessors = makeAccessors(dir, { fsPromises });
const filePath = path.join(dir, 'settings.json');
// Each write is chunked with a pause, yet the reader must never see a
// partial payload: the target only changes via a complete atomic rename.
const torn = await countTornReads(filePath, (settings) => accessors.writeSettingsToDisk(settings), 20);
expect(torn).toBe(0);
const leftovers = fs.readdirSync(dir).filter((name) => name.startsWith('settings.json.tmp-'));
expect(leftovers).toEqual([]);
});
});
it('demonstrates the protected failure mode: a naive direct writer tears under the same slow write', async () => {
await withTempDir(async (dir) => {
const { slowWriteFile } = makeSlowWriteFs();
const filePath = path.join(dir, 'settings.json');
// The old CLI accessor wrote straight to settings.json with writeFile.
// The same slow-write load therefore MUST produce torn reads — proving
// the concurrency test above can actually fail on the pre-fix writer.
const torn = await countTornReads(
filePath,
(settings) => slowWriteFile(filePath, JSON.stringify(settings)),
20,
);
expect(torn).toBeGreaterThan(0);
});
});
it('lenient read maps a corrupt file to {} for config lookup', async () => {
await withTempDir(async (dir) => {
const accessors = makeAccessors(dir);
fs.writeFileSync(path.join(dir, 'settings.json'), '{"unfinished": "trunc');
expect(await accessors.readSettingsFromDiskMigrated()).toEqual({});
});
});
it('strict read throws on a corrupt file instead of reporting "no settings"', async () => {
await withTempDir(async (dir) => {
const accessors = makeAccessors(dir);
fs.writeFileSync(path.join(dir, 'settings.json'), '{"unfinished": "trunc');
await expect(accessors.readSettingsStrict()).rejects.toThrow();
});
});
it('strict read throws on a non-object payload', async () => {
await withTempDir(async (dir) => {
const accessors = makeAccessors(dir);
fs.writeFileSync(path.join(dir, 'settings.json'), '"just a string"');
await expect(accessors.readSettingsStrict()).rejects.toThrow(/corrupt or unreadable/);
});
});
it('names the settings file in the strict read failure', async () => {
await withTempDir(async (dir) => {
const accessors = makeAccessors(dir);
const filePath = path.join(dir, 'settings.json');
fs.writeFileSync(filePath, '{"unfinished": "trunc');
await expect(accessors.readSettingsStrict()).rejects.toThrow(filePath);
});
});
it('strict read treats only a genuinely missing file as no settings', async () => {
await withTempDir(async (dir) => {
const accessors = makeAccessors(dir);
expect(await accessors.readSettingsStrict()).toEqual({});
});
});
it('does not regenerate the relay identity off a corrupt settings file', async () => {
await withTempDir(async (dir) => {
const accessors = makeAccessors(dir);
fs.writeFileSync(
path.join(dir, 'settings.json'),
JSON.stringify({
relaySigningKey: {
privateJwk: crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ format: 'jwk' }),
publicJwk: crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).publicKey.export({ format: 'jwk' }),
},
}),
);
const identity = await createRelayIdentityRuntime({ crypto, ...accessors }).getRelayIdentity();
const serverIdBefore = identity.serverId;
// Corrupt the file, then ask for the identity again: the strict gate must
// make this FAIL rather than mint a replacement keypair.
fs.writeFileSync(path.join(dir, 'settings.json'), '{"relaySigningKey": {"unfinished');
await expect(createRelayIdentityRuntime({ crypto, ...accessors }).getRelayIdentity()).rejects.toThrow();
expect(serverIdBefore).toBeTruthy();
});
});
});
+29 -9
View File
@@ -74,6 +74,10 @@ function getMacosStartupWrapperPath() {
return path.join(getDataDir(), 'bin', 'OpenChamber');
}
function getWindowsStartupWrapperPath() {
return path.join(getDataDir(), 'bin', 'OpenChamber.ps1');
}
function collectStartupEnv(options = {}) {
const env = options.envSnapshot === false ? {} : Object.fromEntries(
Object.entries(process.env)
@@ -189,6 +193,24 @@ exec ${startupShellQuote(process.execPath)} ${args}
return wrapperPath;
}
function writeWindowsStartupWrapper(options = {}) {
const wrapperPath = getWindowsStartupWrapperPath();
const envFilePath = getStartupEnvFilePath();
const startupArgs = buildStartupArgs(options).map(powershellQuote).join(' ');
const ps1Content = [
`$envFile=${powershellQuote(envFilePath)}`,
`if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
`& ${powershellQuote(process.execPath)} ${startupArgs}`,
].join('; ');
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(wrapperPath, ps1Content, { mode: 0o700 });
return wrapperPath;
}
function buildWindowsStartupTaskCommand(wrapperPath) {
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${wrapperPath}"`;
}
function buildMacosLaunchAgent(options = {}) {
const wrapperPath = writeMacosStartupWrapper(options);
const args = [wrapperPath];
@@ -318,21 +340,16 @@ function enableStartupService(options = {}) {
return getStartupStatus();
}
const envFilePath = writeStartupEnvFile(options);
const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', ');
const powerShellCommand = [
`$envFile=${powershellQuote(envFilePath)}`,
`if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
`& ${powershellQuote(process.execPath)} ${startupArgs}`,
].join('; ');
const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`;
writeStartupEnvFile(options);
const wrapperPath = writeWindowsStartupWrapper(options);
const taskCommand = buildWindowsStartupTaskCommand(wrapperPath);
runStartupCommand('schtasks.exe', [
'/Create',
'/TN', STARTUP_SERVICE_ID,
'/SC', 'ONLOGON',
'/RL', 'LIMITED',
'/F',
'/TR', taskArgs,
'/TR', taskCommand,
]);
runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
return getStartupStatus();
@@ -359,6 +376,8 @@ function disableStartupService() {
runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true });
try { fs.unlinkSync(getWindowsStartupWrapperPath()); } catch {}
removeStartupEnvFile();
return getStartupStatus();
}
@@ -367,4 +386,5 @@ export {
getStartupStatus,
enableStartupService,
disableStartupService,
buildWindowsStartupTaskCommand,
};
+12 -14
View File
@@ -17,6 +17,7 @@ import { createClientPairingRuntime } from '../../server/lib/client-auth/pairing
import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js';
import { DEFAULT_RELAY_URL } from '../../server/lib/relay/service.js';
import { bytesToBase64Url } from '../../server/lib/relay/e2ee.js';
import { createSettingsAccessors as createSettingsAccessorsModule } from './cli-settings-accessors.js';
import {
intro as clackIntro,
outro as clackOutro,
@@ -28,7 +29,6 @@ import {
} from '../cli-output.js';
const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json';
const SETTINGS_FILE_NAME = 'settings.json';
const PAIRING_SESSIONS_FILE_NAME = 'client-pairing-sessions.json';
function isValidRelayUrl(value) {
@@ -55,20 +55,18 @@ function resolveRelayUrl(settings) {
// Minimal settings.json read/write for the relay identity runtime. It reads the
// whole object and writes it back with the relay keys added, so other settings
// are preserved. Enough for the CLI without wiring the full settings runtime.
//
// Mirrors the settings runtime's guarantees: atomic writes (tmp + rename) so
// concurrent readers in the running app never observe a half-written file, and
// a STRICT reader gating relay identity regeneration so a swallowed read
// failure can never mint a new serverId and orphan paired devices.
function createSettingsAccessors() {
const settingsPath = path.join(getOpenChamberDataDir(), SETTINGS_FILE_NAME);
const readSettingsFromDiskMigrated = async () => {
try {
return JSON.parse(await fs.promises.readFile(settingsPath, 'utf8'));
} catch {
return {};
}
};
const writeSettingsToDisk = async (settings) => {
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
};
return { readSettingsFromDiskMigrated, writeSettingsToDisk };
return createSettingsAccessorsModule({
fsPromises: fs.promises,
path,
dataDir: getOpenChamberDataDir(),
settingsFileName: 'settings.json',
});
}
// Resolves the instance's relay identity (serverId + encryption public key,
+1 -1
View File
@@ -18,7 +18,7 @@
<!-- Web app manifest (endpoint-first with data URL fallback) -->
<script>
const baseUrl = location.origin;
const defaultAppName = 'OpenChamber - AI Coding Assistant';
const defaultAppName = 'OpenChamber';
const defaultShortName = 'OpenChamber';
const pwaNameStorageKey = 'openchamber.pwaName';
const pwaOrientationStorageKey = 'openchamber.pwaOrientation';
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@openchamber/web",
"version": "1.19.0",
"version": "1.21.0",
"private": false,
"type": "module",
"main": "./server/index.js",
@@ -13,8 +13,8 @@
},
"scripts": {
"dev": "bun run build:watch",
"dev:server": "bun server/index.js --port ${OPENCHAMBER_PORT:-3001}",
"dev:server:watch": "nodemon --watch server --ext js --exec \"bun server/index.js --port ${OPENCHAMBER_PORT:-3001}\"",
"dev:server": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} bun server/index.js --port ${OPENCHAMBER_PORT:-3001}",
"dev:server:watch": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} nodemon --watch server --ext js --exec \"bun server/index.js --port ${OPENCHAMBER_PORT:-3001}\"",
"build": "vite build",
"build:watch": "vite build --watch",
"type-check": "tsc --noEmit",
@@ -25,7 +25,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "1.18.18",
"@opencode-ai/sdk": "1.18.25",
"@simplewebauthn/server": "13.3.1",
"bun-pty": "^0.4.5",
"compression": "^1.8.1",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "OpenChamber - AI Coding Companion",
"name": "OpenChamber",
"short_name": "OpenChamber",
"description": "OpenChamber desktop companion for the OpenCode AI coding agent",
"start_url": "/",
+30 -6
View File
@@ -76,6 +76,7 @@ import { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } from
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
import { createSessionGoalRuntime } from './lib/session-goal/runtime.js';
import { applySmallModelOverrideToOpenCodeConfig } from './lib/small-model/config-injection.js';
import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js';
import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js';
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
@@ -1162,8 +1163,8 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
return [...new Set(directories)];
},
// A managed restart can move OpenCode to a NEW port (the old one may stay
// occupied by an orphaned process, e.g. killProcessOnPort is a no-op on
// Windows). Rebind the message-stream upstream readers to the current port
// occupied if killProcessOnPort/waitForPortRelease didn't free it in time,
// on any platform). Rebind the message-stream upstream readers to the current port
// so the UI keeps receiving events instead of staying pinned to the old
// process (#2638). The runtime is created later by the startup pipeline;
// by the time any restart runs, it is assigned.
@@ -1204,11 +1205,25 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
const managedEnv = includeControl || includeWeb || includeMemory
? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
: {};
if (settings?.optimizeSystemPrompt !== true) return managedEnv;
const envWithSystemPrompt = settings?.optimizeSystemPrompt === true
? {
...managedEnv,
...(await systemPromptRuntime.prepareManagedOpenCodeEnv(
managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT,
)),
}
: managedEnv;
const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent);
return { ...managedEnv, ...systemPromptEnv };
// Apply the explicit Small Model override to the managed OpenCode config
// so OpenCode's own title/summary generation uses the user's chosen model.
const configContent = envWithSystemPrompt.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
const withSmallModel = applySmallModelOverrideToOpenCodeConfig({
configContent,
smallModelUseDefault: settings?.smallModelUseDefault,
smallModelOverride: settings?.smallModelOverride,
});
if (withSmallModel === configContent) return envWithSystemPrompt;
return { ...envWithSystemPrompt, OPENCODE_CONFIG_CONTENT: withSmallModel };
},
});
@@ -1291,6 +1306,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({
return sanitizeProjects(settings?.projects || []).map((project) => project.path);
},
resolvePrimaryWorktreeRoot,
managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats')],
});
/**
@@ -1814,6 +1830,14 @@ async function main(options = {}) {
fs,
process,
}),
// Dev/debug instances share the data dir (and thus the relay identity) with
// the production instance, so they must not host the relay on their own —
// paired devices would land on them. OPENCHAMBER_RELAY_HOST=off disables
// passive hosting explicitly (dev scripts set it); the Electron dev shell is
// covered via OPENCHAMBER_ELECTRON_DEV. OPENCHAMBER_RELAY_HOST=on overrides
// both. Explicit enable/pairing on the instance still hosts regardless.
allowPassiveHost: process.env.OPENCHAMBER_RELAY_HOST === 'on'
|| (process.env.OPENCHAMBER_RELAY_HOST !== 'off' && process.env.OPENCHAMBER_ELECTRON_DEV !== '1'),
// Relay demand = any paired device or pending pairing session that uses the
// relay transport. Drives the auto on/off lifecycle.
hasRelayDemand: async () => {
@@ -24,7 +24,8 @@ const normalize = (value) => {
};
export const createMemoryProjectResolver = (dependencies) => {
const { listProjectPaths, resolvePrimaryWorktreeRoot } = dependencies;
const { listProjectPaths, resolvePrimaryWorktreeRoot, managedProjectRoots = [] } = dependencies;
const managedRoots = managedProjectRoots.map(normalize).filter(Boolean);
return async (directory) => {
const resolved = normalize(directory);
@@ -32,6 +33,14 @@ export const createMemoryProjectResolver = (dependencies) => {
return '';
}
const managedRoot = managedRoots.find((root) => {
const relative = path.relative(root, resolved);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
});
if (managedRoot) {
return createProjectIdFromPath(managedRoot);
}
let configured = [];
try {
configured = ((await listProjectPaths()) || []).map(normalize).filter(Boolean);
@@ -51,6 +51,15 @@ describe('resolving a session directory to its project', () => {
expect(await resolve('/tmp/loose')).toBe(createProjectIdFromPath('/tmp/loose'));
});
test('managed chat session directories share the Chats root store', async () => {
const chatsRoot = '/Users/x/.config/openchamber/chats';
const resolve = createResolver({ managedProjectRoots: [chatsRoot] });
expect(await resolve(`${chatsRoot}/2026-08-21/session-a`)).toBe(createProjectIdFromPath(chatsRoot));
expect(await resolve(`${chatsRoot}/2026-08-21/session-b`)).toBe(createProjectIdFromPath(chatsRoot));
expect(await resolve('/Users/x/.config/openchamber/chats-other/session-a')).not.toBe(createProjectIdFromPath(chatsRoot));
});
test('no directory resolves to nothing rather than to some default project', async () => {
const resolve = createResolver();
@@ -236,8 +236,13 @@ export const createAgentMemoryRuntime = (deps) => {
const writeJsonAtomic = async (filePath, value) => {
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
try {
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
} catch (error) {
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
};
const withWriteLock = async (key, mutate) => {
@@ -262,14 +262,15 @@ export const createClientPairingRuntime = ({
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';
// (shown in the device list) and wins outright. The device's self-reported
// label is only a fallback: on a re-pair with the same dedupeKey,
// createClient keeps the replaced record's label over it, so a rescan
// without a typed name does not reset the device to the app default.
const result = await remoteClientAuthRuntime.createClient({
label,
label: normalizeOptionalString(session.label),
fallbackLabel: normalizeOptionalString(clientLabel)
|| normalizeOptionalString(deviceName)
|| 'Remote client',
clientKind: normalizedKind,
dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`,
authMethod: 'pairing',
@@ -13,7 +13,7 @@ const makeRuntime = async (options = {}) => {
createClient: vi.fn(async (input) => {
const client = {
id: `client-${createdClients.length + 1}`,
label: input.label,
label: input.label ?? input.fallbackLabel,
clientKind: input.clientKind,
authMethod: input.authMethod,
pairingId: input.pairingId,
@@ -61,6 +61,10 @@ describe('client auth pairing runtime', () => {
pairingId: created.pairing.id,
clientKind: 'mobile',
dedupeKey: 'device-key',
// No operator-typed pairing label: the app-reported name is only a
// fallback so a re-pair keeps the existing device record's label.
label: null,
fallbackLabel: 'Iryna iPhone',
}));
await expect(runtime.redeemPairingSession({
@@ -157,6 +157,7 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
const createClient = async ({
label,
fallbackLabel,
expiresAt,
clientKind,
dedupeKey,
@@ -172,9 +173,16 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
const store = await readStore();
const normalizedDedupeKey = normalizeOptionalString(dedupeKey);
const token = generateToken();
// A dedupe-keyed mint REPLACES the previous record for the same device,
// so an operator-visible name must survive the replacement: an explicit
// label wins, otherwise the replaced record's label is kept, and only a
// first-ever mint falls back to the client-reported default.
const existing = normalizedDedupeKey
? store.clients.find((entry) => entry.dedupeKey === normalizedDedupeKey)
: null;
const client = {
id: generateId(),
label: normalizeLabel(label),
label: normalizeLabel(normalizeOptionalString(label) || existing?.label || fallbackLabel),
tokenHash: hashToken(token),
createdAt: nowIso(),
lastUsedAt: null,
@@ -83,6 +83,23 @@ describe('remote client auth runtime', () => {
}
});
it('keeps the replaced record label on a dedupe re-mint without an explicit label', async () => {
const { dir, runtime } = await createRuntime();
try {
await runtime.createClient({ label: 'Iryna iPhone', dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
const remint = await runtime.createClient({ dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
expect(remint.client.label).toBe('Iryna iPhone');
const renamed = await runtime.createClient({ label: 'Work phone', dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
expect(renamed.client.label).toBe('Work phone');
const fresh = await runtime.createClient({ dedupeKey: 'mobile:device-2', fallbackLabel: 'OpenChamber Mobile' });
expect(fresh.client.label).toBe('OpenChamber Mobile');
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('keeps the token store private on disk', async () => {
const { dir, runtime } = await createRuntime();
try {
+1 -1
View File
@@ -64,7 +64,7 @@ Install instructions for your platform:
Windows: winget install --id Cloudflare.cloudflared
Linux: Download from https://github.com/cloudflare/cloudflared/releases
Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/
Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/
`);
}
@@ -1,9 +1,15 @@
# 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.
Server-authoritative speech-to-text for the chat composer, plus local
text-to-speech. The client streams 16 kHz mono PCM16 chunks (base64) over a
WebSocket while the user speaks; the server buffers them and transcribes each
segment exactly once, when the segment is committed.
Transcription is deliberately not incremental. Parakeet is an offline model
trained on whole utterances, so re-decoding the growing buffer to animate a
live transcript costs O(n^2) work for a result the final decode replaces. The
composer shows no text while recording and inserts the full transcript on
stop.
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?,
@@ -21,9 +27,9 @@ same status/download/delete routes.
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.
Chunk reordering by `seq` + ack, resampling to the provider rate, segment
splitting, 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
@@ -33,7 +39,7 @@ same status/download/delete routes.
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),
recognizer engine and segment session (one decode per committed segment),
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
@@ -53,9 +59,27 @@ Server → client: `ready`, `ack {ackSeq}`, `partial {text}`,
`{ provider: 'local' | 'openai-compatible', language?, localModel?,
openaiCompatible?: { baseUrl, model, apiKey } }`.
## Segmentation
A dictation is one segment unless it runs long. Past `segmentMinSeconds`
(60 s) the manager commits on the first silent chunk, so cuts land at a pause
rather than mid-word; `segmentMaxSeconds` (90 s) is a hard cap for speech with
no pause in it. Client chunks are ~1 s, so "silent chunk" is roughly a second
of silence.
The bounds exist because Parakeet is a full-attention conformer: decode cost
and peak memory grow quadratically with segment length. Measured on Parakeet
v3 int8 with 2 threads: 60 s took 2.1 s and +90 MB, 180 s took 9.3 s and
+490 MB, 300 s took 21.3 s and +1.5 GB. Committed segments decode while the
user is still speaking, so only the tail is left to transcribe on stop.
## Invariants
- Never load `sherpa-onnx-node` in the main server process.
- Transcription happens on commit only; sessions never emit non-final
transcripts. The `partial` messages a client receives are the concatenation
of already-committed segments, and exist so a dictation that fails partway
can be salvaged instead of losing minutes of speech.
- 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
@@ -1,7 +1,12 @@
/**
* 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.
* segment transcription session that decodes each segment exactly once, when
* the segment is committed.
*
* Parakeet is an offline model: it is trained to see a whole utterance at
* once. Decoding the accumulated audio repeatedly to animate a live transcript
* costs O(n^2) work for a result the final decode throws away, so this session
* only decodes on commit.
*
* Runs inside the dictation worker process only — never load the native
* addon in the main server process.
@@ -147,31 +152,26 @@ export class SherpaOfflineRecognizerEngine {
}
/**
* 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.
* Segment transcription session backed by the offline recognizer.
* Accumulates the current segment's PCM and decodes it once in `commit()`,
* which emits the segment's final transcript and starts a new segment.
*
* Implements the StreamingTranscriptionSession contract used by
* DictationStreamManager.
* DictationStreamManager. It never emits non-final transcripts: the manager's
* live `partial` messages are the concatenation of already-committed segments.
*/
export class SherpaRealtimeTranscriptionSession extends EventEmitter {
export class SherpaSegmentTranscriptionSession extends EventEmitter {
/**
* @param {{ engine: SherpaOfflineRecognizerEngine, minDecodeIntervalMs?: number }} params
* @param {{ engine: SherpaOfflineRecognizerEngine }} params
*/
constructor({ engine, minDecodeIntervalMs }) {
constructor({ engine }) {
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() {
@@ -184,39 +184,38 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
appendPcm16(chunk) {
if (!this.connected || !this.currentSegmentId) {
this.emit('error', new Error('Sherpa realtime session not connected'));
this.emit('error', new Error('Sherpa transcription 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'));
this.emit('error', new Error('Sherpa transcription session not connected'));
return;
}
void (async () => {
try {
await this.maybeDecode(true);
const finalText = this.lastPartialText;
const segmentId = this.currentSegmentId;
const previousSegmentId = this.previousSegmentId;
const segmentId = this.currentSegmentId;
const previousSegmentId = this.previousSegmentId;
const pcm16 = this.pcm16;
this.emit('committed', { segmentId, previousSegmentId });
this.emit('transcript', { segmentId, transcript: finalText, isFinal: true });
// Start the next segment before decoding: decoding blocks the worker for
// seconds on long segments, and audio for the next one keeps arriving.
this.previousSegmentId = segmentId;
this.currentSegmentId = randomUUID();
this.pcm16 = Buffer.alloc(0);
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)));
}
})();
this.emit('committed', { segmentId, previousSegmentId });
let transcript;
try {
transcript = this.engine.decodePcm16(pcm16);
} catch (err) {
this.emit('error', err instanceof Error ? err : new Error(String(err)));
return;
}
this.emit('transcript', { segmentId, transcript, isFinal: true });
}
clear() {
@@ -225,7 +224,6 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
}
this.pcm16 = Buffer.alloc(0);
this.currentSegmentId = randomUUID();
this.lastPartialText = '';
}
close() {
@@ -233,45 +231,4 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
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);
}
}
}
}
@@ -17,7 +17,7 @@
import {
SherpaOfflineRecognizerEngine,
SherpaRealtimeTranscriptionSession,
SherpaSegmentTranscriptionSession,
} from './sherpa-recognizer.js';
import { SherpaTtsEngine } from './sherpa-tts.js';
import { getLocalSttModelDir, getLocalSttModelSpec } from './model-catalog.js';
@@ -126,7 +126,7 @@ async function handleRequest(message) {
case 'session.create': {
cleanupSession(message.sessionId);
const engine = getEngine(message.modelsDir, message.modelId);
const session = new SherpaRealtimeTranscriptionSession({ engine });
const session = new SherpaSegmentTranscriptionSession({ engine });
session.on('committed', (payload) => {
sendToParent({ type: 'session.committed', sessionId: message.sessionId, payload });
});
@@ -3,8 +3,9 @@
* 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).
* transcribed on commit(). This matches how the local session behaves: the
* DictationStreamManager splits long dictations at pauses, and everything
* shorter is one request on stop.
*
* Implements the StreamingTranscriptionSession contract used by
* DictationStreamManager.
@@ -7,23 +7,54 @@
* 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.
* - Segments long dictations at natural pauses: past `segmentMinSeconds` of
* audio it commits on the first silent chunk, and `segmentMaxSeconds` is a
* hard cap for speech with no pause in it. Silence-only segments are
* cleared instead of committed.
* - Concatenates per-segment transcripts into live partials and emits the
* final text once every committed segment has a final transcript.
* final text once every committed segment has a final transcript. The
* manager counts the commits it issued rather than trusting the session's
* echoed events, so a commit still in flight when the client finishes
* cannot be silently dropped from the 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;
// Parakeet is a full-attention conformer: decode cost and peak memory grow
// quadratically with segment length (measured: 60s -> 2.1s/+90MB,
// 300s -> 21.3s/+1.5GB). Segmenting keeps a long dictation off that curve and
// lets committed segments decode while the user is still speaking, so only the
// tail is left to transcribe on stop. Typical dictations are shorter than the
// minimum and are decoded as a single segment.
const DEFAULT_SEGMENT_MIN_SECONDS = 60;
const DEFAULT_SEGMENT_MAX_SECONDS = 90;
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;
const secondsToPcm16Bytes = (seconds, sampleRate) =>
seconds > 0 ? Math.max(1, Math.round(seconds * sampleRate * 2)) : 0;
/**
* Split the current segment once it is long enough to be worth decoding on its
* own and the speaker has just gone quiet, or unconditionally at the hard cap.
* Client chunks are ~1s, so a quiet chunk is roughly a second of silence — long
* enough to be a sentence boundary rather than a gap between words.
*/
function shouldSplitSegment(state) {
if (state.segmentMaxBytes > 0 && state.bytesSinceCommit >= state.segmentMaxBytes) {
return true;
}
if (state.segmentMinBytes <= 0 || state.bytesSinceCommit < state.segmentMinBytes) {
return false;
}
return state.lastChunkPeak < SILENCE_PEAK_THRESHOLD;
}
export class DictationStreamManager {
/**
* @param {object} params
@@ -33,13 +64,15 @@ export class DictationStreamManager {
* The streaming transcription session contract:
* { requiredSampleRate, appendPcm16(buf), commit(), clear(), close(), on(event, handler) }
* @param {number} [params.finalTimeoutMs]
* @param {number} [params.autoCommitSeconds]
* @param {number} [params.segmentMinSeconds] audio before a pause may split a segment
* @param {number} [params.segmentMaxSeconds] hard segment cap for pauseless speech
*/
constructor({ emit, createSttSession, finalTimeoutMs, autoCommitSeconds }) {
constructor({ emit, createSttSession, finalTimeoutMs, segmentMinSeconds, segmentMaxSeconds }) {
this.emit = emit;
this.createSttSession = createSttSession;
this.finalTimeoutMs = finalTimeoutMs ?? DEFAULT_FINAL_TIMEOUT_MS;
this.autoCommitSeconds = autoCommitSeconds ?? DEFAULT_AUTO_COMMIT_SECONDS;
this.segmentMinSeconds = segmentMinSeconds ?? DEFAULT_SEGMENT_MIN_SECONDS;
this.segmentMaxSeconds = segmentMaxSeconds ?? DEFAULT_SEGMENT_MAX_SECONDS;
this.streams = new Map();
}
@@ -87,13 +120,12 @@ export class DictationStreamManager {
if (!state) {
return;
}
// Segment accounting is reset where the commit is issued, not here: this
// event arrives after an async hop, and zeroing the counters on arrival
// would discard audio that came in meanwhile — up to and including
// mistaking the tail of the dictation for silence and clearing it.
state.committedSegmentIds.push(segmentId);
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
if (state.finishRequested && state.awaitingFinalCommit) {
state.awaitingFinalCommit = false;
}
state.pendingCommits = Math.max(0, state.pendingCommits - 1);
this.maybeFinalizeStream(dictationId);
});
@@ -108,10 +140,6 @@ export class DictationStreamManager {
state.finalTranscriptSegmentIds.add(segmentId);
}
if (state.finishRequested && state.awaitingFinalCommit && isFinal) {
state.awaitingFinalCommit = false;
}
const orderedIds = state.committedSegmentIds.includes(segmentId)
? state.committedSegmentIds
: [...state.committedSegmentIds, segmentId];
@@ -143,16 +171,15 @@ export class DictationStreamManager {
receivedChunks: new Map(),
nextSeqToForward: 0,
ackSeq: -1,
autoCommitBytes:
this.autoCommitSeconds > 0
? Math.max(1, Math.round(this.autoCommitSeconds * stt.requiredSampleRate * 2))
: 0,
segmentMinBytes: secondsToPcm16Bytes(this.segmentMinSeconds, stt.requiredSampleRate),
segmentMaxBytes: secondsToPcm16Bytes(this.segmentMaxSeconds, stt.requiredSampleRate),
bytesSinceCommit: 0,
peakSinceCommit: 0,
lastChunkPeak: 0,
committedSegmentIds: [],
transcriptsBySegmentId: new Map(),
finalTranscriptSegmentIds: new Set(),
awaitingFinalCommit: false,
pendingCommits: 0,
finishRequested: false,
finishSealed: false,
finalSeq: null,
@@ -203,7 +230,8 @@ export class DictationStreamManager {
if (resampled.length > 0) {
state.stt.appendPcm16(resampled);
state.bytesSinceCommit += resampled.length;
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
state.lastChunkPeak = pcm16lePeakAbs(resampled);
state.peakSinceCommit = Math.max(state.peakSinceCommit, state.lastChunkPeak);
try {
this.maybeAutoCommitSegment(state);
} catch (error) {
@@ -325,9 +353,7 @@ export class DictationStreamManager {
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
}, 0);
const pendingSegments =
pendingCommittedSegments +
pendingUncommittedTranscriptSegments +
(state.awaitingFinalCommit ? 1 : 0);
pendingCommittedSegments + pendingUncommittedTranscriptSegments + state.pendingCommits;
const pendingAudioSeconds = Math.ceil(Math.max(0, state.bytesSinceCommit) / bytesPerSecond);
const missingSeqCount =
state.finalSeq === null ? 0 : Math.max(0, state.finalSeq - state.ackSeq);
@@ -347,19 +373,36 @@ export class DictationStreamManager {
if (state.finishRequested) {
return;
}
if (state.autoCommitBytes <= 0 || state.bytesSinceCommit < state.autoCommitBytes) {
if (!shouldSplitSegment(state)) {
return;
}
if (state.peakSinceCommit < SILENCE_PEAK_THRESHOLD) {
state.stt.clear();
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.lastChunkPeak = 0;
return;
}
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.stt.commit();
state.lastChunkPeak = 0;
this.commitSegment(state);
}
/**
* Issue a commit and record it as in flight. The session acknowledges with a
* `committed` event; until then the manager must not finalize, or the
* segment's transcript would be missing from the final text.
*/
commitSegment(state) {
state.pendingCommits += 1;
try {
state.stt.commit();
} catch (error) {
state.pendingCommits -= 1;
throw error;
}
}
maybeSealStreamFinish(dictationId) {
@@ -382,19 +425,19 @@ export class DictationStreamManager {
state.stt.clear();
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.awaitingFinalCommit = false;
state.lastChunkPeak = 0;
this.dropUncommittedNonFinalTranscripts(state);
} else {
state.awaitingFinalCommit = true;
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.lastChunkPeak = 0;
try {
state.stt.commit();
this.commitSegment(state);
} catch (error) {
this.failAndCleanupStream(dictationId, error?.message || String(error), true);
return;
}
}
} else {
state.awaitingFinalCommit = false;
}
state.finishSealed = true;
@@ -425,7 +468,7 @@ export class DictationStreamManager {
if (state.ackSeq < state.finalSeq) {
return;
}
if (state.awaitingFinalCommit) {
if (state.pendingCommits > 0) {
return;
}
@@ -175,8 +175,8 @@ describe('DictationStreamManager', () => {
},
});
const { manager, messages } = createManager(session);
// Force auto-commit after ~0.05s of audio so two segments form.
manager.autoCommitSeconds = 0.05;
// Force a hard-cap split after ~0.05s of audio so two segments form.
manager.segmentMaxSeconds = 0.05;
await manager.handleStart('d1', FORMAT, {});
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(1600) });
@@ -191,4 +191,62 @@ describe('DictationStreamManager', () => {
const partials = messages.filter((m) => m.type === 'partial');
expect(partials.length).toBeGreaterThan(0);
});
it('keeps a short dictation as one segment even across pauses', async () => {
const session = new FakeSttSession();
const { manager } = createManager(session);
await manager.handleStart('d1', FORMAT, {});
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: silentChunkBase64(16000) });
manager.handleChunk({ dictationId: 'd1', seq: 2, audioBase64: loudChunkBase64(16000) });
expect(session.commits).toBe(0);
manager.handleFinish('d1', 2);
await waitFor(() => session.commits === 1);
});
it('splits at a pause once the segment passes the minimum length', async () => {
const session = new FakeSttSession();
const { manager } = createManager(session);
manager.segmentMinSeconds = 3;
await manager.handleStart('d1', FORMAT, {});
// 2s of audio: below the minimum, so this pause must not split.
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: silentChunkBase64(16000) });
expect(session.commits).toBe(0);
// Past the minimum, the next quiet chunk is a segment boundary.
manager.handleChunk({ dictationId: 'd1', seq: 2, audioBase64: loudChunkBase64(16000) });
expect(session.commits).toBe(0);
manager.handleChunk({ dictationId: 'd1', seq: 3, audioBase64: silentChunkBase64(16000) });
expect(session.commits).toBe(1);
});
it('splits pauseless speech at the hard cap', async () => {
const session = new FakeSttSession();
const { manager } = createManager(session);
manager.segmentMinSeconds = 60;
manager.segmentMaxSeconds = 2;
await manager.handleStart('d1', FORMAT, {});
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
expect(session.commits).toBe(0);
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: loudChunkBase64(16000) });
expect(session.commits).toBe(1);
});
it('clears a silence-only segment at the hard cap instead of committing it', async () => {
const session = new FakeSttSession();
const { manager } = createManager(session);
manager.segmentMaxSeconds = 1;
await manager.handleStart('d1', FORMAT, {});
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: silentChunkBase64(16000) });
expect(session.commits).toBe(0);
expect(session.clears).toBe(1);
});
});
+76
View File
@@ -3,6 +3,7 @@ import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
import { createProjectDirectoryRuntime } from '../opencode/project-directory-runtime.js';
const createRouteRegistry = () => {
const routes = new Map();
@@ -1131,3 +1132,78 @@ describe('fs list symlink path space (issue 2627)', () => {
});
}
});
describe('fs stat directory scope (issue 3019)', () => {
// Wires the real project-directory runtime so the stat route resolves the
// workspace exactly as the server does: explicit x-opencode-directory header
// first, then the settings.lastDirectory fallback. The renderer's file
// reference probes must send the header because lastDirectory reflects the
// directory the UI last browsed, not the session's directory.
const registerStatWithProjectDirectoryRuntime = () => {
const projectDirectoryRuntime = createProjectDirectoryRuntime({
fsPromises: {
stat: async (targetPath) => {
if (targetPath === '/repo-a' || targetPath === '/repo-b') {
return { isDirectory: () => true };
}
return { isDirectory: () => false, isFile: () => true, size: 12 };
},
realpath: async (targetPath) => targetPath,
},
path: { resolve: (p) => path.posix.resolve(p) },
normalizeDirectoryPath: (p) => p,
readSettingsFromDiskMigrated: async () => ({ lastDirectory: '/repo-a', projects: [] }),
getReadSettingsFromDiskMigrated: undefined,
sanitizeProjects: (input) => input,
});
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
stat: async () => ({ isFile: () => true, size: 12 }),
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: projectDirectoryRuntime.resolveProjectDirectory,
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('GET', '/api/fs/stat');
};
const callStat = async (handler, { headers = {}, query }) => {
const res = createMockResponse();
const req = {
query,
get: (name) => headers[name.toLowerCase()] ?? undefined,
};
await handler(req, res);
return res;
};
it('rejects a stat for a file under the session directory when only lastDirectory resolves the workspace', async () => {
const handler = registerStatWithProjectDirectoryRuntime();
const res = await callStat(handler, { query: { path: '/repo-b/src/index.ts', optional: 'true' } });
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
});
it('accepts the same stat when the session directory rides the x-opencode-directory header', async () => {
const handler = registerStatWithProjectDirectoryRuntime();
const res = await callStat(handler, {
headers: { 'x-opencode-directory': '/repo-b' },
query: { path: '/repo-b/src/index.ts', optional: 'true' },
});
expect(res.statusCode).toBe(200);
expect(res.body.isFile).toBe(true);
});
});
+2 -2
View File
@@ -40,7 +40,7 @@ The following functions are exported and used by the web server:
### Branch Operations
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
- `createBranch(directory, branchName, options)`: Create and checkout a new branch.
- `checkoutBranch(directory, branchName)`: Checkout an existing branch.
- `checkoutBranch(directory, branchName)`: Checkout an existing branch. A remote-tracking name (`origin/main`, or the `remotes/`-prefixed form) resolves to the local branch of that name, created with `--track` when it does not exist yet, because the branch selector offers remote branches as places to work rather than commits to inspect — a literal checkout of the remote ref would detach HEAD. A local branch whose own name looks like a remote ref wins over that resolution, and anything unresolvable is checked out as requested. The returned `branch` is the branch that was actually checked out, which callers should report instead of the requested name.
- `deleteBranch(directory, branch, options)`: Delete a branch (supports force flag).
- `renameBranch(directory, oldName, newName)`: Rename a branch and preserve upstream tracking.
- `getRemotes(directory)`: Get list of configured remotes.
@@ -121,7 +121,7 @@ The following functions are internal helpers used by exported functions:
- `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.
- `all`: Local branches plus every branch each reachable remote reports via `ls-remote --heads`, formatted as `remotes/<remote>/<branch>`. This is a union: local remote-tracking refs deleted on the remote are pruned, and branches that exist on the remote without a local tracking ref (never fetched) are still included, so a freshly pushed branch appears without requiring a fetch. A remote that fails to answer keeps its locally known 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.
+43
View File
@@ -428,6 +428,49 @@ export function registerGitRoutes(app) {
}
});
app.get('/api/git/branch-base', async (req, res) => {
const { getBranchBase } = await getGitLibraries();
try {
const directory = resolveDirectoryQuery(req.query.directory);
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const branch = resolveDirectoryQuery(req.query.branch);
if (!branch) {
return res.status(400).json({ error: 'branch parameter is required' });
}
const result = await getBranchBase(directory, branch);
res.json(result);
} catch (error) {
console.error('Failed to get branch base:', error);
res.status(500).json({ error: error.message || 'Failed to get branch base' });
}
});
app.get('/api/git/range-files', async (req, res) => {
const { getRangeFiles } = await getGitLibraries();
try {
const directory = resolveDirectoryQuery(req.query.directory);
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const base = resolveDirectoryQuery(req.query.base);
const head = resolveDirectoryQuery(req.query.head);
if (!base || !head) {
return res.status(400).json({ error: 'base and head parameters are required' });
}
const files = await getRangeFiles(directory, { base, head });
res.json({ files });
} catch (error) {
console.error('Failed to get git range files:', error);
res.status(500).json({ error: error.message || 'Failed to get git range files' });
}
});
app.post('/api/git/revert', async (req, res) => {
const { revertFile } = await getGitLibraries();
try {
+176 -9
View File
@@ -2654,6 +2654,74 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
return diff;
}
const BRANCH_CREATION_SOURCE_RE = /^branch: Created from (.+)$/;
/**
* Parse a branch reflog (`git reflog show --format=%gs <branch>`) and return the
* ref the branch was created from, when that source is itself a named ref.
*
* Returns null when the branch was created from `HEAD` (bare, as `git switch -c`
* / `git checkout -b` without an explicit start point record) or a raw commit
* (detached start): the original branch name is not recorded anywhere in that
* case, and guessing a base from commit topology would be a heuristic, not an
* answer. Callers should ask the user to pick a base instead.
*/
export function parseBranchCreationSource(reflogText) {
const lines = String(reflogText || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
// Reflog lists newest entries first; the creation entry is the oldest one.
for (let index = lines.length - 1; index >= 0; index -= 1) {
const match = lines[index].match(BRANCH_CREATION_SOURCE_RE);
if (!match) continue;
const source = match[1].trim();
// Bare `HEAD` (`git switch -c` from the current branch) and `HEAD@{...}`
// (detached start) both lack a named source; a raw commit hash does too.
if (!source || /^HEAD(@|$)/.test(source) || /^[0-9a-f]{7,40}$/i.test(source)) {
return null;
}
return source;
}
return null;
}
/**
* Resolve the branch the given branch was created from, from its reflog.
* Returns { base: null } when git has no authoritative record (clone, detached
* start, reflog expired) callers must not fall back to main/master.
*/
export async function getBranchBase(directory, branch) {
const branchName = String(branch || '').trim();
if (!branchName) {
throw new Error('branch is required');
}
const { git } = await createRepositoryGitContext(directory);
let reflog = '';
try {
reflog = await git.raw(['reflog', 'show', '--format=%gs', branchName]);
} catch {
return { base: null };
}
const source = parseBranchCreationSource(reflog);
if (!source || source === branchName) {
return { base: null };
}
const resolves = await git
.raw(['rev-parse', '--verify', '--quiet', source])
.then((value) => Boolean(String(value || '').trim()))
.catch(() => false);
if (!resolves) {
return { base: null };
}
return { base: source };
}
export async function getRangeFiles(directory, { base, head } = {}) {
const { git } = await createRepositoryGitContext(directory);
const baseRef = typeof base === 'string' ? base.trim() : '';
@@ -2673,11 +2741,26 @@ export async function getRangeFiles(directory, { base, head } = {}) {
// ignore
}
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
return String(raw || '')
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
// `-C` (copy detection among changed files only, so cheap) makes copies
// surface as C entries instead of plain additions; rename detection is on
// by default.
const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
// -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries
// (`R100`, `C75`) the first path token is the ORIGINAL path and the second
// is the DESTINATION — the diff (and the UI) must address the destination.
const tokens = String(raw || '').split('\0');
const files = [];
for (let index = 0; index < tokens.length; index += 1) {
const status = (tokens[index] || '').trim();
if (!status) continue;
const isRenameOrCopy = status.startsWith('R') || status.startsWith('C');
const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim();
index += isRenameOrCopy ? 2 : 1;
if (path) {
files.push({ path, status: status.charAt(0) });
}
}
return files;
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
@@ -3664,7 +3747,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
}
}));
return remoteBranches.filter(remoteBranch => {
const activeBranches = remoteBranches.filter(remoteBranch => {
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
if (!match) return false;
const remoteName = remoteBranch.split('/')[1];
@@ -3672,6 +3755,25 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
if (unreachableRemotes.has(remoteName)) return true;
return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
});
// A branch pushed to the remote that was never fetched locally has no
// remote-tracking ref, so `git branch` never reports it — but ls-remote
// just told us it exists. Add those so a freshly pushed branch shows up
// without requiring a fetch first (#2098). Unreachable remotes have no
// ls-remote data and therefore add nothing here; their local view above
// is preserved unchanged.
const seenBranches = new Set(activeBranches);
for (const [remoteName, actualRemoteBranches] of branchesByRemote) {
for (const branchName of actualRemoteBranches) {
const qualifiedBranch = `remotes/${remoteName}/${branchName}`;
if (!seenBranches.has(qualifiedBranch)) {
seenBranches.add(qualifiedBranch);
activeBranches.push(qualifiedBranch);
}
}
}
return activeBranches;
} catch (error) {
console.warn('Failed to filter active remote branches, returning all:', error.message);
return remoteBranches;
@@ -3690,12 +3792,69 @@ export async function createBranch(directory, branchName, options = {}) {
}
}
// Deliberately not `--quiet`: simple-git resolves a quiet non-zero exit as
// success, so the ref itself has to be echoed for the answer to mean anything.
const gitRefExists = async (git, ref) => {
try {
const output = await git.raw(['show-ref', '--verify', ref]);
return String(output).trim().length > 0;
} catch {
return false;
}
};
/**
* The branch selector lists remote-tracking branches beside local ones, so
* picking `origin/main` means "work on main", not "detach HEAD at the remote's
* commit" which is what a literal checkout of a remote-tracking ref does.
* Resolve such a pick to the local branch, creating it with tracking when it
* does not exist yet. Anything we cannot resolve is checked out as requested,
* leaving git's own DWIM behavior intact.
*/
const resolveBranchCheckoutTarget = async (git, branchName) => {
const requested = String(branchName || '').trim();
if (!requested) {
throw new Error('Branch name is required');
}
const asRequested = { branch: requested, remoteRef: null };
if (await gitRefExists(git, `refs/heads/${requested}`)) {
return asRequested;
}
const remoteRef = requested.replace(/^remotes\//, '');
if (!(await gitRefExists(git, `refs/remotes/${remoteRef}`))) {
return asRequested;
}
const remotes = await git.getRemotes();
const remote = remotes.find((entry) => entry?.name && remoteRef.startsWith(`${entry.name}/`));
if (!remote) {
return asRequested;
}
const localBranch = remoteRef.slice(remote.name.length + 1);
// `origin/HEAD` names no branch of its own; it is a pointer to one.
if (!localBranch || localBranch === 'HEAD') {
return asRequested;
}
const localExists = await gitRefExists(git, `refs/heads/${localBranch}`);
return { branch: localBranch, remoteRef: localExists ? null : remoteRef };
};
export async function checkoutBranch(directory, branchName) {
const { git } = await createRepositoryGitContext(directory);
try {
await git.checkout(branchName);
return { success: true, branch: branchName };
const target = await resolveBranchCheckoutTarget(git, branchName);
if (target.remoteRef) {
await git.raw(['checkout', '-b', target.branch, '--track', target.remoteRef]);
} else {
await git.checkout(target.branch);
}
return { success: true, branch: target.branch };
} catch (error) {
console.error('Failed to checkout branch:', error);
throw error;
@@ -3815,7 +3974,15 @@ export async function getWorktrees(directory) {
path: entry.worktree,
}));
} catch (error) {
console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
// Worktrees are an optional feature. When the caller passes a directory
// that is not inside any git repository (for example, the managed
// OpenCode's working directory or an unconfigured project path), git
// exits with "fatal: not a git repository ...". Treat that as an
// authoritative empty result so the route handler can still respond
// 200 [] and the desktop main.log stays free of noise.
if (!isNotGitRepositoryError(error)) {
console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
}
return [];
}
}
+250 -1
View File
@@ -2,10 +2,11 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import simpleGit from 'simple-git';
import {
checkoutBranch,
checkoutCommit,
cherryPick,
createWorktree,
@@ -13,6 +14,7 @@ import {
getBranches,
getRangeDiff,
getStatus,
getWorktrees,
isGitRepository,
populateWorktreeWithLockRecovery,
removeWorktree,
@@ -28,6 +30,8 @@ import {
getDiff,
getFileDiff,
validateWorktreeCreate,
parseBranchCreationSource,
getRangeFiles,
} from './service.js';
// ---------------------------------------------------------------------------
@@ -461,6 +465,51 @@ describe('worktree root resolution', () => {
});
});
// ---------------------------------------------------------------------------
// getWorktrees
// ---------------------------------------------------------------------------
describe('getWorktrees', () => {
if (!canRunGit()) {
it.skip('git binary not available', () => {});
return;
}
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
afterEach(() => {
warnSpy.mockClear();
});
afterAll(() => {
warnSpy.mockRestore();
});
it('returns an empty list for a non-git directory without warning', async () => {
const nonGit = createTempDir();
const result = await getWorktrees(nonGit);
expect(result).toEqual([]);
expect(warnSpy).not.toHaveBeenCalled();
});
it('returns the worktrees for a real git repository', async () => {
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', 'init']);
const result = await getWorktrees(repo);
expect(Array.isArray(result)).toBe(true);
expect(warnSpy).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// createWorktree
// ---------------------------------------------------------------------------
@@ -1004,6 +1053,66 @@ describe('checkoutCommit', () => {
});
});
// ---------------------------------------------------------------------------
// checkoutBranch
// ---------------------------------------------------------------------------
describe('checkoutBranch', () => {
it('checks out a local branch by name', async () => {
const { repository } = createRepositoryWithRemote();
runGit(repository, ['branch', 'feature']);
const result = await checkoutBranch(repository, 'feature');
expect(result).toEqual({ success: true, branch: 'feature' });
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('feature');
});
it('creates a tracking local branch instead of detaching HEAD on a remote branch', async () => {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' });
const result = await checkoutBranch(repository, 'origin/react');
expect(result).toEqual({ success: true, branch: 'react' });
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('react');
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'react@{upstream}']).trim()).toBe('origin/react');
});
it('checks out the existing local branch when a remote branch is picked', async () => {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' });
runGit(repository, ['branch', 'react', 'origin/react']);
const result = await checkoutBranch(repository, 'origin/react');
expect(result).toEqual({ success: true, branch: 'react' });
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('react');
});
it('accepts the remotes/ prefixed form of a remote branch', async () => {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' });
const result = await checkoutBranch(repository, 'remotes/origin/react');
expect(result).toEqual({ success: true, branch: 'react' });
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('react');
});
it('prefers a local branch whose name looks like a remote ref', async () => {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' });
runGit(repository, ['branch', 'origin/react']);
const result = await checkoutBranch(repository, 'origin/react');
expect(result).toEqual({ success: true, branch: 'origin/react' });
expect(runGit(repository, ['symbolic-ref', 'HEAD']).trim()).toBe('refs/heads/origin/react');
});
it('rejects an unknown branch', async () => {
const { repository } = createRepositoryWithRemote();
await expect(checkoutBranch(repository, 'does-not-exist')).rejects.toThrow();
});
});
// ---------------------------------------------------------------------------
// cherryPick
// ---------------------------------------------------------------------------
@@ -1320,6 +1429,47 @@ describe.runIf(canRunGit())('getBranches', () => {
// decide whether a base branch exists at all.
expect(branches.all).toContain('remotes/origin/react');
});
it('includes remote branches with no local tracking ref and prunes refs deleted on the remote (#2098)', async () => {
const remote = createTempDir();
runGit(remote, ['init', '--bare', '--initial-branch=main']);
const repository = createTempDir();
runGit(repository, ['init', '-b', 'main']);
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', 'origin', remote]);
runGit(repository, ['push', '-u', 'origin', 'main']);
runGit(repository, ['checkout', '-b', 'feature-known']);
runGit(repository, ['push', '-u', 'origin', 'feature-known']);
// This tracking ref will go stale: the collaborator deletes the branch on
// the remote below, and the list must prune it.
runGit(repository, ['checkout', '-b', 'feature-stale']);
runGit(repository, ['push', '-u', 'origin', 'feature-stale']);
runGit(repository, ['checkout', 'main']);
runGit(repository, ['branch', '-D', 'feature-stale']);
// A collaborator pushes a branch straight to the remote and deletes
// another; this repository never fetches, so it has no local
// remote-tracking ref for feature-remote-only.
const collaborator = createTempDir();
runGit(collaborator, ['clone', remote, '.']);
runGit(collaborator, ['config', 'user.email', 'test@example.com']);
runGit(collaborator, ['config', 'user.name', 'Test']);
runGit(collaborator, ['checkout', '-b', 'feature-remote-only']);
runGit(collaborator, ['push', 'origin', 'feature-remote-only']);
runGit(collaborator, ['push', 'origin', ':feature-stale']);
const branches = await getBranches(repository);
expect(branches.all).toContain('remotes/origin/feature-remote-only');
expect(branches.all).toContain('remotes/origin/feature-known');
expect(branches.all).toContain('feature-known');
expect(branches.all).not.toContain('remotes/origin/feature-stale');
});
});
describe.runIf(canRunGit())('getRangeDiff', () => {
@@ -1336,3 +1486,102 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
expect(diff).toContain('feature.txt');
});
});
describe('parseBranchCreationSource', () => {
it('returns the source ref from the oldest creation entry', () => {
// Reflog lists newest entries first; creation is the last line.
const reflog = [
'commit: abc123',
'branch: Created from origin/main',
].join('\n');
expect(parseBranchCreationSource(reflog)).toBe('origin/main');
});
it('returns null when the branch was created from a detached HEAD pointer', () => {
const reflog = 'branch: Created from HEAD@{0}';
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null when the branch was created from the current HEAD without a named source', () => {
// `git switch -c <branch>` / `git checkout -b <branch>` from the current
// branch record `branch: Created from HEAD` in the reflog (git 2.x). The
// source branch name is not recorded, so no base can be derived from it.
const reflog = 'branch: Created from HEAD';
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null when the branch was created from a raw commit', () => {
const reflog = 'branch: Created from 9a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b';
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null when there is no creation entry', () => {
const reflog = ['commit: abc123', 'reset: moving to HEAD'].join('\n');
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null for empty input', () => {
expect(parseBranchCreationSource('')).toBeNull();
expect(parseBranchCreationSource(undefined)).toBeNull();
});
});
describe.runIf(canRunGit())('getRangeFiles', () => {
it('returns added and modified paths with their status letters', async () => {
const { repository } = createRepositoryWithRemote();
fs.writeFileSync(path.join(repository, 'added.txt'), 'new\n');
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\nchanged\n');
runGit(repository, ['add', 'added.txt', 'README.md']);
runGit(repository, ['commit', '-m', 'changes']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
expect(files).toEqual(expect.arrayContaining([
{ path: 'added.txt', status: 'A' },
{ path: 'README.md', status: 'M' },
]));
});
it('reports the destination path for renamed files, including spaces', async () => {
const { repository } = createRepositoryWithRemote();
// The original file must exist in the base: rename detection pairs a
// deletion against an addition relative to base, not within the branch.
fs.writeFileSync(path.join(repository, 'old name with spaces.md'), '# Test\n');
runGit(repository, ['add', 'old name with spaces.md']);
runGit(repository, ['commit', '-m', 'add file to rename']);
runGit(repository, ['push', 'origin', 'HEAD:react']);
// Spaces in filenames exercise the -z token split: a newline split would
// mangle these paths long before status letters matter.
fs.renameSync(path.join(repository, 'old name with spaces.md'), path.join(repository, 'new name with spaces.md'));
runGit(repository, ['add', '-A']);
runGit(repository, ['commit', '-m', 'rename']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
const renameEntry = files.find((file) => file.status === 'R');
expect(renameEntry).toBeDefined();
expect(renameEntry.path).toBe('new name with spaces.md');
expect(files.some((file) => file.path === 'old name with spaces.md')).toBe(false);
});
it('reports the destination path for copied files', async () => {
const { repository } = createRepositoryWithRemote();
// The source must exist in the base. Copy detection needs the repository's
// own `diff.renames=copies` setting on top of the service's -C flag; the
// parser must survive whatever C entries git emits.
runGit(repository, ['config', 'diff.renames', 'copies']);
fs.writeFileSync(path.join(repository, 'copied source.md'), '# Copy me\n');
runGit(repository, ['add', 'copied source.md']);
runGit(repository, ['commit', '-m', 'add source']);
runGit(repository, ['push', 'origin', 'HEAD:react']);
fs.copyFileSync(path.join(repository, 'copied source.md'), path.join(repository, 'copied destination.md'));
runGit(repository, ['add', '-A']);
runGit(repository, ['commit', '-m', 'copy']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
const copyEntry = files.find((file) => file.status === 'C');
expect(copyEntry).toBeDefined();
expect(copyEntry.path).toBe('copied destination.md');
});
});
+10 -1
View File
@@ -674,7 +674,16 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote
};
}
const sourceCandidates = resolvedTargets.slice();
// Only the repo this branch actually pushes to (the ranked-first remote)
// and its fork network can be the SOURCE of the branch's PRs. Other
// configured remotes — a maintainer's checkout often carries contributor
// forks — are places to look for an open PR, but their `owner:branch`
// heads are unrelated branches that merely share a name; treating them as
// sources made a fork's closed `main` PR show up on the local main.
const primaryRemoteName = resolvedTargets[0]?.remoteName ?? null;
const sourceCandidates = resolvedTargets.filter(
(target) => target.remoteName === primaryRemoteName,
);
// 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 };
@@ -34,3 +34,7 @@ server implementation. VS Code does not call this route for workspace images;
those use its local filesystem bridge. If called, the grant route returns an
explicit unsupported response because OpenCode temporary images are not
supported there.
Requests to OpenCode carry the directory in a percent-encoded
`x-opencode-directory` header, matching the SDK wire format; OpenCode rejects
raw non-ASCII header values.
@@ -200,7 +200,9 @@ const fetchMessage = async ({ sessionId, messageId, directory, buildOpenCodeUrl,
const response = await fetch(url, {
headers: {
accept: 'application/json',
'x-opencode-directory': directory,
// Percent-encoded to match the SDK wire format; raw non-ASCII values
// are rejected by OpenCode.
'x-opencode-directory': encodeURIComponent(directory),
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(10_000),
@@ -74,6 +74,20 @@ const prepare = (app, directory, sources) => request(app)
.expect(200);
describe('session image assets', () => {
it('percent-encodes the directory header on the message fetch', async () => {
const fixture = await createFixture();
await prepare(fixture.app, fixture.directory, ['image.png']);
expect(fixture.fetchMock).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
headers: expect.objectContaining({
'x-opencode-directory': encodeURIComponent(fixture.directory),
}),
}),
);
});
it('prepares workspace and OpenCode temporary images with one message fetch', async () => {
const fixture = await createFixture({ sources: ['workspace.png'] });
await fs.writeFile(path.join(fixture.directory, 'workspace.png'), PNG);
@@ -83,7 +83,10 @@ const resolveVariant = (providers, providerID, modelID, variant) => {
const parseConfigModel = (value) => splitModel(value);
const buildDirectoryHeaders = (directory) => ({
...(directory ? { 'x-opencode-directory': directory } : {}),
// OpenCode rejects non-ASCII header values; the official SDK sends this
// header percent-encoded, so match that wire format (non-ASCII checkout
// paths such as "Masaüstü" otherwise fail every dispatched prompt).
...(directory ? { 'x-opencode-directory': encodeURIComponent(directory) } : {}),
});
const fetchJson = async (url, authHeaders, fallback, directory) => {
@@ -161,6 +161,29 @@ describe('openchamber session routes', () => {
}
});
it('percent-encodes the directory header for non-ASCII checkout paths', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/home/user/Masaüstü/projeler', title: 'Side task' })
.expect(200);
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'x-opencode-directory': encodeURIComponent('/home/user/Masaüstü/projeler'),
}),
}),
);
} 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' }) }));
@@ -207,7 +207,8 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
- `readSettingsFromDiskMigrated()`
- `writeSettingsToDisk(settings)`
- `persistSettings(changes)`
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
## Public exports (settings-helpers.js)
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -5,6 +5,12 @@ import path from 'node:path';
import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js';
import { mergePathValues } from './path-utils.js';
// Login-shell probes source the user's rc files. A slow or interactive rc
// (nvm, pyenv, a prompt waiting for input) must not hold server startup
// hostage: a probe that overruns is abandoned and resolution falls through
// to the next candidate. Electron's own login-shell probe uses the same bound.
const SHELL_PROBE_TIMEOUT_MS = 5_000;
export const createOpenCodeEnvRuntime = (deps) => {
const {
state,
@@ -208,6 +214,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
timeout: SHELL_PROBE_TIMEOUT_MS,
});
if (result.status !== 0) {
@@ -460,6 +467,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
timeout: SHELL_PROBE_TIMEOUT_MS,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
@@ -527,6 +535,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
timeout: SHELL_PROBE_TIMEOUT_MS,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
@@ -608,6 +617,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
timeout: SHELL_PROBE_TIMEOUT_MS,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
@@ -1011,7 +1021,13 @@ export const createOpenCodeEnvRuntime = (deps) => {
const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary);
if (normalized === '') {
delete process.env.OPENCODE_BINARY;
// The empty-string sentinel drops a previously APPLIED settings
// override (source === 'settings'). An OPENCODE_BINARY provided by
// the user's own environment is explicit configuration and must not
// be destroyed by an empty setting.
if (state.resolvedOpencodeBinarySource === 'settings') {
delete process.env.OPENCODE_BINARY;
}
state.resolvedOpencodeBinary = null;
state.resolvedOpencodeBinarySource = null;
clearWslOpencodeResolution();
@@ -200,6 +200,39 @@ describe('OpenCode env runtime', () => {
expect(state.resolvedOpencodeBinarySource).toBe('settings');
});
it('keeps an env-provided OPENCODE_BINARY when the setting is an empty-string sentinel', async () => {
const dir = createTempDir('openchamber-env-opencode-');
const binary = path.join(dir, 'opencode');
fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') fs.chmodSync(binary, 0o755);
process.env.OPENCODE_BINARY = binary;
const { runtime, state } = createRuntime({ opencodeBinary: '' });
await expect(runtime.applyOpencodeBinaryFromSettings()).resolves.toBeNull();
expect(process.env.OPENCODE_BINARY).toBe(binary);
expect(state.resolvedOpencodeBinary).toBeNull();
expect(state.resolvedOpencodeBinarySource).toBeNull();
});
it('drops a previously applied settings override when the setting is cleared to an empty string', async () => {
const dir = createTempDir('openchamber-settings-opencode-');
const binary = path.join(dir, 'opencode');
fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') fs.chmodSync(binary, 0o755);
const settings = { opencodeBinary: binary };
const { runtime, state } = createRuntime(settings);
await expect(runtime.applyOpencodeBinaryFromSettings()).resolves.toBe(binary);
expect(process.env.OPENCODE_BINARY).toBe(binary);
expect(state.resolvedOpencodeBinarySource).toBe('settings');
settings.opencodeBinary = '';
await expect(runtime.applyOpencodeBinaryFromSettings()).resolves.toBeNull();
expect(process.env.OPENCODE_BINARY).toBeUndefined();
expect(state.resolvedOpencodeBinary).toBeNull();
expect(state.resolvedOpencodeBinarySource).toBeNull();
});
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');
@@ -301,6 +334,29 @@ describe('OpenCode env runtime', () => {
});
});
it('bounds every login-shell probe and falls through when one overruns', () => {
setPlatform('darwin');
process.env.PATH = createTempDir('openchamber-empty-path-');
process.env.SHELL = '/bin/zsh';
delete process.env.OPENCODE_BINARY;
const shellCalls = [];
const { runtime } = createRuntime({}, {
homedir: () => createTempDir('openchamber-empty-home-'),
spawnSync: (command, args, options) => {
shellCalls.push({ command, args, options });
// What spawnSync reports when `timeout` fires: no status, an error.
return { status: null, signal: 'SIGTERM', error: new Error('spawnSync ETIMEDOUT'), stdout: '', stderr: '' };
},
});
expect(runtime.resolveOpencodeCliPath()).toBeNull();
expect(shellCalls.length).toBeGreaterThan(0);
for (const call of shellCalls) {
expect(call.args).toContain('-lic');
expect(call.options.timeout).toBe(5_000);
}
});
it('does not auto-detect the Windows OpenCode desktop app as a CLI', () => {
setPlatform('win32');
const localAppData = createTempDir('openchamber-localappdata-');
+48 -9
View File
@@ -112,8 +112,45 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
now = Date.now,
} = deps;
const killProcessOnPortWin32 = (port) => {
try {
// Get-NetTCPConnection reads the same locale-independent WinNT API
// netstat's display layer translates (e.g. "LISTENING" renders as
// "ABHÖREN"/"ÉCOUTE"/"ESCUTANDO" on non-English Windows), so this
// works regardless of the OS display language.
const result = spawnSync(
'powershell',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`Get-NetTCPConnection -State Listen -LocalPort ${Number.parseInt(port, 10)} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`,
],
{ encoding: 'utf8', timeout: 5000, windowsHide: true }
);
const output = result.stdout || '';
const myPid = process.pid;
const pids = new Set();
for (const line of output.split(/\r?\n/)) {
const pid = Number.parseInt(line.trim(), 10);
if (pid && pid !== myPid) pids.add(pid);
}
for (const pid of pids) {
try {
spawnSync('taskkill', ['/PID', String(pid), '/F'], { stdio: 'ignore', timeout: 3000, windowsHide: true });
} catch {
}
}
} catch {
}
};
const killProcessOnPort = (port) => {
if (!port || process.platform === 'win32') return;
if (!port) return;
if (process.platform === 'win32') {
killProcessOnPortWin32(port);
return;
}
try {
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true });
const output = result.stdout || '';
@@ -324,7 +361,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
// Drop it from the registry only once it has actually exited, so a child
// that survived teardown stays eligible for the next run's reaper.
if (Number.isInteger(pid) && hasChildProcessExited(child)) {
unregisterManagedProcess(pid);
await unregisterManagedProcess(pid);
}
}
};
@@ -477,7 +514,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
// actual host (Electron sets OPENCHAMBER_RUNTIME='desktop'; the standalone
// web CLI leaves it unset → 'web'; SSH remote → 'ssh-remote') rather than a
// hardcoded label, matching the server's existing runtimeName convention.
registerManagedProcess({
await registerManagedProcess({
pid: child.pid,
ownerPid: process.pid,
port,
@@ -792,11 +829,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
if (state.isExternalOpenCode) {
console.log('Re-probing external OpenCode server...');
const probePort = state.openCodePort || env.ENV_CONFIGURED_OPENCODE_PORT || 4096;
const probePort = state.openCodePort ?? env.ENV_EFFECTIVE_PORT ?? 4096;
const probeOrigin = state.openCodeBaseUrl ?? env.ENV_CONFIGURED_OPENCODE_HOST?.origin;
const healthy = await probeExternalOpenCode(probePort, probeOrigin);
if (healthy) {
console.log(`External OpenCode server on port ${probePort} is healthy`);
state.openCodeBaseUrl = probeOrigin ?? null;
setOpenCodePort(probePort);
state.isOpenCodeReady = true;
state.lastOpenCodeError = null;
@@ -859,10 +897,11 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
}
// The restart may have landed on a NEW port (the old one can remain
// occupied by an orphaned process, e.g. Windows killProcessOnPort is a
// no-op). Upstream event readers pinned to the old process would keep
// the UI silent forever, so rebind them to the current port. Best
// effort: a failure here must not fail the restart itself.
// occupied if killProcessOnPort/waitForPortRelease didn't free it in
// time, on any platform). Upstream event readers pinned to the old
// process would keep the UI silent forever, so rebind them to the
// current port. Best effort: a failure here must not fail the restart
// itself.
try {
onOpenCodeRestarted?.();
} catch (error) {
@@ -875,7 +914,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
} catch (error) {
console.error(`Failed to restart OpenCode: ${error.message}`);
state.lastOpenCodeError = error.message;
if (!env.ENV_CONFIGURED_OPENCODE_PORT) {
if (!env.ENV_EFFECTIVE_PORT) {
state.openCodePort = null;
syncToHmrState();
}
@@ -2,11 +2,17 @@ import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
const spawnMock = vi.fn();
const spawnSyncMock = vi.fn();
const recordStartupPerformanceMock = vi.fn();
vi.mock('node:child_process', () => ({
spawn: spawnMock,
spawnSync: vi.fn(),
spawnSync: spawnSyncMock,
// `managed-process-registry.js` (imported transitively via lifecycle.js)
// calls `promisify(execFile)` at module load, so the mock must expose a
// function here. Lifecycle tests don't exercise the reaper path, so a plain
// stub is enough; the registry's best-effort writes are no-ops on errors.
execFile: vi.fn(),
}));
vi.mock('./startup-performance.js', () => ({
recordStartupPerformance: recordStartupPerformanceMock,
@@ -20,6 +26,7 @@ const originalFetch = globalThis.fetch;
afterEach(() => {
spawnMock.mockReset();
spawnSyncMock.mockReset();
recordStartupPerformanceMock.mockReset();
globalThis.fetch = originalFetch;
if (typeof originalOpencodeBinary === 'string') {
@@ -151,6 +158,56 @@ describe('OpenCode lifecycle', () => {
expect(terminalEvents).toHaveLength(1);
});
it('recovers an external OPENCODE_HOST connection using its configured endpoint', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ healthy: true }),
}));
globalThis.fetch = fetchMock;
const runtime = createRuntime({}, {
openCodePort: null,
openCodeBaseUrl: null,
isExternalOpenCode: true,
}, {
ENV_CONFIGURED_OPENCODE_PORT: null,
ENV_CONFIGURED_OPENCODE_HOST: { origin: 'http://seamus:4095', port: 4095 },
ENV_EFFECTIVE_PORT: 4095,
});
await runtime.restartOpenCode();
expect(fetchMock).toHaveBeenCalledWith(
'http://seamus:4095/global/health',
expect.objectContaining({ method: 'GET' }),
);
expect(runtime.testState.openCodePort).toBe(4095);
expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
expect(runtime.testState.lastOpenCodeError).toBeNull();
});
it('retains the OPENCODE_HOST port after an external re-probe fails', async () => {
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
const runtime = createRuntime({}, {
openCodePort: 4095,
openCodeBaseUrl: 'http://seamus:4095',
isExternalOpenCode: true,
}, {
ENV_CONFIGURED_OPENCODE_PORT: null,
ENV_CONFIGURED_OPENCODE_HOST: { origin: 'http://seamus:4095', port: 4095 },
ENV_EFFECTIVE_PORT: 4095,
});
await expect(runtime.restartOpenCode()).rejects.toThrow(
'External OpenCode server on port 4095 is not responding',
);
expect(runtime.testState.openCodePort).toBe(4095);
expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
});
it('warms recently used directories after a successful bootstrap', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
@@ -791,3 +848,70 @@ describe('OpenCode lifecycle', () => {
await server.close();
});
});
describe('killProcessOnPort on Windows', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
const setPlatform = (platform) => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
};
it('force-kills the process listening on the target port via taskkill', () => {
setPlatform('win32');
const orphanPid = 54321;
spawnSyncMock.mockImplementation((cmd) => {
if (cmd === 'powershell') {
return { stdout: `${orphanPid}\r\n` };
}
return { stdout: '' };
});
const runtime = createRuntime();
runtime.killProcessOnPort(45678);
expect(spawnSyncMock).toHaveBeenCalledWith(
'powershell',
expect.arrayContaining([expect.stringContaining('-LocalPort 45678')]),
expect.objectContaining({ windowsHide: true })
);
expect(spawnSyncMock).toHaveBeenCalledWith(
'taskkill',
['/PID', String(orphanPid), '/F'],
expect.objectContaining({ windowsHide: true })
);
});
it('never force-kills its own process id', () => {
setPlatform('win32');
spawnSyncMock.mockImplementation((cmd) => {
if (cmd === 'powershell') {
return { stdout: `${process.pid}\r\n` };
}
return { stdout: '' };
});
const runtime = createRuntime();
runtime.killProcessOnPort(45678);
expect(spawnSyncMock).not.toHaveBeenCalledWith('taskkill', expect.anything(), expect.anything());
});
it('does nothing when no process is listening on the target port', () => {
setPlatform('win32');
spawnSyncMock.mockImplementation((cmd) => {
if (cmd === 'powershell') {
return { stdout: '' };
}
return { stdout: '' };
});
const runtime = createRuntime();
runtime.killProcessOnPort(45678);
expect(spawnSyncMock).not.toHaveBeenCalledWith('taskkill', expect.anything(), expect.anything());
});
});
@@ -0,0 +1,13 @@
export function registerManagedProcess(entry: {
pid?: number;
ownerPid?: number;
port?: number | null;
binary?: string | null;
runtime?: string;
}): Promise<void>;
export function unregisterManagedProcess(pid?: number): Promise<void>;
export function reapOrphanedProcesses(options?: {
log?: (message: string) => void;
}): Promise<{ inspected: number; reaped: number }>;
@@ -32,13 +32,26 @@
// been reparented to init/pid 1, or the recorded owner pid is dead. A
// child still owned by a live instance is left untouched.
//
// All filesystem and child-process operations here are ASYNCHRONOUS. The web
// server runs in-process inside the Electron main event loop (and other hosts),
// so any `spawnSync`/`*Sync` FS call blocks the single event loop — which also
// serves UI asset requests and realtime SSE traffic. The startup reaper can
// iterate several registry entries and, on Windows, each one spawns `tasklist`
// (100-500ms) and possibly `taskkill`; doing that synchronously stalls the
// whole process and is what caused the 1.13.3 `openchamber-ui://` lag
// regression (#1841). `execFile`/`fsp.*` keep the event loop responsive while
// the reaper waits on the kernel.
//
// The VS Code extension cannot import this module (it does not bundle the web
// package); it carries a parity implementation that reads/writes the SAME dir.
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const defaultExecFileAsync = promisify(execFile);
const resolveRegistryDir = () => {
const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY;
@@ -48,67 +61,6 @@ const resolveRegistryDir = () => {
const entryFilePath = (pid) => path.join(resolveRegistryDir(), `${pid}.json`);
const writeEntryFile = (entry) => {
const dir = resolveRegistryDir();
try {
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${entry.pid}.json`);
const tmp = `${filePath}.tmp-${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
fs.renameSync(tmp, filePath);
} catch {
// Best-effort: a failed registry write must never break spawn/shutdown.
}
};
const readAllEntries = () => {
const dir = resolveRegistryDir();
let names = [];
try {
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
} catch {
return [];
}
const out = [];
for (const name of names) {
const filePath = path.join(dir, name);
try {
const entry = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (entry && Number.isInteger(entry.pid)) {
out.push({ entry, filePath });
} else {
fs.rmSync(filePath, { force: true });
}
} catch {
// Corrupt/partial file — drop it.
try { fs.rmSync(filePath, { force: true }); } catch {}
}
}
return out;
};
/** Record an OpenCode process WE spawned so a future run can reap it if orphaned. */
export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } = {}) => {
if (!Number.isInteger(pid)) return;
writeEntryFile({
pid,
ownerPid: Number.isInteger(ownerPid) ? ownerPid : process.pid,
port: Number.isInteger(port) ? port : null,
binary: typeof binary === 'string' ? binary : null,
runtime: typeof runtime === 'string' ? runtime : 'web',
startedAt: new Date().toISOString(),
});
};
/** Drop a pid from the registry (after we have killed/closed it ourselves). */
export const unregisterManagedProcess = (pid) => {
if (!Number.isInteger(pid)) return;
try {
fs.rmSync(entryFilePath(pid), { force: true });
} catch {
}
};
const isPidAlive = (pid) => {
if (!Number.isInteger(pid)) return false;
try {
@@ -122,38 +74,6 @@ const isPidAlive = (pid) => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Returns { ppid, command } for a live pid on Unix, or null if it can't be read.
const readUnixProcInfo = (pid) => {
try {
const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
const line = (result.stdout || '').trim();
if (!line) return null;
const match = line.match(/^\s*(\d+)\s+(.*)$/);
if (!match) return null;
return { ppid: Number.parseInt(match[1], 10), command: match[2] };
} catch {
return null;
}
};
// Windows image name for a pid (e.g. "opencode.exe"), or null.
const readWindowsImageName = (pid) => {
try {
const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
return (result.stdout || '').trim() || null;
} catch {
return null;
}
};
const commandIdentifiesOurServer = (command, entry) => {
if (typeof command !== 'string') return false;
const lower = command.toLowerCase();
@@ -164,88 +84,218 @@ const commandIdentifiesOurServer = (command, entry) => {
return true;
};
const killOrphan = async (pid) => {
if (process.platform === 'win32') {
/**
* Build the registry API over injectable filesystem and child-process
* dependencies. Production callers use the default instance exported below;
* tests pass their own `fs`/`execFileAsync` instead of mocking node builtins.
*/
export const createManagedProcessRegistry = ({ fs = fsp, execFileAsync = defaultExecFileAsync } = {}) => {
const writeEntryFile = async (entry) => {
const dir = resolveRegistryDir();
try {
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true });
await fs.mkdir(dir, { recursive: true });
const filePath = path.join(dir, `${entry.pid}.json`);
const tmp = `${filePath}.tmp-${process.pid}`;
await fs.writeFile(tmp, JSON.stringify(entry, null, 2));
await fs.rename(tmp, filePath);
} catch {
// Best-effort: a failed registry write must never break spawn/shutdown.
}
return;
}
const signalTree = (signal) => {
try { process.kill(-pid, signal); } catch {}
try { process.kill(pid, signal); } catch {}
};
signalTree('SIGTERM');
for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) {
await sleep(150);
}
if (isPidAlive(pid)) {
signalTree('SIGKILL');
await sleep(300);
}
};
// Decide+act on a single registry entry. Returns true if it was reaped.
const processEntry = async (entry, { log }) => {
// Dead pid → nothing to do (caller drops the file).
if (!isPidAlive(entry.pid)) return false;
const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid);
if (process.platform === 'win32') {
const image = readWindowsImageName(entry.pid);
const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode');
// Windows lacks reliable reparent-to-1 semantics (job objects usually kill
// children with the parent), so we reap only when the owner is provably dead
// AND the image still looks like opencode.
if (looksLikeOpencode && ownerGone) {
await killOrphan(entry.pid);
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (owner ${entry.ownerPid} gone)`);
return true;
}
return false;
}
const info = readUnixProcInfo(entry.pid);
// Can't verify identity (or it's not our server) → leave it alone.
if (!info || !commandIdentifiesOurServer(info.command, entry)) return false;
const orphaned = info.ppid === 1 || ownerGone;
if (!orphaned) return false; // still owned by a live instance
await killOrphan(entry.pid);
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (reparented/owner gone)`);
return true;
};
/**
* Kill any genuinely-orphaned OpenCode processes WE previously spawned, and
* prune their registry files. Safe to call at startup before spawning a new
* server. Returns { inspected, reaped }.
*/
export const reapOrphanedProcesses = async ({ log } = {}) => {
const records = readAllEntries();
if (records.length === 0) return { inspected: 0, reaped: 0 };
let reaped = 0;
for (const { entry, filePath } of records) {
let drop = false;
const readAllEntries = async () => {
const dir = resolveRegistryDir();
let names = [];
try {
const wasReaped = await processEntry(entry, { log });
if (wasReaped) reaped += 1;
// Drop the file when the process is gone (reaped now, or already dead);
// keep it only while the process is still alive and owned by a live owner.
drop = wasReaped || !isPidAlive(entry.pid);
} catch (error) {
log?.(`[lifecycle] reap check failed for pid ${entry.pid}: ${error?.message ?? error}`);
names = await fs.readdir(dir);
} catch {
return [];
}
if (drop) {
try { fs.rmSync(filePath, { force: true }); } catch {}
const out = [];
for (const name of names.filter((value) => value.endsWith('.json'))) {
const filePath = path.join(dir, name);
try {
const entry = JSON.parse(await fs.readFile(filePath, 'utf8'));
if (entry && Number.isInteger(entry.pid)) {
out.push({ entry, filePath });
} else {
await fs.rm(filePath, { force: true });
}
} catch {
// Corrupt/partial file — drop it.
try {
await fs.rm(filePath, { force: true });
} catch {
// ignore
}
}
}
}
return out;
};
return { inspected: records.length, reaped };
/** Record an OpenCode process WE spawned so a future run can reap it if orphaned. */
const registerManagedProcess = async ({ pid, ownerPid, port, binary, runtime } = {}) => {
if (!Number.isInteger(pid)) return;
await writeEntryFile({
pid,
ownerPid: Number.isInteger(ownerPid) ? ownerPid : process.pid,
port: Number.isInteger(port) ? port : null,
binary: typeof binary === 'string' ? binary : null,
runtime: typeof runtime === 'string' ? runtime : 'web',
startedAt: new Date().toISOString(),
});
};
/** Drop a pid from the registry (after we have killed/closed it ourselves). */
const unregisterManagedProcess = async (pid) => {
if (!Number.isInteger(pid)) return;
try {
await fs.rm(entryFilePath(pid), { force: true });
} catch {
// Best-effort: dropping a missing file is not an error.
}
};
// Returns { ppid, command } for a live pid on Unix, or null if it can't be read.
const readUnixProcInfo = async (pid) => {
try {
const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'ppid=,command='], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
const line = (stdout || '').trim();
if (!line) return null;
const match = line.match(/^\s*(\d+)\s+(.*)$/);
if (!match) return null;
return { ppid: Number.parseInt(match[1], 10), command: match[2] };
} catch {
return null;
}
};
// Windows image name for a pid (e.g. "opencode.exe"), or null.
const readWindowsImageName = async (pid) => {
try {
const { stdout } = await execFileAsync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
return (stdout || '').trim() || null;
} catch {
return null;
}
};
const killOrphan = async (pid) => {
if (process.platform === 'win32') {
try {
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
stdio: 'ignore',
timeout: 5000,
windowsHide: true,
});
} catch {
// Best-effort: a failed kill is not fatal (startup reaper is a backstop).
}
return;
}
const signalTree = (signal) => {
try {
process.kill(-pid, signal);
} catch {
// process group may already be gone
}
try {
process.kill(pid, signal);
} catch {
// pid may already be gone
}
};
signalTree('SIGTERM');
for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) {
await sleep(150);
}
if (isPidAlive(pid)) {
signalTree('SIGKILL');
await sleep(300);
}
};
// Decide+act on a single registry entry. Returns true if it was reaped.
const processEntry = async (entry, { log }) => {
// Dead pid → nothing to do (caller drops the file).
if (!isPidAlive(entry.pid)) return false;
const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid);
if (process.platform === 'win32') {
const image = await readWindowsImageName(entry.pid);
const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode');
// Windows lacks reliable reparent-to-1 semantics (job objects usually kill
// children with the parent), so we reap only when the owner is provably dead
// AND the image still looks like opencode.
if (looksLikeOpencode && ownerGone) {
await killOrphan(entry.pid);
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (owner ${entry.ownerPid} gone)`);
return true;
}
return false;
}
const info = await readUnixProcInfo(entry.pid);
// Can't verify identity (or it's not our server) → leave it alone.
if (!info || !commandIdentifiesOurServer(info.command, entry)) return false;
const orphaned = info.ppid === 1 || ownerGone;
if (!orphaned) return false; // still owned by a live instance
await killOrphan(entry.pid);
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (reparented/owner gone)`);
return true;
};
/**
* Kill any genuinely-orphaned OpenCode processes WE previously spawned, and
* prune their registry files. Safe to call at startup before spawning a new
* server. Returns { inspected, reaped }.
*/
const reapOrphanedProcesses = async ({ log } = {}) => {
const records = await readAllEntries();
if (records.length === 0) return { inspected: 0, reaped: 0 };
let reaped = 0;
for (const { entry, filePath } of records) {
let drop = false;
try {
const wasReaped = await processEntry(entry, { log });
if (wasReaped) reaped += 1;
// Drop the file when the process is gone (reaped now, or already dead);
// keep it only while the process is still alive and owned by a live owner.
drop = wasReaped || !isPidAlive(entry.pid);
} catch (error) {
log?.(`[lifecycle] reap check failed for pid ${entry.pid}: ${error?.message ?? error}`);
}
if (drop) {
try {
await fs.rm(filePath, { force: true });
} catch {
// best-effort
}
}
}
return { inspected: records.length, reaped };
};
return { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses };
};
const defaultRegistry = createManagedProcessRegistry();
export const registerManagedProcess = defaultRegistry.registerManagedProcess;
export const unregisterManagedProcess = defaultRegistry.unregisterManagedProcess;
export const reapOrphanedProcesses = defaultRegistry.reapOrphanedProcesses;
@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createManagedProcessRegistry } from './managed-process-registry.js';
// The registry takes its filesystem and child-process helpers as dependencies,
// so these tests inject fakes instead of mocking node builtins.
const readdirMock = vi.fn();
const readFileMock = vi.fn();
const rmMock = vi.fn();
const mkdirMock = vi.fn();
const writeFileMock = vi.fn();
const renameMock = vi.fn();
// `execFileImpl` is the swappable per-test implementation, called with the same
// (cmd, args, opts, cb) shape the callback-style `execFile` uses; the injected
// `execFileAsync` adapts it to the `{ stdout, stderr }` promise the module awaits.
const execFileImpl = vi.fn();
const { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } = createManagedProcessRegistry({
fs: {
readdir: readdirMock,
readFile: readFileMock,
rm: rmMock,
mkdir: mkdirMock,
writeFile: writeFileMock,
rename: renameMock,
},
execFileAsync: (cmd, args, opts) =>
new Promise((resolve, reject) => {
execFileImpl(cmd, args, opts, (err, stdout, stderr) =>
err ? reject(err) : resolve({ stdout: stdout ?? '', stderr: stderr ?? '' }));
}),
});
const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform');
const ORIGINAL_KILL = process.kill;
const killMock = vi.fn();
const setPlatform = (platform) => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
};
const restorePlatform = () => {
if (ORIGINAL_PLATFORM) {
Object.defineProperty(process, 'platform', ORIGINAL_PLATFORM);
}
};
const installKillMock = () => {
Object.defineProperty(process, 'kill', { value: killMock, configurable: true });
};
const restoreKill = () => {
Object.defineProperty(process, 'kill', { value: ORIGINAL_KILL, configurable: true });
};
// Helper to make given pids look alive on signal-0 (returns true); any other
// pid throws ESRCH (dead). Non-zero signals always "succeed" so `killOrphan`'s
// signalTree is inert under test.
const killAliveFor = (alivePids) =>
killMock.mockImplementation((pid, signal) => {
if (signal === 0 || signal === undefined) {
if (alivePids.includes(pid)) return true;
const error = new Error('ESRCH');
error.code = 'ESRCH';
throw error;
}
return true;
});
// Configure `execFileImpl` with a (cmd, args, opts, cb) dispatcher.
const execFileYields = (dispatch) =>
execFileImpl.mockImplementation((cmd, args, opts, cb) => dispatch(cmd, args, opts, cb));
beforeEach(() => {
readdirMock.mockReset();
readFileMock.mockReset();
rmMock.mockReset();
mkdirMock.mockReset();
writeFileMock.mockReset();
renameMock.mockReset();
execFileImpl.mockReset();
killMock.mockReset();
installKillMock();
});
afterEach(() => {
restoreKill();
restorePlatform();
});
describe('reapOrphanedProcesses', () => {
it('returns zero counts when the registry directory is missing', async () => {
readdirMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const result = await reapOrphanedProcesses();
expect(result).toEqual({ inspected: 0, reaped: 0 });
expect(execFileImpl).not.toHaveBeenCalled();
});
it('drops registry entries whose pid is already dead, without spawning anything', async () => {
readdirMock.mockResolvedValue(['99999.json']);
readFileMock.mockResolvedValue(
JSON.stringify({ pid: 99999, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }),
);
killMock.mockImplementation(() => {
const error = new Error('ESRCH');
error.code = 'ESRCH';
throw error;
});
rmMock.mockResolvedValue();
const result = await reapOrphanedProcesses();
expect(result).toEqual({ inspected: 1, reaped: 0 });
expect(rmMock).toHaveBeenCalledTimes(1);
expect(execFileImpl).not.toHaveBeenCalled();
});
describe('on Windows', () => {
beforeEach(() => setPlatform('win32'));
it('reaps an opencode image whose owner is gone', async () => {
readdirMock.mockResolvedValue(['777.json']);
readFileMock.mockResolvedValue(
JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }),
);
// pid 777 alive, owner 12345 dead.
killAliveFor([777]);
execFileYields((cmd, _args, _opts, cb) => {
if (cmd === 'tasklist') return cb(null, 'opencode.exe', '');
if (cmd === 'taskkill') return cb(null, '', '');
cb(new Error(`unexpected cmd: ${cmd}`));
});
rmMock.mockResolvedValue();
const result = await reapOrphanedProcesses({ log: () => {} });
expect(result).toEqual({ inspected: 1, reaped: 1 });
expect(execFileImpl).toHaveBeenCalledWith(
'tasklist',
expect.any(Array),
expect.objectContaining({ windowsHide: true }),
expect.any(Function),
);
expect(execFileImpl).toHaveBeenCalledWith(
'taskkill',
expect.any(Array),
expect.objectContaining({ windowsHide: true }),
expect.any(Function),
);
});
it('leaves a non-opencode image alone even if the owner is gone', async () => {
readdirMock.mockResolvedValue(['777.json']);
readFileMock.mockResolvedValue(
JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }),
);
killAliveFor([777]);
execFileYields((_cmd, _args, _opts, cb) => cb(null, 'notepad.exe', ''));
rmMock.mockResolvedValue();
const result = await reapOrphanedProcesses({ log: () => {} });
expect(result).toEqual({ inspected: 1, reaped: 0 });
const calls = execFileImpl.mock.calls.filter(([cmd]) => cmd === 'taskkill');
expect(calls).toHaveLength(0);
});
it('leaves an opencode image whose owner is still alive', async () => {
readdirMock.mockResolvedValue(['777.json']);
readFileMock.mockResolvedValue(
JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }),
);
// Both alive.
killAliveFor([777, 12345]);
execFileYields((_cmd, _args, _opts, cb) => cb(null, 'opencode.exe', ''));
const result = await reapOrphanedProcesses({ log: () => {} });
expect(result).toEqual({ inspected: 1, reaped: 0 });
const calls = execFileImpl.mock.calls.filter(([cmd]) => cmd === 'taskkill');
expect(calls).toHaveLength(0);
});
});
describe('on Unix', () => {
beforeEach(() => setPlatform('linux'));
it('reaps a reparented opencode serve matching the recorded port', async () => {
readdirMock.mockResolvedValue(['777.json']);
readFileMock.mockResolvedValue(
JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }),
);
// pid 777 stays "alive"; killOrphan's signalTree is inert (mock returns
// true for non-zero signals), and its wait loop sees isPidAlive true so
// it exhausts the SIGTERM wait then sends SIGKILL and sleeps 300ms.
killAliveFor([777]);
execFileYields((cmd, _args, _opts, cb) => {
if (cmd === 'ps') return cb(null, '1 /usr/bin/opencode serve --port 4096\n', '');
cb(new Error(`unexpected cmd: ${cmd}`));
});
rmMock.mockResolvedValue();
const result = await reapOrphanedProcesses({ log: () => {} });
expect(result).toEqual({ inspected: 1, reaped: 1 });
});
it('leaves a process whose command is not our opencode serve', async () => {
readdirMock.mockResolvedValue(['777.json']);
readFileMock.mockResolvedValue(
JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }),
);
killAliveFor([777]);
execFileYields((cmd, _args, _opts, cb) => {
if (cmd === 'ps') return cb(null, '1 /some/other/binary serve\n', '');
cb(new Error(`unexpected cmd: ${cmd}`));
});
const result = await reapOrphanedProcesses({ log: () => {} });
expect(result).toEqual({ inspected: 1, reaped: 0 });
});
it('leaves a process still owned by a live owner (not reparented)', async () => {
readdirMock.mockResolvedValue(['777.json']);
readFileMock.mockResolvedValue(
JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }),
);
killAliveFor([777, 12345]);
execFileYields((cmd, _args, _opts, cb) => {
if (cmd === 'ps') return cb(null, '12345 /usr/bin/opencode serve --port 4096\n', '');
cb(new Error(`unexpected cmd: ${cmd}`));
});
const result = await reapOrphanedProcesses({ log: () => {} });
expect(result).toEqual({ inspected: 1, reaped: 0 });
});
});
});
describe('registerManagedProcess', () => {
it('writes an entry file atomically via tmp + rename', async () => {
mkdirMock.mockResolvedValue();
writeFileMock.mockResolvedValue();
renameMock.mockResolvedValue();
await registerManagedProcess({ pid: 4242, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'desktop' });
expect(mkdirMock).toHaveBeenCalledWith(expect.any(String), { recursive: true });
expect(writeFileMock).toHaveBeenCalledWith(
expect.stringContaining('4242.json.tmp-'),
expect.any(String),
);
expect(renameMock).toHaveBeenCalledWith(
expect.stringContaining('4242.json.tmp-'),
expect.stringContaining('4242.json'),
);
});
it('is a no-op for a non-integer pid', async () => {
await registerManagedProcess({ pid: 'not-a-pid' });
expect(writeFileMock).not.toHaveBeenCalled();
});
});
describe('unregisterManagedProcess', () => {
it('removes the entry file', async () => {
rmMock.mockResolvedValue();
await unregisterManagedProcess(4242);
expect(rmMock).toHaveBeenCalledWith(expect.stringContaining('4242.json'), { force: true });
});
it('is a no-op for a non-integer pid', async () => {
await unregisterManagedProcess(undefined);
expect(rmMock).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant';
const DEFAULT_PWA_APP_NAME = 'OpenChamber';
const mapPwaOrientationToManifest = (value) => {
if (value === 'portrait') {
return 'portrait-primary';
@@ -9,6 +9,13 @@ afterEach(() => {
globalThis.fetch = originalFetch;
});
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
const supportedCapability = { supported: true, manager: 'opencode', reason: null };
const createApp = (overrides = {}) => {
const app = express();
app.use(express.json());
@@ -67,22 +74,99 @@ describe('OpenCode upgrade routes', () => {
});
});
it('names the latest release as the upgrade target when the caller sends none', async () => {
const requests = [];
globalThis.fetch = vi.fn(async (url, init) => {
requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
if (String(url).includes('registry.npmjs.org')) {
return jsonResponse({ version: '1.18.23' });
}
if (String(url).includes('api.github.com')) {
return jsonResponse({ tag_name: 'v1.18.23' });
}
return jsonResponse({ success: true, version: '1.18.23' });
});
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
await request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(200, { success: true, version: '1.18.23', restarted: true });
const upgradeRequest = requests.find((entry) => entry.url.includes('/global/upgrade'));
expect(upgradeRequest?.body).toEqual({ target: '1.18.23' });
});
it('keeps an explicitly requested target instead of resolving the latest release', async () => {
const requests = [];
globalThis.fetch = vi.fn(async (url, init) => {
requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
return jsonResponse({ success: true, version: '1.18.20' });
});
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
await request(app)
.post('/api/opencode/upgrade')
.send({ target: '1.18.20' })
.expect(200);
expect(requests).toHaveLength(1);
expect(requests[0].url).toContain('/global/upgrade');
expect(requests[0].body).toEqual({ target: '1.18.20' });
});
it('fails without calling the updater when the latest release cannot be resolved', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (String(url).includes('/global/upgrade')) {
throw new Error('the updater must not be called without a target');
}
return new Response('nope', { status: 503 });
});
const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
const response = await request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(502);
expect(response.body.success).toBe(false);
expect(response.body.code).toBe('OPENCODE_UPGRADE_TARGET_UNRESOLVED');
expect(response.body.error).toContain('Could not determine which OpenCode version to install');
expect(dependencies.refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
});
it('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (String(url).includes('/global/upgrade')) {
return jsonResponse(
{ name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } },
400,
);
}
return jsonResponse({ version: '1.18.23' });
});
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
await request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(400, { success: false, error: 'Expected a semantic version' });
});
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' },
}));
releaseUpgrade = () => resolve(jsonResponse({ success: true, version: '1.18.9' }));
});
globalThis.fetch = vi.fn(() => upstreamResponse);
const { app, dependencies } = createApp({
getOpenCodeUpgradeCapability: () => ({
supported: true,
manager: 'opencode',
reason: null,
}),
const upgradeCalls = vi.fn();
globalThis.fetch = vi.fn((url) => {
if (String(url).includes('/global/upgrade')) {
upgradeCalls();
return upstreamResponse;
}
return Promise.resolve(jsonResponse({ version: '1.18.9' }));
});
const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
const first = request(app)
.post('/api/opencode/upgrade')
@@ -94,7 +178,7 @@ describe('OpenCode upgrade routes', () => {
})
.then((response) => response);
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(upgradeCalls).toHaveBeenCalledTimes(1);
});
await request(app)
+53 -5
View File
@@ -164,6 +164,41 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
return versions.sort((left, right) => compareVersions(right, left))[0];
};
// OpenCode's `/global/upgrade` requires an explicit semver target and rejects
// a bodyless call, so "update to the latest" has to name the version. The
// release lookup is the same one the upgrade-status check already uses to
// decide there is anything to offer.
const resolveOpenCodeUpgradeTarget = async (requestedTarget) => {
if (typeof requestedTarget === 'string' && requestedTarget.trim().length > 0) {
return { resolved: true, target: requestedTarget.trim() };
}
try {
const latest = await fetchLatestOpenCodeVersion();
if (!latest) {
return { resolved: false, reason: 'The latest OpenCode version could not be determined.' };
}
return { resolved: true, target: latest };
} catch (error) {
return {
resolved: false,
reason: error instanceof Error ? error.message : 'The latest OpenCode version could not be determined.',
};
}
};
// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`,
// which carries no `error` field. Reading only `error` left the user with the
// bare HTTP status text ("Bad Request") and nothing to act on.
const readOpenCodeUpgradeErrorMessage = (payload, response) => {
const candidates = [payload?.error, payload?.data?.message, payload?.message];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return response.statusText || 'Failed to upgrade OpenCode';
};
const pruneExpiredPendingMcpAuthContexts = () => {
const now = Date.now();
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
@@ -218,10 +253,23 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
});
}
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
? req.body.target.trim()
: undefined;
const requestedTarget = req.body?.target;
// The target lookup reaches the network, so it runs inside the operation:
// the in-flight lock is taken synchronously above, and a second click
// cannot slip past while the release version is being resolved.
const upgradeOperation = (async () => {
const targetResolution = await resolveOpenCodeUpgradeTarget(requestedTarget);
if (!targetResolution.resolved) {
return {
status: 502,
body: {
success: false,
code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED',
error: `Could not determine which OpenCode version to install: ${targetResolution.reason}`,
},
};
}
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
method: 'POST',
headers: {
@@ -229,7 +277,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify(target ? { target } : {}),
body: JSON.stringify({ target: targetResolution.target }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
@@ -237,7 +285,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
status: response.status,
body: {
success: false,
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
error: readOpenCodeUpgradeErrorMessage(payload, response),
},
};
}
@@ -29,6 +29,9 @@ export const createSettingsHelpers = (dependencies) => {
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 SIDEBAR_PROJECT_DISPLAY_MODE_VALUES = new Set(['all', 'single']);
const SIDEBAR_SESSION_GROUPING_MODE_VALUES = new Set(['by-worktree', 'flat']);
const SIDEBAR_PROJECT_SORT_ORDER_VALUES = new Set(['manual', 'a-z', 'z-a', 'date-added', 'recent']);
const HIDDEN_MODELS_MAX = 1024;
const RECENT_EFFORTS_MAX_KEYS = 128;
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
@@ -243,6 +246,18 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
result.activeProjectId = candidate.activeProjectId;
}
if (SIDEBAR_PROJECT_DISPLAY_MODE_VALUES.has(candidate.sidebarProjectDisplayMode)) {
result.sidebarProjectDisplayMode = candidate.sidebarProjectDisplayMode;
}
if (SIDEBAR_SESSION_GROUPING_MODE_VALUES.has(candidate.sidebarSessionGroupingMode)) {
result.sidebarSessionGroupingMode = candidate.sidebarSessionGroupingMode;
}
if (SIDEBAR_PROJECT_SORT_ORDER_VALUES.has(candidate.sidebarProjectSortOrder)) {
result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder;
}
if (typeof candidate.sidebarShowRecentSection === 'boolean') {
result.sidebarShowRecentSection = candidate.sidebarShowRecentSection;
}
if (Array.isArray(candidate.securityScopedBookmarks)) {
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
@@ -601,6 +616,9 @@ 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.editorFontSize === 'number' && Number.isFinite(candidate.editorFontSize)) {
result.editorFontSize = Math.max(9, Math.min(32, Math.round(candidate.editorFontSize)));
}
if (typeof candidate.terminalShell === 'string') {
const shell = candidate.terminalShell.trim().toLowerCase();
if (TERMINAL_SHELL_VALUES.has(shell)) result.terminalShell = shell;
@@ -66,6 +66,28 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({});
});
it('sanitizes shared sidebar display preferences', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'z-a',
sidebarShowRecentSection: false,
})).toEqual({
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'z-a',
sidebarShowRecentSection: false,
});
expect(helpers.sanitizeSettingsUpdate({
sidebarProjectDisplayMode: 'grid',
sidebarSessionGroupingMode: 'project',
sidebarProjectSortOrder: 'random',
sidebarShowRecentSection: 'false',
})).toEqual({});
});
it('accepts only booleans for wide chat layout', () => {
const helpers = createTestHelpers();
@@ -82,6 +104,16 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: 'true' })).toEqual({});
});
it('sanitizes and returns the persisted editor font size', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 20.6 })).toEqual({ editorFontSize: 21 });
expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 8 })).toEqual({ editorFontSize: 9 });
expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 33 })).toEqual({ editorFontSize: 32 });
expect(helpers.sanitizeSettingsUpdate({ editorFontSize: Number.NaN })).toEqual({});
expect(helpers.formatSettingsResponse({ editorFontSize: 20 })).toMatchObject({ editorFontSize: 20 });
});
it('accepts messageStreamTransport as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -155,6 +155,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
const iconBackground = normalizeIconBackground(candidate.iconBackground);
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
const defaultModel = typeof candidate.defaultModel === 'string' ? candidate.defaultModel.trim() : '';
const defaultVariant = typeof candidate.defaultVariant === 'string' ? candidate.defaultVariant.trim() : '';
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
? Number(candidate.lastOpenedAt)
@@ -175,6 +176,8 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
...(iconBackground ? { iconBackground } : {}),
...(color ? { color } : {}),
...(defaultModel && defaultModel.includes('/') ? { defaultModel } : {}),
// A variant is meaningless without the model it belongs to.
...(defaultModel && defaultModel.includes('/') && defaultVariant ? { defaultVariant } : {}),
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
};
@@ -106,6 +106,22 @@ describe('settings normalization runtime - symlink resolution', () => {
expect(result[0].path).toBe('/resolved/missing/path');
});
it('keeps a default thinking level next to its model and drops it alone', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p,
path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/a', defaultModel: 'anthropic/claude-opus-5', defaultVariant: 'high' },
{ id: 'proj2', path: '/b', defaultVariant: 'high' },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].defaultVariant).toBe('high');
expect(result[1].defaultVariant).toBe(undefined);
});
it('deduplicates projects that resolve to the same realpath', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p,
@@ -547,25 +547,41 @@ export const createSettingsRuntime = (deps) => {
// briefly opens the target file. Preserve atomic rename everywhere it works,
// but fall back to a direct replacement so settings persistence does not
// get permanently wedged on Windows desktop installs.
await fsPromises.copyFile(tmp, target);
await fsPromises.rm(tmp, { force: true });
try {
await fsPromises.copyFile(tmp, target);
} finally {
await fsPromises.rm(tmp, { force: true }).catch(() => {});
}
};
const cleanupOrphanedSettingsTempFiles = async (directory) => {
try {
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
const cleanupTasks = entries
.filter((entry) => entry.isFile() && entry.name.startsWith('settings.json.tmp-'))
.map((entry) => fsPromises.rm(path.join(directory, entry.name), { force: true }).catch(() => {}));
await Promise.all(cleanupTasks);
} catch {
// Best-effort cleanup: errors reading directory must not fail settings operations
}
};
const writeSettingsToDisk = async (settings) => {
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)}`;
try {
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), { 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) {
await fsPromises.rm(tmp, { force: true }).catch(() => {});
console.warn('Failed to write settings file:', error);
throw error;
}
@@ -854,7 +870,13 @@ export const createSettingsRuntime = (deps) => {
return { settings: next, changed: true };
};
let hasCleanedOrphanedTempFiles = false;
const readSettingsFromDiskMigrated = async () => {
if (!hasCleanedOrphanedTempFiles) {
hasCleanedOrphanedTempFiles = true;
await cleanupOrphanedSettingsTempFiles(path.dirname(SETTINGS_FILE_PATH));
}
const current = await readSettingsFromDisk();
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
@@ -39,6 +39,24 @@ const createRuntime = async () => {
};
describe('settings runtime', () => {
it('round-trips shared sidebar preferences through settings.json', async () => {
const { runtime, settingsFilePath, cleanup } = await createRuntime();
const preferences = {
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'date-added',
sidebarShowRecentSection: false,
};
try {
await runtime.persistSettings(preferences);
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
} finally {
await cleanup();
}
});
it.skipIf(process.platform === 'win32')('writes settings with restrictive directory and file permissions', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
@@ -133,4 +151,71 @@ describe('settings runtime', () => {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
const settingsDir = path.dirname(settingsFilePath);
const orphan1 = path.join(settingsDir, 'settings.json.tmp-1234-11111-abc');
const orphan2 = path.join(settingsDir, 'settings.json.tmp-5678-22222-def');
const unrelated = path.join(settingsDir, 'other-file.json');
await fsPromises.writeFile(orphan1, '{"broken": true}', 'utf8');
await fsPromises.writeFile(orphan2, '{"broken": true}', 'utf8');
await fsPromises.writeFile(unrelated, '{"keep": true}', 'utf8');
await fsPromises.writeFile(settingsFilePath, '{"theme": "light"}', 'utf8');
await runtime.readSettingsFromDiskMigrated();
const files = await fsPromises.readdir(settingsDir);
expect(files).toContain('settings.json');
expect(files).toContain('other-file.json');
expect(files).not.toContain('settings.json.tmp-1234-11111-abc');
expect(files).not.toContain('settings.json.tmp-5678-22222-def');
} finally {
await cleanup();
}
});
it('removes temp file when writeSettingsToDisk encounters a write error', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
const settingsFilePath = path.join(tempRoot, 'settings.json');
let capturedTmp = null;
const wrappedFs = {
...fsPromises,
rename: async (src, dst) => {
capturedTmp = src;
const error = new Error('unexpected disk failure');
error.code = 'EIO';
throw error;
},
};
const runtime = createSettingsRuntime({
fsPromises: wrappedFs,
path,
crypto,
SETTINGS_FILE_PATH: settingsFilePath,
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
sanitizeSettingsUpdate: (settings) => settings,
mergePersistedSettings: (_current, changes) => changes,
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
formatSettingsResponse: (settings) => settings,
resolveDirectoryCandidate: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: (value) => value,
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
syncManagedRemoteTunnelConfigWithPresets: async () => {},
upsertManagedRemoteTunnelToken: async () => {},
});
try {
await expect(runtime.writeSettingsToDisk({ theme: 'dark' })).rejects.toThrow('unexpected disk failure');
expect(capturedTmp).toBeTruthy();
const files = await fsPromises.readdir(tempRoot);
expect(files.some((f) => f.startsWith('settings.json.tmp-'))).toBe(false);
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
});
@@ -116,7 +116,7 @@ export const createThemeRuntime = (dependencies) => {
const seen = new Set();
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
if (!entry.name.toLowerCase().endsWith('.json')) continue;
const filePath = path.join(themesDir, entry.name);
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import { createThemeRuntime } from './theme-runtime.js';
const validTheme = (id = 'custom-theme') => ({
metadata: {
id,
name: 'Custom Theme',
variant: 'dark',
},
colors: {
primary: {
base: '#ffffff',
foreground: '#000000',
},
surface: {
background: '#000000',
foreground: '#ffffff',
muted: '#111111',
mutedForeground: '#eeeeee',
elevated: '#222222',
elevatedForeground: '#dddddd',
subtle: '#333333',
},
interactive: {
border: '#444444',
selection: '#555555',
selectionForeground: '#ffffff',
focusRing: '#666666',
hover: '#777777',
},
status: {
error: '#ff0000',
errorForeground: '#ffffff',
errorBackground: '#330000',
errorBorder: '#660000',
warning: '#ffaa00',
warningForeground: '#000000',
warningBackground: '#332200',
warningBorder: '#664400',
success: '#00ff00',
successForeground: '#000000',
successBackground: '#003300',
successBorder: '#006600',
info: '#0000ff',
infoForeground: '#ffffff',
infoBackground: '#000033',
infoBorder: '#000066',
},
syntax: {
base: {
background: '#000000',
foreground: '#ffffff',
keyword: '#ff00ff',
string: '#00ff00',
number: '#ffaa00',
function: '#00ffff',
variable: '#ffffff',
type: '#ffff00',
comment: '#888888',
operator: '#ffffff',
},
highlights: {
diffAdded: '#003300',
diffRemoved: '#330000',
lineNumber: '#888888',
},
},
},
});
const fileEntry = (name, type = 'file') => ({
name,
isFile: () => type === 'file',
isDirectory: () => type === 'directory',
isSymbolicLink: () => type === 'symlink',
});
const createTestRuntime = ({ entries, files, stats }) => createThemeRuntime({
fsPromises: {
readdir: async () => entries,
stat: async (filePath) => stats[filePath],
readFile: async (filePath) => files[filePath],
},
path: { join: (...parts) => parts.join('/') },
themesDir: '/themes',
maxThemeJsonBytes: 512 * 1024,
logger: { warn: () => {} },
});
describe('theme runtime', () => {
describe('readCustomThemesFromDisk', () => {
it('loads valid theme files', async () => {
const runtime = createTestRuntime({
entries: [fileEntry('direct.json')],
files: { '/themes/direct.json': JSON.stringify(validTheme('direct-theme')) },
stats: { '/themes/direct.json': { isFile: () => true, size: 1024 } },
});
const themes = await runtime.readCustomThemesFromDisk();
expect(themes.map((theme) => theme.metadata.id)).toEqual(['direct-theme']);
});
it('loads JSON themes whose directory entry is a symbolic link', async () => {
const runtime = createTestRuntime({
entries: [fileEntry('linked.json', 'symlink')],
files: { '/themes/linked.json': JSON.stringify(validTheme('linked-theme')) },
stats: { '/themes/linked.json': { isFile: () => true, size: 1024 } },
});
const themes = await runtime.readCustomThemesFromDisk();
expect(themes.map((theme) => theme.metadata.id)).toEqual(['linked-theme']);
});
it('skips JSON directories after stat resolution', async () => {
const runtime = createTestRuntime({
entries: [fileEntry('directory.json', 'directory')],
files: { '/themes/directory.json': JSON.stringify(validTheme('directory-theme')) },
stats: { '/themes/directory.json': { isFile: () => false, size: 1024 } },
});
const themes = await runtime.readCustomThemesFromDisk();
expect(themes).toEqual([]);
});
});
});
@@ -3,6 +3,8 @@
Server-owned storage for the Project Notes surface: free-form notes, todos, and
plan markdown files.
The managed Chats root (`~/.config/openchamber/chats`) is also one context owner. Every dated per-session directory beneath it resolves to that root, so Notes, Todo, Plans, pinned knowledge, and project memory are shared across ordinary chats without registering Chats as a user project.
## Ownership
| Path | Owner | Contents |
@@ -231,8 +231,13 @@ export const createProjectContextRuntime = (deps) => {
const writeJsonAtomic = async (filePath, value) => {
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
try {
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
} catch (error) {
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
};
const withWriteLock = async (projectId, mutate) => {
@@ -536,8 +536,13 @@ export const createProjectConfigRuntime = (deps) => {
};
await fsPromises.mkdir(parentDirectory, { recursive: true });
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
try {
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
} catch (error) {
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
};
const withProjectWriteLock = async (projectID, mutate) => {
@@ -97,6 +97,25 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo
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.
## GitHub Copilot quota semantics
GitHub Copilot usage exposes only the `premium_interactions` snapshot as the
`premium_interactions` window. Shared UI labels that window **AI Credits** and treats it as
the provider's primary usage marker. Legacy chat-request quota and unlimited
completion quota are intentionally omitted. Keep
`packages/web/server/lib/quota/providers/copilot.js` and
`packages/vscode/src/quotaProviders.ts` in sync.
The `/copilot_internal/user` endpoint is undocumented; its quota semantics mirror
what `microsoft/vscode-copilot-chat` consumes (`CopilotUserQuotaInfo`). Each
snapshot carries `entitlement`, `remaining`, `unlimited`, and
`percent_remaining`. Providers must honor these rules:
- `unlimited: true` renders a percent-less window with an "Unlimited" value label.
- Percent math requires a positive `entitlement`; entitlements of `0`, `-1`, or null are unusable.
- When entitlement/remaining are unusable, fall back to `100 - percent_remaining`.
- Snapshots other than `premium_interactions` (legacy annual plans) yield zero windows.
## Notes for contributors
- Keep provider IDs stable; clients use them directly.
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
@@ -1,90 +0,0 @@
import { readAuthFile } from '../../opencode/auth.js';
import { asObject, buildResult, getAuthEntry, normalizeAuthEntry, toNumber, toUsageWindow } from '../utils/index.js';
export const providerId = 'command-code';
export const providerName = 'Command Code';
export const aliases = ['command-code'];
const API_BASE_URL = 'https://api.commandcode.ai';
const getApiKey = (auth = readAuthFile()) => {
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
const stored = entry?.key ?? entry?.access ?? entry?.token;
return (typeof stored === 'string' ? stored.trim() : '') || process.env.COMMAND_CODE_API_KEY?.trim() || null;
};
const requestJson = async (path, apiKey, fetchImpl) => {
const response = await fetchImpl(`${API_BASE_URL}${path}`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${apiKey}`,
'User-Agent': 'OpenChamber quota provider',
},
signal: AbortSignal.timeout(15_000),
});
if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed');
if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`);
return response.json().catch(() => null);
};
const formatCredits = (value) => String(Math.round((value + Number.EPSILON) * 100) / 100);
const toBalanceWindow = (value) => toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel: formatCredits(value),
});
export const parseCommandCodeCredits = (payload) => {
const root = asObject(payload);
const credits = asObject(root?.credits);
const limits = asObject(root?.windowLimits);
const windows = {};
for (const [label, field] of [['monthly_credits', 'monthlyCredits'], ['purchased_credits', 'purchasedCredits'], ['free_credits', 'freeCredits']]) {
const value = toNumber(credits?.[field]);
if (value !== null) windows[label] = toBalanceWindow(value);
}
for (const [label, field, windowSeconds] of [['5h', 'fiveHour', 5 * 60 * 60], ['weekly', 'weekly', 7 * 24 * 60 * 60]]) {
const limit = asObject(limits?.[field]);
const used = toNumber(limit?.used);
const cap = toNumber(limit?.cap);
if (used === null || cap === null || cap <= 0) continue;
const resetAt = toNumber(limit?.resetAt);
windows[label] = toUsageWindow({
usedPercent: Math.min(100, Math.max(0, used / cap * 100)),
windowSeconds,
resetAt: resetAt === null ? null : resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt,
valueLabel: `${formatCredits(used)} / ${formatCredits(cap)}`,
});
}
return windows;
};
export const fetchCommandCodeUsage = async (apiKey, fetchImpl = fetch) => {
const identity = asObject(await requestJson('/alpha/whoami', apiKey, fetchImpl));
const org = asObject(identity?.org);
const orgId = typeof org?.id === 'string' ? org.id.trim() : '';
const creditsPath = orgId
? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}`
: '/alpha/billing/credits';
const credits = await requestJson(creditsPath, apiKey, fetchImpl);
const windows = parseCommandCodeCredits(credits);
if (Object.keys(windows).length === 0) throw new Error('Command Code usage data could not be parsed');
return windows;
};
export const isConfigured = () => Boolean(getApiKey());
export const fetchQuota = async (auth = readAuthFile()) => {
const apiKey = getApiKey(auth);
if (!apiKey) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' });
try {
return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows: await fetchCommandCodeUsage(apiKey) } });
} catch (error) {
return buildResult({ providerId, providerName, ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
}
};
@@ -1,72 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchCommandCodeUsage, fetchQuota, parseCommandCodeCredits } from './command-code.js';
const creditsPayload = {
credits: { monthlyCredits: 120, purchasedCredits: 30, freeCredits: 5 },
windowLimits: {
fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 },
weekly: { used: 70, cap: 200, resetAt: 1_776_604_800 },
},
};
describe('Command Code quota provider', () => {
it('parses balances and rate-limit windows', () => {
const windows = parseCommandCodeCredits(creditsPayload);
expect(windows.monthly_credits).toMatchObject({ usedPercent: null, valueLabel: '120' });
expect(windows.purchased_credits).toMatchObject({ usedPercent: null, valueLabel: '30' });
expect(windows.free_credits).toMatchObject({ usedPercent: null, valueLabel: '5' });
expect(windows['5h']).toMatchObject({ usedPercent: 25, valueLabel: '25 / 100', resetAt: 1_776_000_000_000 });
expect(windows.weekly.usedPercent).toBe(35);
});
it('formats fractional credit values for display', () => {
const windows = parseCommandCodeCredits({
credits: { monthlyCredits: 69.7947070034 },
windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } },
});
expect(windows.monthly_credits.valueLabel).toBe('69.79');
expect(windows['5h'].valueLabel).toBe('0.21 / 14');
});
it('resolves the organization before fetching credits', async () => {
const requests = [];
const windows = await fetchCommandCodeUsage('secret', async (url, options) => {
requests.push({ url, options });
return new Response(JSON.stringify(url.endsWith('/alpha/whoami') ? { org: { id: 'org/a' } } : creditsPayload));
});
expect(requests.map(({ url }) => url)).toEqual([
'https://api.commandcode.ai/alpha/whoami',
'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa',
]);
expect(requests[0].options.headers.Authorization).toBe('Bearer secret');
expect(windows['5h'].usedPercent).toBe(25);
});
it('fetches account-scoped credits without orgId for personal accounts', async () => {
const urls = [];
await fetchCommandCodeUsage('secret', async (url) => {
urls.push(url);
return new Response(JSON.stringify(url.endsWith('/alpha/whoami') ? { user: { id: 'user-1' }, org: null } : creditsPayload));
});
expect(urls).toEqual([
'https://api.commandcode.ai/alpha/whoami',
'https://api.commandcode.ai/alpha/billing/credits',
]);
});
it('does not expose credentials in authentication errors', async () => {
await expect(fetchCommandCodeUsage('secret', async () => new Response('', { status: 401 }))).rejects.toThrow('authentication failed');
});
it('reads OAuth access credentials from the OpenCode auth file', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } })))
.mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload)));
vi.stubGlobal('fetch', fetchMock);
const result = await fetchQuota({ 'command-code': { type: 'oauth', access: 'test-token' } });
expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true });
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-token');
vi.unstubAllGlobals();
});
});
@@ -13,14 +13,35 @@ const buildCopilotWindows = (payload) => {
const resetAt = toTimestamp(payload?.quota_reset_date);
const windows = {};
// Mirrors the quota semantics of microsoft/vscode-copilot-chat
// (CopilotUserQuotaInfo): each snapshot carries entitlement, remaining,
// unlimited, and percent_remaining. Unlimited plans report no usable
// entitlement; percent_remaining is a server-computed fallback.
const addWindow = (label, snapshot) => {
if (!snapshot) return;
if (snapshot.unlimited === true) {
windows[label] = toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt,
valueLabel: 'Unlimited'
});
return;
}
const entitlement = toNumber(snapshot.entitlement);
const remaining = toNumber(snapshot.remaining);
const usedPercent = entitlement && remaining !== null
? Math.max(0, 100 - (remaining / entitlement) * 100)
let usedPercent = entitlement !== null && entitlement > 0 && remaining !== null
? Math.min(100, Math.max(0, 100 - (remaining / entitlement) * 100))
: null;
const valueLabel = entitlement !== null && remaining !== null
if (usedPercent === null) {
const percentRemaining = toNumber(snapshot.percent_remaining);
if (percentRemaining !== null) {
usedPercent = Math.min(100, Math.max(0, 100 - percentRemaining));
}
}
const valueLabel = entitlement !== null && entitlement > 0 && remaining !== null
? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left`
: null;
windows[label] = toUsageWindow({
@@ -31,9 +52,7 @@ const buildCopilotWindows = (payload) => {
});
};
addWindow('chat', quota.chat);
addWindow('completions', quota.completions);
addWindow('premium', quota.premium_interactions);
addWindow('premium_interactions', quota.premium_interactions);
return windows;
};
@@ -143,15 +162,12 @@ export const fetchQuotaAddon = async () => {
}
const payload = await response.json();
const windows = buildCopilotWindows(payload);
const premium = windows.premium ? { premium: windows.premium } : windows;
return buildResult({
providerId: providerIdAddon,
providerName: providerNameAddon,
ok: true,
configured: true,
usage: { windows: premium }
usage: { windows: buildCopilotWindows(payload) }
});
} catch (error) {
return buildResult({
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../opencode/auth.js', () => ({
readAuthFile: () => ({ 'github-copilot': { access: 'test-token' } }),
}));
import { fetchQuota, fetchQuotaAddon } from './copilot.js';
afterEach(() => {
vi.unstubAllGlobals();
});
const payload = {
quota_reset_date: '2026-09-01T00:00:00Z',
quota_snapshots: {
chat: { entitlement: 100, remaining: 80 },
completions: { entitlement: 1000, remaining: 900 },
premium_interactions: { entitlement: 300, remaining: 225 },
},
};
const mockResponse = (body = payload) => ({
ok: true,
status: 200,
json: async () => body,
});
describe('GitHub Copilot quota provider', () => {
it.each([
['primary provider', fetchQuota],
['add-on provider', fetchQuotaAddon],
])('exposes only premium interactions for the %s', async (_name, fetchProviderQuota) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse()));
const result = await fetchProviderQuota();
expect(result.ok).toBe(true);
expect(Object.keys(result.usage.windows)).toEqual(['premium_interactions']);
expect(result.usage.windows.premium_interactions.usedPercent).toBe(25);
expect(result.usage.windows.premium_interactions.valueLabel).toBe('225 / 300 left');
});
it.each([
['primary provider', fetchQuota],
['add-on provider', fetchQuotaAddon],
])('reports unlimited plans without a percent for the %s', async (_name, fetchProviderQuota) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
quota_reset_date: '2026-09-01T00:00:00Z',
quota_snapshots: {
premium_interactions: { unlimited: true, entitlement: -1, remaining: -1 },
},
})));
const result = await fetchProviderQuota();
expect(result.ok).toBe(true);
expect(result.usage.windows.premium_interactions.usedPercent).toBeNull();
expect(result.usage.windows.premium_interactions.valueLabel).toBe('Unlimited');
});
it.each([
['primary provider', fetchQuota],
['add-on provider', fetchQuotaAddon],
])('falls back to percent_remaining when entitlement is unusable for the %s', async (_name, fetchProviderQuota) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
quota_reset_date: '2026-09-01T00:00:00Z',
quota_snapshots: {
premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 75.5 },
},
})));
const result = await fetchProviderQuota();
expect(result.ok).toBe(true);
expect(result.usage.windows.premium_interactions.usedPercent).toBeCloseTo(24.5);
expect(result.usage.windows.premium_interactions.valueLabel ?? null).toBeNull();
});
});
@@ -9,7 +9,6 @@ import { buildResult } from '../utils/index.js';
import * as claude from './claude/index.js';
import * as codex from './codex.js';
import * as commandCode from './command-code.js';
import * as copilot from './copilot.js';
import * as crof from './crof.js';
import * as cursor from './cursor.js';
@@ -30,12 +29,6 @@ import * as opencodeGo from './opencode-go.js';
import * as xai from './xai.js';
const registry = {
'command-code': {
providerId: commandCode.providerId,
providerName: commandCode.providerName,
isConfigured: commandCode.isConfigured,
fetchQuota: commandCode.fetchQuota
},
claude: {
providerId: claude.providerId,
providerName: claude.providerName,
@@ -160,6 +153,7 @@ const registry = {
const pendingFetches = new Map();
export const listConfiguredQuotaProviders = () => {
const configured = [];
@@ -23,7 +23,7 @@ Host side (`packages/web/server/lib/relay/`):
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
- `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
- `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. A standby watcher waits a 2-minute grace after the claim frees before taking over, so a cleanly restarting host (app update/relaunch) — which reclaims at boot with no wait — always wins the restart window over a bystander instance. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. Instances created with `allowPassiveHost: false` (dev servers via `OPENCHAMBER_RELAY_HOST=off`, the Electron dev shell via `OPENCHAMBER_ELECTRON_DEV`; `OPENCHAMBER_RELAY_HOST=on` overrides) never start the host passively at all — boot, demand reconcile, and watcher takeover leave them in `standby`; only explicit enable/pairing hosts there. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
- `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
- `e2ee.js`, `tunnel-codec.js` — host-side (JS) mirrors of the shared crypto and framing (see "Two implementations" below).
+34 -2
View File
@@ -70,6 +70,11 @@ export const createRelayService = ({
// evict each other at the relay worker ("Control replaced") and devices land
// on a random instance. Optional: without it, behavior is pre-lock.
hostLock = null,
// When false, this instance never starts the relay host on its own (boot,
// demand reconcile, or claim-watch takeover) — only an explicit user action
// (enable, pairing) force-claims. Dev/debug instances set this so they do not
// capture paired devices from the production instance sharing the data dir.
allowPassiveHost = true,
logger = console,
}) => {
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
@@ -80,6 +85,13 @@ export const createRelayService = ({
// claimant dies; a running host stands down when another process claims.
let claimWatchTimer = null;
const CLAIM_WATCH_INTERVAL_MS = 30_000;
// A standby instance does not grab a freed claim immediately: a clean restart
// of the previous host (app update, relaunch) releases the claim for a short
// while, and taking it during that window strands the devices on this —
// possibly older — instance. The restarting host reclaims at boot without any
// wait, so it always wins the window.
const CLAIM_TAKEOVER_GRACE_MS = 120_000;
let claimFreeSinceMs = null;
const readConfig = async () => {
const settings = await readSettingsFromDiskMigrated();
@@ -133,8 +145,19 @@ export const createRelayService = ({
}
return;
}
if (status.state === 'standby' && hostLock.tryClaim()) {
logger.warn('[Relay] host claim is free — taking over the relay host');
if (status.state !== 'standby' || !allowPassiveHost) return;
if (hostLock.liveClaimantPid() !== null) {
claimFreeSinceMs = null;
return;
}
if (claimFreeSinceMs === null) {
claimFreeSinceMs = Date.now();
return;
}
if (Date.now() - claimFreeSinceMs < CLAIM_TAKEOVER_GRACE_MS) return;
if (hostLock.tryClaim()) {
claimFreeSinceMs = null;
logger.warn('[Relay] host claim stayed free — taking over the relay host');
await start(relayUrl);
}
} catch (error) {
@@ -149,10 +172,19 @@ export const createRelayService = ({
if (!claimWatchTimer) return;
clearInterval(claimWatchTimer);
claimWatchTimer = null;
claimFreeSinceMs = null;
};
const start = async (relayUrl, { claim = 'try' } = {}) => {
if (hostClient) return;
if (claim !== 'force' && !allowPassiveHost) {
status = {
state: 'standby',
lastError: 'passive relay hosting is disabled on this instance — enable the relay or create a pairing link to host here',
connectedClients: 0,
};
return;
}
if (hostLock) {
const claimed = claim === 'force' ? hostLock.forceClaim() : hostLock.tryClaim();
if (!claimed) {
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest';
import crypto from 'node:crypto';
import { createRelayService } from './service.js';
const makeService = (options = {}) => {
// In-memory settings store with a pre-seeded relay identity so the service
// never regenerates a signing key during the test.
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
let settings = {
relaySigningKey: {
privateJwk: privateKey.export({ format: 'jwk' }),
publicJwk: publicKey.export({ format: 'jwk' }),
},
privateRelay: { enabled: true, relayUrl: 'wss://relay.example.test/ws' },
...options.settings,
};
const hostLock = {
tryClaim: vi.fn(() => true),
forceClaim: vi.fn(() => true),
holdsClaim: vi.fn(() => true),
liveClaimantPid: vi.fn(() => null),
release: vi.fn(),
};
const service = createRelayService({
crypto,
readSettingsFromDiskMigrated: async () => settings,
writeSettingsToDisk: async (next) => { settings = next; },
readSettingsStrict: async () => settings,
getLocalPort: () => 0,
hasRelayDemand: options.hasRelayDemand ?? (async () => true),
hostLock,
allowPassiveHost: options.allowPassiveHost,
logger: { warn: () => {} },
});
return { service, hostLock, getSettings: () => settings };
};
describe('relay service passive hosting', () => {
it('never claims or starts the host passively when passive hosting is disabled', async () => {
const { service, hostLock } = makeService({ allowPassiveHost: false });
try {
await service.startIfEnabled();
let status = await service.getStatus();
expect(status.state).toBe('standby');
expect(hostLock.tryClaim).not.toHaveBeenCalled();
expect(hostLock.forceClaim).not.toHaveBeenCalled();
await service.reconcile();
status = await service.getStatus();
expect(status.state).toBe('standby');
expect(status.lastError).toContain('passive relay hosting is disabled');
expect(hostLock.tryClaim).not.toHaveBeenCalled();
} finally {
service.stop();
}
});
it('force-claims for an explicit pairing even when passive hosting is disabled', async () => {
const { service, hostLock } = makeService({ allowPassiveHost: false });
try {
const candidate = await service.ensureEnabledForPairing();
expect(candidate?.type).toBe('relay');
expect(hostLock.forceClaim).toHaveBeenCalled();
} finally {
service.stop();
}
});
});
@@ -249,6 +249,7 @@ export const createSessionGoalRuntime = ({
getOpenCodeAuthHeaders,
getSmallModelService,
emitGoalNotification,
isEnabled = isSessionGoalEnabled,
idleQuietMs = IDLE_QUIET_MS,
kickoffQuietMs = KICKOFF_QUIET_MS,
maxAutoTurns = MAX_AUTO_TURNS,
@@ -444,7 +445,7 @@ export const createSessionGoalRuntime = ({
};
const tick = async (sessionId, directory) => {
if (!isSessionGoalEnabled()) return;
if (!isEnabled()) return;
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
.catch((error) => {
@@ -35,13 +35,14 @@ const startIdleTick = async (fetchImpl) => {
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
getOpenCodeAuthHeaders: () => ({}),
getSmallModelService,
isEnabled: () => true,
idleQuietMs: 10,
});
runtime.processPayload({
type: 'session.status',
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
});
await vi.advanceTimersByTimeAsync(10);
await vi.runOnlyPendingTimersAsync();
return { runtime, getSmallModelService };
};
@@ -156,6 +157,7 @@ describe('session goal live activity gate', () => {
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
getOpenCodeAuthHeaders: () => ({}),
getSmallModelService: async () => service,
isEnabled: () => true,
idleQuietMs: 10,
});
@@ -163,7 +165,7 @@ describe('session goal live activity gate', () => {
type: 'session.status',
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
});
await vi.advanceTimersByTimeAsync(10);
await vi.runOnlyPendingTimersAsync();
expect(service.generateSmallModelText).toHaveBeenCalledOnce();
const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
@@ -24,6 +24,8 @@ attached to that session. Pins never come from project-wide note or plan state.
A new-session draft passes its pins into this metadata when its first message
creates the session.
Directories beneath the managed `~/.config/openchamber/chats` root resolve to that root before project context and project memory are read. Every ordinary chat therefore shares one Chats knowledge owner instead of creating an unreachable context store for each dated session directory.
`session.metadata.openchamber.knowledge_context_delivered` holds the signature
of what the session is carrying. It lives with the session, so it survives the
tab closing and is visible to every sender, including the ones with no tab.
@@ -103,18 +103,24 @@ other runtime API.
`https://chatgpt.com/backend-api/codex/responses` with
`ChatGPT-Account-Id`; expired tokens are refreshed against
`auth.openai.com` (single-flight) and written back to `auth.json`.
- **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`.
- **Anthropic** (`type: api`): `/messages` with `x-api-key`, against
`provider.anthropic.options.baseURL` when configured (used as-is, matching
`@ai-sdk/anthropic` — no `/v1` is inserted) or `https://api.anthropic.com/v1`
otherwise.
- **Google** (`type: api`): `generateContent` with `x-goog-api-key`; Gemini 3
uses `thinkingLevel` while older Flash models use `thinkingBudget: 0`.
uses `thinkingLevel`, Gemini 2.x uses `thinkingBudget: 0`, and all other
models omit `thinkingConfig` entirely.
- Everything else: OpenAI-compatible `/chat/completions` against the
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the
provider's `api` field from the models.dev catalog. The credential follows
the same shape: config `options.apiKey`, then the runtime credential, then
the auth.json entry. Configured API keys honor OpenCode's `{env:NAME}` and
`{file:path}` substitutions; file contents and resolved credentials remain
server-side.
the auth.json entry. `provider.<id>.options.headers` is sent with the
request and overrides the bearer default, so gateways that authenticate on
their own header work here exactly as they do in a chat turn. Configured API
keys and header values honor OpenCode's `{env:NAME}` and `{file:path}`
substitutions; file contents and resolved credentials remain server-side.
- The runtime credential is refused for providers listed in
`OWN_CREDENTIAL_HANDLING`. Their branches need the stored entry rather than
a bearer token: the clearest case is the ChatGPT-plan `openai` login, whose
@@ -131,6 +137,17 @@ other runtime API.
- `routes.js``GET /api/small-model` (resolution preview) and
`POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?,
model?, directory? }` → `{ text, providerID, modelID, source }`).
- `config-injection.js` — applies the Settings → Chat → Small Model override
to the config injected into the **managed OpenCode process**
(`OPENCODE_CONFIG_CONTENT`), so OpenCode's own internal `small_model`
consumers — session title and summary generation — use the user's explicit
choice instead of OpenCode's fallback chain. Only an explicit override
(`smallModelUseDefault === false` with a non-empty `smallModelOverride`) is
injected; "use default" leaves the config untouched so OpenCode's own
resolution stays authoritative. Wired into `getManagedOpenCodeEnv` in
`server/index.js`; the pure helper is unit-tested in
`config-injection.test.js`. External OpenCode servers are unaffected (they
are not launched with this env).
## Which providers the pickers may offer
+64 -20
View File
@@ -2,7 +2,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
import { readConfig, readConfigLayers } from '../opencode/shared.js';
import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js';
import { getCatalogProvider } from './catalog.js';
import { getAuthEntryForProvider } from './resolve.js';
import { getRuntimeProvider } from './runtime-providers.js';
@@ -19,6 +19,18 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
const USER_AGENT = 'opencode/1.0 openchamber';
const mergeHeadersCaseInsensitive = (base, overrides) => {
const merged = { ...base };
for (const [name, value] of Object.entries(overrides || {})) {
const existingName = Object.keys(merged).find((key) => key.toLowerCase() === name.toLowerCase());
if (existingName) {
delete merged[existingName];
}
merged[name] = value;
}
return merged;
};
const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
@@ -157,11 +169,10 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system,
});
const response = await fetch(`${trimmedBase}/chat/completions`, {
method: 'POST',
headers: {
headers: mergeHeadersCaseInsensitive({
'Content-Type': 'application/json',
Accept: 'application/json',
...headers,
},
}, headers),
body: JSON.stringify({
model: modelID,
messages: [
@@ -340,8 +351,11 @@ const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTo
return text;
};
const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => callMessages({
url: 'https://api.anthropic.com/v1/messages',
const callAnthropic = async ({ apiKey, baseURL, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => callMessages({
// Matches @ai-sdk/anthropic: baseURL is the full API prefix (commonly
// already ending in /v1), so it gets /messages appended as-is rather than
// having /v1/messages appended, which would double up a configured /v1.
url: `${(baseURL || 'https://api.anthropic.com/v1').replace(/\/+$/, '')}/messages`,
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
@@ -403,9 +417,10 @@ const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => {
const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`;
const thinkingConfig = modelID.toLowerCase().startsWith('gemini-3')
? { thinkingLevel: modelID.toLowerCase().includes('flash') ? 'minimal' : 'low' }
: { thinkingBudget: 0 };
const lowerModelID = modelID.toLowerCase();
const thinkingConfig = lowerModelID.startsWith('gemini-3')
? { thinkingLevel: lowerModelID.includes('flash') ? 'minimal' : 'low' }
: lowerModelID.startsWith('gemini-2') ? { thinkingBudget: 0 } : null;
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -415,13 +430,11 @@ const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, re
},
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
...(system && { systemInstruction: { parts: [{ text: system }] } }),
generationConfig: {
maxOutputTokens,
thinkingConfig,
...(responseSchema
? { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) }
: {}),
...(thinkingConfig && { thinkingConfig }),
...(responseSchema && { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) }),
},
}),
signal: requestSignal(timeoutMs, signal),
@@ -508,7 +521,7 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys
// Custom provider configuration support
// ---------------------------------------------------------------------------
const resolveConfigApiKey = (value, workingDirectory, providerID) => {
const resolveConfigValue = (value, workingDirectory, providerID, headerName = null) => {
const envMatch = value.match(/^\{env:([^}]+)\}$/i);
if (envMatch) {
return process.env[envMatch[1].trim()]?.trim() || null;
@@ -529,7 +542,12 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => {
{ config: layers.customConfig, filePath: layers.paths.customPath },
{ config: layers.projectConfig, filePath: layers.paths.projectPath },
{ config: layers.userConfig, filePath: layers.paths.userPath },
].find(({ config }) => config?.provider?.[providerID]?.options?.apiKey === value);
].find(({ config }) => {
const options = config?.provider?.[providerID]?.options;
return headerName
? options?.headers?.[headerName] === value
: options?.apiKey === value;
});
resolvedPath = path.resolve(source?.filePath ? path.dirname(source.filePath) : workingDirectory || process.cwd(), configuredPath);
}
@@ -538,10 +556,33 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => {
if (!key) throw new Error('empty file');
return key;
} catch {
throw new Error(`Failed to resolve configured apiKey file for provider "${providerID}"`);
throw new Error(`Failed to resolve configured ${headerName ? `header "${headerName}"` : 'apiKey'} file for provider "${providerID}"`);
}
};
/**
* `options.headers` from the provider config, with the same `{env:…}`/`{file:…}`
* substitutions the API key gets.
*
* OpenCode sends these on every request, so dropping them here would have the
* small model authenticating differently from the request path against the same
* URL. Gateways fronted by an API-management layer reject a bearer-only request
* outright, because the header is the credential rather than a supplement to it.
*/
const readConfiguredHeaders = (providerCfg, workingDirectory, providerID) => {
const configured = providerCfg?.options?.headers;
if (!isPlainObject(configured)) return null;
const headers = {};
for (const [name, value] of Object.entries(configured)) {
// Config headers are strings; a malformed entry is skipped rather than
// stringified into a header the gateway would reject.
if (String(value) !== value) continue;
const resolved = resolveConfigValue(value.trim(), workingDirectory, providerID, name);
if (resolved) headers[name] = resolved;
}
return Object.keys(headers).length ? headers : null;
};
const readProviderConfig = (workingDirectory, providerID) => {
try {
const config = readConfig(workingDirectory);
@@ -549,9 +590,10 @@ const readProviderConfig = (workingDirectory, providerID) => {
if (!providerCfg || typeof providerCfg !== 'object') return null;
const baseURL = typeof providerCfg?.options?.baseURL === 'string' ? providerCfg.options.baseURL.trim() : null;
const rawApiKey = typeof providerCfg?.options?.apiKey === 'string' ? providerCfg.options.apiKey.trim() : null;
const apiKey = rawApiKey ? resolveConfigApiKey(rawApiKey, workingDirectory, providerID) : null;
const apiKey = rawApiKey ? resolveConfigValue(rawApiKey, workingDirectory, providerID) : null;
return {
baseURL,
headers: readConfiguredHeaders(providerCfg, workingDirectory, providerID),
// Shape the config-supplied key as a regular api-key auth entry so it
// can win the precedence check below and flow through the dispatch's
// `entry.type === 'api' ? entry.key : ...` branch unchanged.
@@ -706,7 +748,7 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
}
if (providerID === 'anthropic') {
return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal });
return callAnthropic({ apiKey, baseURL: providerConfig?.baseURL, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal });
}
if (providerID === 'google') {
return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal });
@@ -751,7 +793,9 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
return callOpenaiCompatible({
baseURL,
headers: { Authorization: `Bearer ${apiKey}` },
// Configured headers last: a gateway that authenticates on its own header
// must be able to override the bearer default rather than sit beside it.
headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
modelID,
prompt,
system,
@@ -5,11 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// readConfig reads merged opencode config layers from disk; mock it so each
// test controls the provider config without touching the filesystem. call.js
// imports only readConfig from shared.js, so the rest of that module is left
// untouched for this file.
// imports the config readers and a plain-object predicate from shared.js, so
// the rest of that module is left untouched for this file.
vi.mock('../opencode/shared.js', () => ({
readConfig: vi.fn(),
readConfigLayers: vi.fn(),
// Pure predicate with no disk access — the real implementation, so header
// parsing is exercised rather than stubbed.
isPlainObject: (value) => value instanceof Object && !Array.isArray(value),
}));
vi.mock('./runtime-providers.js', () => ({ getRuntimeProvider: vi.fn(async () => null) }));
@@ -67,6 +70,7 @@ describe('callSmallModel — custom provider config', () => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
delete process.env.OPENCHAMBER_TEST_PROVIDER_KEY;
delete process.env.OPENCHAMBER_TEST_GATEWAY_KEY;
});
describe('config-supplied credentials (no auth.json entry)', () => {
@@ -122,6 +126,105 @@ describe('callSmallModel — custom provider config', () => {
expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer sk-env-key');
});
it('sends configured provider headers alongside the bearer token', async () => {
process.env.OPENCHAMBER_TEST_GATEWAY_KEY = 'sub-key';
readConfig.mockReturnValue({
provider: {
custom: {
options: {
apiKey: 'sk-config',
baseURL: 'https://proxy.example.test/v1',
headers: {
'Ocp-Apim-Subscription-Key': '{env:OPENCHAMBER_TEST_GATEWAY_KEY}',
'x-tenant': 'team',
},
},
},
},
});
fetchMock.mockResolvedValue(ok('hello'));
await callSmallModel({
auth: {},
catalog: {},
workingDirectory: '/proj',
providerID: 'custom',
modelID: 'model',
prompt: 'hi',
});
const { init } = lastCall(fetchMock);
expect(init.headers['Ocp-Apim-Subscription-Key']).toBe('sub-key');
expect(init.headers['x-tenant']).toBe('team');
expect(init.headers.Authorization).toBe('Bearer sk-config');
});
it('resolves a relative header file from the config layer that defines it', async () => {
const configPath = '/config/opencode.json';
const secretPath = '/config/gateway-key';
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
if (filePath === secretPath) return 'sub-key\n';
throw new Error(`Unexpected file read: ${filePath}`);
});
const provider = {
custom: {
options: {
apiKey: 'sk-config',
baseURL: 'https://proxy.example.test/v1',
headers: { 'x-gateway-key': '{file:./gateway-key}' },
},
},
};
readConfig.mockReturnValue({ provider });
readConfigLayers.mockReturnValue({
customConfig: {},
projectConfig: {},
userConfig: { provider },
paths: { customPath: null, projectPath: '/project/opencode.json', userPath: configPath },
});
fetchMock.mockResolvedValue(ok('hello'));
await callSmallModel({
auth: {},
catalog: {},
workingDirectory: '/project',
providerID: 'custom',
modelID: 'model',
prompt: 'hi',
});
expect(lastCall(fetchMock).init.headers['x-gateway-key']).toBe('sub-key');
expect(fs.readFileSync).toHaveBeenCalledWith(secretPath, 'utf8');
});
it('overrides Authorization without depending on header-name casing', async () => {
readConfig.mockReturnValue({
provider: {
custom: {
options: {
apiKey: 'sk-config',
baseURL: 'https://proxy.example.test/v1',
headers: { authorization: 'Basic gateway-token' },
},
},
},
});
fetchMock.mockResolvedValue(ok('hello'));
await callSmallModel({
auth: {},
catalog: {},
workingDirectory: '/project',
providerID: 'custom',
modelID: 'model',
prompt: 'hi',
});
const headers = lastCall(fetchMock).init.headers;
expect(headers.authorization).toBe('Basic gateway-token');
expect(headers.Authorization).toBeUndefined();
});
it('uses apiKey and baseURL from provider config when no auth.json entry exists', async () => {
readConfig.mockReturnValue({
provider: {
@@ -316,6 +419,69 @@ describe('callSmallModel — custom provider config', () => {
});
});
describe('anthropic provider custom baseURL override', () => {
const anthropicOk = (text) => ({
ok: true,
status: 200,
json: async () => ({ content: [{ type: 'text', text }] }),
});
it('respects provider.anthropic.options.baseURL over the hardcoded Anthropic endpoint', async () => {
readConfig.mockReturnValue({
provider: { anthropic: { options: { baseURL: 'http://127.0.0.1:3456/v1' } } },
});
fetchMock.mockResolvedValue(anthropicOk('ok'));
await callSmallModel({
auth: { anthropic: { type: 'api', key: 'dummy' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
prompt: 'hi',
});
const { url, init } = lastCall(fetchMock);
expect(url).toBe('http://127.0.0.1:3456/v1/messages');
expect(url).not.toContain('api.anthropic.com');
expect(init.headers['x-api-key']).toBe('dummy');
});
it('uses a bare-host baseURL as-is without inserting /v1, matching @ai-sdk/anthropic', async () => {
readConfig.mockReturnValue({
provider: { anthropic: { options: { baseURL: 'http://127.0.0.1:3456' } } },
});
fetchMock.mockResolvedValue(anthropicOk('ok'));
await callSmallModel({
auth: { anthropic: { type: 'api', key: 'dummy' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
prompt: 'hi',
});
expect(lastCall(fetchMock).url).toBe('http://127.0.0.1:3456/messages');
});
it('falls back to https://api.anthropic.com when no anthropic baseURL override is configured', async () => {
readConfig.mockReturnValue({});
fetchMock.mockResolvedValue(anthropicOk('ok'));
await callSmallModel({
auth: { anthropic: { type: 'api', key: 'sk-ant' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
prompt: 'hi',
});
expect(lastCall(fetchMock).url).toBe('https://api.anthropic.com/v1/messages');
});
});
describe('catalog-based base URL (no config override)', () => {
it('uses the catalog api field when no config baseURL is set', async () => {
readConfig.mockReturnValue({});
@@ -536,6 +702,22 @@ describe('callSmallModel — Google thinking configuration', () => {
const body = JSON.parse(lastCall(fetchMock).init.body);
expect(body.generationConfig.thinkingConfig).toEqual({ thinkingBudget: 0 });
});
it('omits thinkingConfig for other Google/Gemini models', async () => {
fetchMock.mockResolvedValue(googleResponse('generated commit'));
await callSmallModel({
auth: { google: { type: 'api', key: 'google-key' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'google',
modelID: 'gemini-1.5-flash',
prompt: 'generate',
});
const body = JSON.parse(lastCall(fetchMock).init.body);
expect(body.generationConfig.thinkingConfig).toBeUndefined();
});
});
describe('callSmallModel — GitHub Copilot endpoint routing', () => {
@@ -0,0 +1,51 @@
/**
* Applies the user's explicit Small Model override (Settings Chat Small
* Model) to the configuration injected into the managed OpenCode process.
*
* OpenCode's own session-title and summary generation reads `small_model`
* from its config layers. Previously the OpenChamber settings override only
* fed OpenChamber's own `/api/small-model/generate` utility service, so a
* configured Small Model never reached OpenCode's title generation and
* sessions kept their fallback/untitled state. Injecting the override as
* `small_model` in the managed `OPENCODE_CONFIG_CONTENT` closes that gap for
* the managed server.
*
* Only an explicit override applies (`smallModelUseDefault === false` with a
* non-empty `smallModelOverride`). "Use default" leaves the config untouched,
* so OpenCode's own resolution chain (config `small_model`, then its family
* scan) stays authoritative this mirrors the precedence documented in
* `packages/web/server/lib/small-model/DOCUMENTATION.md`.
*
* Malformed user config is left untouched rather than rewritten: OpenCode's
* own loader is the right place to surface it, and silently rewriting it
* would hide the error.
*/
export const applySmallModelOverrideToOpenCodeConfig = ({
configContent,
smallModelUseDefault,
smallModelOverride,
}) => {
if (smallModelUseDefault !== false) {
return configContent;
}
const override = typeof smallModelOverride === 'string' ? smallModelOverride.trim() : '';
if (!override) {
return configContent;
}
const current = (() => {
if (typeof configContent !== 'string' || configContent.trim().length === 0) {
return {};
}
try {
return JSON.parse(configContent);
} catch {
return null;
}
})();
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
return configContent;
}
return JSON.stringify({ ...current, small_model: override });
};
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { applySmallModelOverrideToOpenCodeConfig } from './config-injection.js';
describe('applySmallModelOverrideToOpenCodeConfig', () => {
it('leaves config unchanged when use-default is not explicitly disabled', () => {
const config = '{"model":"anthropic/claude-sonnet-4-5"}';
expect(
applySmallModelOverrideToOpenCodeConfig({
configContent: config,
smallModelUseDefault: true,
smallModelOverride: 'anthropic/claude-haiku-4-5',
}),
).toBe(config);
expect(
applySmallModelOverrideToOpenCodeConfig({
configContent: config,
smallModelUseDefault: undefined,
smallModelOverride: 'anthropic/claude-haiku-4-5',
}),
).toBe(config);
});
it('leaves config unchanged when the override is empty or whitespace', () => {
const config = '{"model":"anthropic/claude-sonnet-4-5"}';
expect(
applySmallModelOverrideToOpenCodeConfig({
configContent: config,
smallModelUseDefault: false,
smallModelOverride: ' ',
}),
).toBe(config);
expect(
applySmallModelOverrideToOpenCodeConfig({
configContent: config,
smallModelUseDefault: false,
smallModelOverride: undefined,
}),
).toBe(config);
});
it('injects small_model into an empty config', () => {
const result = applySmallModelOverrideToOpenCodeConfig({
configContent: undefined,
smallModelUseDefault: false,
smallModelOverride: 'anthropic/claude-haiku-4-5',
});
expect(JSON.parse(result)).toEqual({ small_model: 'anthropic/claude-haiku-4-5' });
});
it('injects small_model while preserving existing config keys and plugins', () => {
const result = applySmallModelOverrideToOpenCodeConfig({
configContent: '{"model":"anthropic/claude-sonnet-4-5","plugin":["file:///tool.js"]}',
smallModelUseDefault: false,
smallModelOverride: 'google/gemini-2.5-flash',
});
expect(JSON.parse(result)).toEqual({
model: 'anthropic/claude-sonnet-4-5',
plugin: ['file:///tool.js'],
small_model: 'google/gemini-2.5-flash',
});
});
it('replaces an existing small_model with the override', () => {
const result = applySmallModelOverrideToOpenCodeConfig({
configContent: '{"small_model":"anthropic/claude-haiku-4-5"}',
smallModelUseDefault: false,
smallModelOverride: 'google/gemini-2.5-flash',
});
expect(JSON.parse(result)).toEqual({ small_model: 'google/gemini-2.5-flash' });
});
it('leaves malformed config untouched instead of rewriting it', () => {
const config = '{not-valid-json';
expect(
applySmallModelOverrideToOpenCodeConfig({
configContent: config,
smallModelUseDefault: false,
smallModelOverride: 'anthropic/claude-haiku-4-5',
}),
).toBe(config);
const arrayConfig = '["not","an","object"]';
expect(
applySmallModelOverrideToOpenCodeConfig({
configContent: arrayConfig,
smallModelUseDefault: false,
smallModelOverride: 'anthropic/claude-haiku-4-5',
}),
).toBe(arrayConfig);
});
});
@@ -19,6 +19,8 @@
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
## PTY Lifecycle
- IDs are client-provided or generated with `randomUUID()`.
+29 -1
View File
@@ -228,7 +228,7 @@ export function createTerminalRuntime({
}
if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
const creation = (async () => {
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false };
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell });
sessions.set(id, session);
return session;
@@ -297,6 +297,34 @@ export function createTerminalRuntime({
res.status(500).json({ error: error?.message || 'Failed to list terminal shells' });
}
});
app.get('/api/terminal/sessions', (req, res) => {
const rawCwd = typeof req.query?.cwd === 'string' ? req.query.cwd.trim() : '';
const cwdFilter = rawCwd ? path.resolve(rawCwd) : null;
const list = [];
for (const session of sessions.values()) {
if (cwdFilter && path.resolve(session.cwd) !== cwdFilter) continue;
list.push({
sessionId: session.id,
cwd: session.cwd,
status: session.status,
createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null,
});
}
res.json({ sessions: list });
});
app.post('/api/terminal/touch', (req, res) => {
const rawIds = Array.isArray(req.body?.sessionIds) ? req.body.sessionIds : [];
const now = Date.now();
let touched = 0;
for (const id of rawIds) {
if (typeof id !== 'string') continue;
const session = sessions.get(id);
if (!session) continue;
session.lastActivity = now;
touched += 1;
}
res.json({ touched });
});
app.post('/api/terminal/create', async (req, res) => {
try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); }
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); }
@@ -179,6 +179,32 @@ describe('terminal runtime', () => {
} finally { await harness.runtime.shutdown(); }
});
it('lists sessions scoped to a working directory and refreshes activity via touch', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-a', cwd: '/repo' } }, createResponse());
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-b', cwd: '/other' } }, createResponse());
const all = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, all);
expect(all.body.sessions.map((s) => s.sessionId).sort()).toEqual(['term-a', 'term-b']);
const scoped = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
expect(scoped.body.sessions).toEqual([
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) },
]);
const touch = createResponse();
harness.routes.post.get('/api/terminal/touch')({ body: { sessionIds: ['term-a', 'missing', 42] } }, touch);
expect(touch.body).toEqual({ touched: 1 });
const malformed = createResponse();
harness.routes.post.get('/api/terminal/touch')({ body: {} }, malformed);
expect(malformed.body).toEqual({ touched: 0 });
} finally { await harness.runtime.shutdown(); }
});
it('strips AppImage ARGV0 from PTY child environments', async () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
@@ -6,11 +6,11 @@ import {
const PROVIDER_INSTALL_INFO = {
[TUNNEL_PROVIDER_CLOUDFLARE]: {
dependency: 'cloudflared',
installUrl: 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/',
installUrl: 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/',
commands: {
darwin: 'brew install cloudflared',
win32: 'winget install --id Cloudflare.cloudflared',
linux: 'Download cloudflared from https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/',
linux: 'Download cloudflared from https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/',
},
},
[TUNNEL_PROVIDER_NGROK]: {
@@ -28,4 +28,13 @@ describe('getTunnelDependencyInstallInfo', () => {
expect(info.installCommand).toBe('brew install cloudflared');
});
it('returns the current Linux cloudflared download guidance', () => {
const info = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE, 'linux');
const downloadUrl = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/';
expect(info.installUrl).toBe(downloadUrl);
expect(info.installCommand).toBe(`Download cloudflared from ${downloadUrl}`);
expect(info.message).toContain(downloadUrl);
});
});
+2 -2
View File
@@ -839,7 +839,7 @@ export const createUiAuth = ({
let clientTokenResult = null;
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
clientTokenResult = await clientAuthController.createClient({
label: req.body?.clientLabel,
fallbackLabel: req.body?.clientLabel,
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
clientKind: req.body?.clientKind,
dedupeKey: req.body?.dedupeKey,
@@ -907,7 +907,7 @@ export const createUiAuth = ({
let clientTokenResult = null;
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
clientTokenResult = await clientAuthController.createClient({
label: req.body?.clientLabel,
fallbackLabel: req.body?.clientLabel,
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
clientKind: req.body?.clientKind,
dedupeKey: req.body?.dedupeKey,
@@ -269,7 +269,7 @@ describe('ui auth client credential seam', () => {
token: 'client-token',
client: {
id: 'device-1',
label: input.label,
label: input.label ?? input.fallbackLabel,
createdAt: new Date().toISOString(),
lastUsedAt: null,
revokedAt: null,
@@ -295,7 +295,7 @@ describe('ui auth client credential seam', () => {
await auth.handleSessionCreate(req, res);
expect(res.body.clientToken).toBe('client-token');
expect(createClientInput.label).toBe('OpenChamber Desktop');
expect(createClientInput.fallbackLabel).toBe('OpenChamber Desktop');
const expiresAt = Date.parse(createClientInput.expiresAt);
expect(expiresAt).toBeGreaterThanOrEqual(before + 122_000);
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 124_000);
@@ -27,6 +27,7 @@ const LANGUAGE_NAMES = {
ko: 'Korean',
pl: 'Polish',
ja: 'Japanese',
tr: 'Turkish',
};
/**
+2
View File
@@ -11,6 +11,8 @@ export const createWebGitAPI = (): GitAPI => ({
getGitDiff: gitApiHttp.getGitDiff,
getGitFileDiff: gitApiHttp.getGitFileDiff,
getGitRangeDiff: gitApiHttp.getGitRangeDiff,
getGitRangeFiles: gitApiHttp.getGitRangeFiles,
getBranchBase: gitApiHttp.getBranchBase,
revertGitFile: gitApiHttp.revertGitFile,
stageGitFile: gitApiHttp.stageGitFile,
stageGitFiles: gitApiHttp.stageGitFiles,
+10
View File
@@ -8,6 +8,8 @@ import {
restartTerminalSession,
forceKillTerminal,
listTerminalShells,
listTerminalSessions,
touchTerminalSessions,
} from '@openchamber/ui/lib/terminalApi';
import type {
TerminalAPI,
@@ -23,6 +25,14 @@ export const createWebTerminalAPI = (): TerminalAPI => ({
return listTerminalShells();
},
async listSessions(cwd: string) {
return listTerminalSessions(cwd);
},
async touchSessions(sessionIds: string[]) {
await touchTerminalSessions(sessionIds);
},
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
return createTerminalSession(options);
},
+5 -1
View File
@@ -2,7 +2,7 @@ import { createConfiguredWebAPIs, getDesktopRelayRestoreReady } from './runtimeC
import { registerSW } from 'virtual:pwa-register';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import { resolveHostedSurface, type HostedSurface } from '@openchamber/ui/lib/runtimeSurface';
import { resolveHostedSurface, watchHostedSurfaceViewport, type HostedSurface } from '@openchamber/ui/lib/runtimeSurface';
import {
isEmbeddedSessionChat,
requestEmbeddedSessionRuntimeBootstrap,
@@ -90,6 +90,10 @@ const start = async (): Promise<void> => {
: null;
window.__OPENCHAMBER_RUNTIME_APIS__ = createConfiguredWebAPIs(embeddedBootstrap);
// Reload into the other app shell when the viewport crosses the phone
// threshold after boot (no-op in fixed shells and with ?surface= overrides).
watchHostedSurfaceViewport();
if (hostedSurface === 'mobile') {
const { renderMobileApp } = await import('@openchamber/ui/apps/renderMobileApp');
renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__);
+22 -1
View File
@@ -108,7 +108,7 @@ describe('createConfiguredWebAPIs', () => {
expect(initializeRuntimeEndpoint).toHaveBeenCalledWith({
apiBaseUrl: bootstrap.apiBaseUrl,
runtimeKey: null,
runtimeKey: 'host:host-1',
});
expect(setRuntimeBearerToken).toHaveBeenCalledWith(bootstrap.clientToken);
expect(setRuntimeExtraHeaders).toHaveBeenCalledWith(bootstrap.runtimeHeaders);
@@ -116,6 +116,27 @@ describe('createConfiguredWebAPIs', () => {
expect(opencodeClient.reconnectToRuntimeBaseUrl).toHaveBeenCalled();
});
test('uses the configured desktop host id across changing SSH tunnel URLs', () => {
const current = makeWindow();
current.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = {
target: 'remote',
status: 'ok',
hostId: 'ssh-castle',
url: 'http://127.0.0.1:62545',
localAvailable: true,
};
current.__OPENCHAMBER_API_BASE_URL__ = 'http://127.0.0.1:62545';
current.__OPENCHAMBER_LOCAL_ORIGIN__ = 'http://127.0.0.1:3901';
installWindow(current);
createConfiguredWebAPIs();
expect(initializeRuntimeEndpoint).toHaveBeenCalledWith({
apiBaseUrl: 'http://127.0.0.1:62545',
runtimeKey: 'host:ssh-castle',
});
});
test('activates an embedded relay without relying on Electron preload IPC', () => {
const relay = {
relayUrl: 'wss://relay.example.com',
+4 -1
View File
@@ -2,6 +2,7 @@ import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRun
import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch';
import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch';
import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore';
import { getInjectedBootOutcome } from '@openchamber/ui/lib/desktopBoot';
import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
import type { EmbeddedSessionRuntimeBootstrap } from '@openchamber/ui/components/layout/contextPanelEmbeddedChat';
import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
@@ -45,6 +46,8 @@ export const getDesktopRelayRestoreReady = (): Promise<void> => desktopRelayRest
export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootstrap | null) => {
const { apiBaseUrl, clientToken, localOrigin, runtimeHeaders, relayHostId, relay } = bootstrap ?? readRuntimeBootstrapConfig();
const bootOutcome = bootstrap ? null : getInjectedBootOutcome();
const desktopHostId = relayHostId || (bootOutcome?.target === 'remote' ? bootOutcome.hostId : '');
const urls = configureRuntimeUrlResolver({
apiBaseUrl: apiBaseUrl || undefined,
@@ -52,7 +55,7 @@ export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootst
});
initializeRuntimeEndpoint({
apiBaseUrl,
runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : null,
runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : (desktopHostId ? `host:${desktopHostId}` : null),
});
setRuntimeBearerToken(clientToken || null);
setRuntimeExtraHeaders(runtimeHeaders || null);
+26 -1
View File
@@ -67,5 +67,30 @@ self.addEventListener('notificationclick', (event) => {
const data = (event.notification.data ?? null) as { url?: string } | null;
const url = data?.url ?? '/';
event.waitUntil(self.clients.openWindow(url));
event.waitUntil((async () => {
// Prefer focusing an already-open window (e.g. the installed PWA) and
// navigating it to the target, instead of always spawning a new window.
const target = new URL(url, self.location.origin).href;
const windowClients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
for (const client of windowClients) {
try {
if ('navigate' in client) {
await client.navigate(target);
}
} catch {
// navigate() can reject for uncontrolled clients; fall back to focus.
}
if ('focus' in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(target);
}
})());
});