Merge branch 'main' into reproduce/issue-1720

Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
Mayuresh K
2026-07-01 18:15:18 +01:00
committed by GitHub
725 changed files with 34139 additions and 23393 deletions
+4 -7
View File
@@ -409,7 +409,10 @@ function createAgent(agentName, config, workingDirectory, scope) {
targetScope = AGENT_SCOPE.USER;
}
const { prompt, scope: _scopeFromConfig, ...frontmatter } = config;
const { prompt, scope: _scopeFromConfig, ...rawFrontmatter } = config;
const frontmatter = Object.fromEntries(
Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined)
);
writeMdFile(targetPath, frontmatter, prompt || '');
console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`);
@@ -685,12 +688,6 @@ function deleteAgent(agentName, workingDirectory, scope) {
}
export {
ensureProjectAgentDir,
getProjectAgentPath,
getUserAgentPath,
getAgentScope,
getAgentWritePath,
getAgentPermissionSource,
getAgentSources,
getAgentConfig,
createAgent,
@@ -51,7 +51,8 @@ export const createOpenCodeAuthStateRuntime = (dependencies) => {
return {};
}
const credentials = Buffer.from(`opencode:${password}`).toString('base64');
const username = process.env.OPENCODE_SERVER_USERNAME?.trim() || 'opencode';
const credentials = Buffer.from(`${username}:${password}`).toString('base64');
return { Authorization: `Basic ${credentials}` };
};
+6
View File
@@ -32,7 +32,10 @@ export const createBootstrapRuntime = (dependencies) => {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
addOrUpdateApnsToken,
removeApnsToken,
updateUiVisibility,
clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
@@ -95,7 +98,10 @@ export const createBootstrapRuntime = (dependencies) => {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
addOrUpdateApnsToken,
removeApnsToken,
updateUiVisibility,
clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
@@ -327,11 +327,6 @@ function deleteCommand(commandName, workingDirectory) {
}
export {
ensureProjectCommandDir,
getProjectCommandPath,
getUserCommandPath,
getCommandScope,
getCommandWritePath,
getCommandSources,
createCommand,
updateCommand,
@@ -26,6 +26,30 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
expandSnippets,
} = dependencies;
// Build the response for a config mutation based on whether OpenCode actually
// reloaded the change. When connected to an external OpenCode server that
// OpenChamber cannot restart, the change is persisted to disk but the running
// server will not serve it until the user restarts that server. We must not
// report a clean "reloading" success in that case, otherwise the UI silently
// reverts the edit to the stale value on the next refresh.
const buildConfigMutationResponse = (refreshResult, { liveMessage, manualRestartMessage }) => {
if (refreshResult && refreshResult.external) {
return {
success: true,
requiresReload: false,
requiresManualRestart: true,
message: manualRestartMessage,
};
}
return {
success: true,
requiresReload: true,
message: liveMessage,
reloadDelayMs: clientReloadDelayMs,
};
};
const completeMcpMutation = async (res, action, name, applyChange) => {
applyChange();
@@ -104,16 +128,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createAgent(agentName, config, directory, scope);
await refreshOpenCodeAfterConfigChange('agent creation', {
const refreshResult = await refreshOpenCodeAfterConfigChange('agent creation', {
agentName
});
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} created successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
res.json(buildConfigMutationResponse(refreshResult, {
liveMessage: `Agent ${agentName} created successfully. Reloading interface…`,
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
}));
} catch (error) {
console.error('Failed to create agent:', error);
res.status(500).json({ error: error.message || 'Failed to create agent' });
@@ -134,16 +156,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
console.log('[Server] Working directory:', directory);
updateAgent(agentName, updates, directory);
await refreshOpenCodeAfterConfigChange('agent update');
const refreshResult = await refreshOpenCodeAfterConfigChange('agent update');
console.log(`[Server] Agent ${agentName} updated successfully`);
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} updated successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
res.json(buildConfigMutationResponse(refreshResult, {
liveMessage: `Agent ${agentName} updated successfully. Reloading interface…`,
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
}));
} catch (error) {
console.error('[Server] Failed to update agent:', error);
console.error('[Server] Error stack:', error.stack);
@@ -161,14 +181,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
const scope = req.body?.scope;
deleteAgent(agentName, directory, scope);
await refreshOpenCodeAfterConfigChange('agent deletion');
const refreshResult = await refreshOpenCodeAfterConfigChange('agent deletion');
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
res.json(buildConfigMutationResponse(refreshResult, {
liveMessage: `Agent ${agentName} deleted successfully. Reloading interface…`,
manualRestartMessage: `Agent ${agentName} deleted. Restart your connected OpenCode server to apply the change.`,
}));
} catch (error) {
console.error('Failed to delete agent:', error);
res.status(500).json({ error: error.message || 'Failed to delete agent' });
@@ -396,6 +396,35 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
}
};
const runWithClientCreateAuth = async (req, res, next, handler) => {
try {
if (typeof uiAuthController.resolveAuthContext === 'function') {
const context = await uiAuthController.resolveAuthContext(req, res, {
allowClientAuth: true,
allowUrlToken: false,
});
if (context?.type === 'session') {
await handler(context);
return;
}
if (context?.type === 'client') {
const client = await clientRecordFromAuthContext(context);
if (client?.clientKind === 'desktop-local') {
await handler({ ...context, client });
return;
}
return res.status(403).json({ error: 'Client tokens cannot create remote clients' });
}
}
await runWithUiAuth(req, res, next, async () => {
await handler({ type: 'session' });
}, { sessionOnly: true });
} catch (error) {
next(error);
}
};
const clientIdFromAuthContext = (context) => {
const raw = context?.client?.id || context?.clientId;
return typeof raw === 'string' && raw.length > 0 ? raw : null;
@@ -567,7 +596,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
});
app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => {
await runWithUiAuth(req, res, next, async () => {
await runWithClientCreateAuth(req, res, next, async () => {
const result = await remoteClientAuthRuntime.createClient({
label: req.body?.label,
clientKind: req.body?.clientKind,
@@ -575,7 +604,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
});
res.setHeader('Cache-Control', 'no-store');
res.status(201).json(result);
}, { sessionOnly: true });
});
});
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
@@ -399,6 +399,36 @@ describe('client auth routes', () => {
expect(revoked.body.client.id).toBe(current.body.client.id);
});
it('allows only the local desktop client token to create remote client tokens', async () => {
const app = express();
let authContext = { type: 'session' };
const dependencies = createDependencies({
resolveAuthContext: async () => authContext,
});
registerAuthAndAccessRoutes(app, dependencies);
const desktop = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
const remote = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Phone' });
authContext = { type: 'client', clientId: remote.body.client.id, client: remote.body.client };
const denied = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Another phone' });
expect(denied.status).toBe(403);
expect(denied.body.error).toBe('Client tokens cannot create remote clients');
authContext = { type: 'client', clientId: desktop.body.client.id, client: desktop.body.client };
const created = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Mobile' });
expect(created.status).toBe(201);
expect(created.body.client.label).toBe('Mobile');
});
it('requires UI-session auth for passkey registration management routes', async () => {
const app = express();
const dependencies = createDependencies();
@@ -13,6 +13,33 @@ import { registerPluginRoutes } from './plugin-routes.js';
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
import { registerOpenCodeRoutes } from './routes.js';
import { getProviderSources, removeProviderConfig } from './providers.js';
import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js';
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
import { listSnippets, getSnippet, createSnippet, updateSnippet, deleteSnippet, expandSnippets } from './snippets.js';
import {
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
} from './plugins.js';
import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js';
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.js';
import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js';
import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js';
import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js';
import { scanSkillsRepository } from '../skills-catalog/scan.js';
import { installSkillsFromRepository } from '../skills-catalog/install.js';
import { scanClawdHubPage } from '../skills-catalog/clawdhub/scan.js';
import { installSkillsFromClawdHub } from '../skills-catalog/clawdhub/install.js';
export const createFeatureRoutesRuntime = (dependencies) => {
const {
@@ -63,8 +90,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
writeSseEvent,
} = routeDependencies;
const { getProviderSources, removeProviderConfig } = await import('./index.js');
registerSettingsUtilityRoutes(app, {
readCustomThemesFromDisk,
refreshOpenCodeAfterConfigChange,
@@ -111,40 +136,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
writeSseEvent,
});
const {
getAgentSources,
getAgentConfig,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
createCommand,
updateCommand,
deleteCommand,
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
listSnippets,
getSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
expandSnippets,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
} = await import('./index.js');
registerConfigEntityRoutes(app, {
resolveProjectDirectory,
resolveOptionalProjectDirectory,
@@ -193,32 +184,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
isExactSemver,
});
const {
getSkillSources,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
} = await import('./index.js');
const {
getCuratedSkillsSources,
getCacheKey,
getCachedScan,
setCachedScan,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
scanClawdHubPage,
installSkillsFromClawdHub,
isClawdHubSource,
} = await import('../skills-catalog/index.js');
const { getProfiles, getProfile } = await import('../git/index.js');
registerSkillRoutes(app, {
-95
View File
@@ -1,95 +0,0 @@
export {
AGENT_DIR,
COMMAND_DIR,
SKILL_DIR,
CONFIG_FILE,
AGENT_SCOPE,
COMMAND_SCOPE,
SKILL_SCOPE,
readConfig,
writeConfig,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
} from './shared.js';
export {
getAgentScope,
getAgentPermissionSource,
getAgentSources,
getAgentConfig,
createAgent,
updateAgent,
deleteAgent,
} from './agents.js';
export {
getCommandScope,
getCommandSources,
createCommand,
updateCommand,
deleteCommand,
} from './commands.js';
export {
getSkillSources,
getSkillScope,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
updateSkill,
deleteSkill,
} from './skills.js';
export {
getProviderSources,
removeProviderConfig,
} from './providers.js';
export {
readAuthFile,
writeAuthFile,
removeProviderAuth,
getProviderAuth,
listProviderAuths,
AUTH_FILE,
OPENCODE_DATA_DIR,
} from './auth.js';
export { createUiAuth } from '../ui-auth/ui-auth.js';
export {
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
} from './mcp.js';
export {
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
parsePluginRaw,
serializePluginEntry,
} from './plugins.js';
export {
listSnippets,
getSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
expandSnippets,
} from './snippets.js';
export { getNpmInfo, lookupNpmPackage, clearCache as clearNpmCache } from './npm-registry.js';
export { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
+59 -10
View File
@@ -1,5 +1,6 @@
import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
@@ -140,7 +141,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
});
};
const closeManagedOpenCodeChild = async (child) => {
const terminateChildProcess = async (child) => {
if (!child) {
return;
}
@@ -212,6 +213,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
await waitForChildProcessClose(child, 1000);
};
const closeManagedOpenCodeChild = async (child) => {
const pid = child?.pid;
try {
await terminateChildProcess(child);
} finally {
// 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);
}
}
};
const formatCapturedOutput = ({ stdout, stderr }) => {
const parts = [];
if (stdout.trim()) {
@@ -324,6 +338,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
child.on('error', onError);
});
// Record this child so a future run can reap it if we crash before teardown.
// The web-server lifecycle runs in-process inside multiple hosts, so tag the
// 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({
pid: child.pid,
ownerPid: process.pid,
port,
binary,
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
});
return {
url,
pid: child.pid || null,
@@ -726,12 +753,22 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
await restartOpenCode();
// A managed OpenCode process is restarted (and thus re-reads config from
// disk) by restartOpenCode(). An external OpenCode server is NOT owned by
// OpenChamber: restartOpenCode() only re-probes its health, so the freshly
// written config is on disk but the running server keeps serving its old,
// startup-cached config until the user restarts it themselves. Report this
// honestly so callers don't claim the change is live.
const external = state.isExternalOpenCode === true;
try {
await waitForOpenCodeReady();
state.isOpenCodeReady = true;
state.openCodeNotReadySince = 0;
if (agentName) {
// Waiting for the agent to appear only makes sense when we actually
// reloaded config. An external server will never surface it here.
if (agentName && !external) {
await waitForAgentPresence(agentName);
}
@@ -743,10 +780,22 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
throw error;
}
return { reloaded: !external, external };
};
const bootstrapOpenCodeAtStartup = async () => {
try {
// Before doing anything, reap any OpenCode process WE spawned in a prior
// run that was orphaned by a crash/hard-exit. Verified + scoped to our own
// pids, so it never touches a live instance's or the user's own server.
try {
const { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) });
if (reaped > 0) console.log(`[lifecycle] startup reaped ${reaped} orphaned OpenCode process(es)`);
} catch (error) {
console.warn('[lifecycle] orphan reap failed:', error?.message ?? error);
}
syncFromHmrState();
if (await isOpenCodeProcessHealthy()) {
console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`);
@@ -770,15 +819,15 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) {
console.log('Auto-detected existing OpenCode server on default port 4096');
setOpenCodePort(4096);
state.isOpenCodeReady = true;
state.isExternalOpenCode = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else {
// We never auto-attach to an arbitrary pre-existing OpenCode instance.
// Attaching to an external server requires explicit opt-in via env
// (OPENCODE_HOST / OPENCODE_PORT / OPENCODE_SKIP_START), handled by the
// branches above. Without that opt-in we always start our OWN managed
// instance on a freshly-allocated port. A blind probe of the default
// port 4096 used to hijack a user's separately-running OpenCode (e.g.
// the OpenCode desktop app), coupling our lifecycle to theirs and
// breaking init against an unexpected server version/config.
if (env.ENV_EFFECTIVE_PORT) {
console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`);
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
@@ -0,0 +1,251 @@
// Managed OpenCode process registry + orphan reaper.
//
// OpenChamber spawns the OpenCode server as an EXTERNAL child binary (on Unix
// with `detached: true`, so it leads its own process group). That binary can
// therefore outlive its parent if the parent is hard-killed/crashes/`Ctrl+C`ed
// before graceful teardown runs — leaving an orphaned `opencode serve` that
// then contends on the shared SQLite DB and slows everything down.
//
// We cannot tie an arbitrary external binary to the parent's death portably
// (Electron's `utilityProcess` would, but it only runs JS entrypoints, not a
// standalone binary). So we use the same pattern OpenCode's own CLI daemon uses
// for its detached server: an on-disk record of the pids WE spawned, plus a
// startup reaper that kills ONLY our own, verified, genuinely-orphaned
// processes — never a process a live instance (another desktop window, a VS
// Code host, the user's standalone `opencode`) is actively using.
//
// Storage: ONE FILE PER SPAWNED PROCESS in a registry directory, named
// `<childPid>.json`. Multiple runtimes (web/desktop/VS Code) and multiple
// windows all run concurrently; a single shared JSON file would be corrupted by
// the read-modify-write race (last writer wins, clobbering another instance's
// entry). Per-process files mean every instance only ever writes/deletes its
// OWN file, so there is no write contention at all.
//
// Safety model (why this never kills the wrong thing):
// 1. The reaper only ever considers pids THIS product recorded. The user's
// standalone CLI server, the official desktop app, and the TUI are never
// recorded, so they are never even candidates.
// 2. Before killing, it re-verifies the live pid is still an `opencode serve`
// matching the recorded port (guards against the OS recycling a dead pid
// onto an unrelated process).
// 3. It kills only when the spawning owner is provably gone — the child has
// been reparented to init/pid 1, or the recorded owner pid is dead. A
// child still owned by a live instance is left untouched.
//
// 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 os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const resolveRegistryDir = () => {
const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY;
if (override && override.trim()) return override.trim();
return path.join(os.homedir(), '.config', 'openchamber', 'managed-opencode');
};
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 {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM = process exists but we lack permission to signal it → still alive.
return error?.code === 'EPERM';
}
};
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();
if (!lower.includes('opencode') || !lower.includes('serve')) return false;
// Tie to the exact server we registered when we know its port, so a recycled
// pid running a *different* opencode server is never mistaken for ours.
if (Number.isInteger(entry.port) && !command.includes(String(entry.port))) return false;
return true;
};
const killOrphan = async (pid) => {
if (process.platform === 'win32') {
try {
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true });
} catch {
}
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;
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 { fs.rmSync(filePath, { force: true }); } catch {}
}
}
return { inspected: records.length, reaped };
};
@@ -2,9 +2,9 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
export const NPM_CACHE_TTL_MS = 3_600_000;
export const NPM_FETCH_TIMEOUT_MS = 5_000;
export const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
const NPM_CACHE_TTL_MS = 3_600_000;
const NPM_FETCH_TIMEOUT_MS = 5_000;
const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
/**
* @typedef {Object} NpmPackagePayload
@@ -1,5 +1,13 @@
import { createRealpathCache } from '../path-realpath-cache.js';
// Browser transport percent-encodes directory hints and marks them explicitly.
// Only marked values are decoded so literal percent sequences from direct API
// clients are preserved.
const safeDecodeMarkedURIComponent = (value, encoding) => {
if (encoding !== 'uri') return value;
try { return decodeURIComponent(value); } catch { return value; }
};
export const createProjectDirectoryRuntime = (dependencies) => {
const {
fsPromises,
@@ -50,18 +58,24 @@ export const createProjectDirectoryRuntime = (dependencies) => {
};
const resolveProjectDirectory = async (req) => {
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null;
const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
const requested = [headerDirectory, queryDirectory].filter(Boolean);
if (requested) {
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
if (requested.length > 0) {
let lastError = null;
for (const candidate of requested) {
const validated = await validateDirectoryPath(candidate);
if (validated.ok) {
return { directory: validated.directory, error: null };
}
lastError = validated.error;
}
return { directory: validated.directory, error: null };
return { directory: null, error: lastError };
}
const readSettings = typeof getReadSettingsFromDiskMigrated === 'function'
@@ -103,22 +117,27 @@ export const createProjectDirectoryRuntime = (dependencies) => {
};
const resolveOptionalProjectDirectory = async (req) => {
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null;
const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
const requested = [headerDirectory, queryDirectory].filter(Boolean);
if (!requested) {
if (requested.length === 0) {
return { directory: null, error: null };
}
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
let lastError = null;
for (const candidate of requested) {
const validated = await validateDirectoryPath(candidate);
if (validated.ok) {
return { directory: validated.directory, error: null };
}
lastError = validated.error;
}
return { directory: validated.directory, error: null };
return { directory: null, error: lastError };
};
return {
@@ -128,6 +128,80 @@ describe('project directory runtime', () => {
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('decodes marked x-opencode-directory header values', async () => {
const pathWithUnicode = '/home/user/测试项目';
let validatedPath = null;
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
validatedPath = p;
return { isDirectory: () => true };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => {
if (header === 'x-opencode-directory') return encodeURIComponent(pathWithUnicode);
if (header === 'x-opencode-directory-encoding') return 'uri';
return null;
},
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(validatedPath).toBe(pathWithUnicode);
expect(result).toEqual({ directory: pathWithUnicode, error: null });
});
it('preserves raw percent sequences without directory encoding marker', async () => {
const rawPath = '/home/user/foo%20bar';
let validatedPath = null;
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
validatedPath = p;
return { isDirectory: () => true };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? rawPath : null,
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(validatedPath).toBe(rawPath);
expect(result).toEqual({ directory: rawPath, error: null });
});
it('falls back to query directory when an unmarked encoded header is invalid', async () => {
const validPath = '/home/user/workspace/project';
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
if (p === validPath) return { isDirectory: () => true };
throw { code: 'ENOENT' };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? encodeURIComponent(validPath) : null,
query: { directory: validPath },
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: validPath, error: null });
});
it('resolves symlinks in query directory parameter', async () => {
const runtime = createTestRuntime({
fsPromises: {
@@ -222,5 +296,29 @@ describe('project directory runtime', () => {
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('preserves raw percent sequences without directory encoding marker', async () => {
const rawPath = '/optional/foo%25bar';
let validatedPath = null;
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
validatedPath = p;
return { isDirectory: () => true };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? rawPath : null,
query: {},
};
const result = await runtime.resolveOptionalProjectDirectory(req);
expect(validatedPath).toBe(rawPath);
expect(result).toEqual({ directory: rawPath, error: null });
});
});
});
+38 -5
View File
@@ -31,7 +31,26 @@ export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions }
};
};
export const waitForSseDrain = (res, signal) => new Promise((resolve) => {
export const normalizeForwardedDirectoryHeaders = (headers) => {
const rawDirectory = headers?.['x-opencode-directory'];
if (typeof rawDirectory !== 'string') {
return headers;
}
if (headers['x-opencode-directory-encoding'] !== 'uri') {
return headers;
}
try {
headers['x-opencode-directory'] = decodeURIComponent(rawDirectory);
} catch {
// Leave malformed values untouched; upstream will reject invalid paths.
}
delete headers['x-opencode-directory-encoding'];
return headers;
};
const waitForSseDrain = (res, signal) => new Promise((resolve) => {
if (signal?.aborted || res.writableEnded || res.destroyed) {
resolve();
return;
@@ -113,7 +132,7 @@ const SESSION_LIST_ALLOWED_FIELDS = [
'project',
];
export const sanitizeSessionListItem = (session) => {
const sanitizeSessionListItem = (session) => {
if (!session || typeof session !== 'object' || Array.isArray(session)) {
return session;
}
@@ -149,7 +168,7 @@ export const sanitizeSessionListItem = (session) => {
return sanitized;
};
export const sanitizeSessionListPayload = (payload) => {
const sanitizeSessionListPayload = (payload) => {
if (!Array.isArray(payload)) {
return payload;
}
@@ -295,7 +314,9 @@ export const registerOpenCodeProxy = (app, deps) => {
? req.originalUrl
: (typeof req.url === 'string' ? req.url : '');
const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
const headers = collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders());
const headers = normalizeForwardedDirectoryHeaders(
collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())
);
headers.accept ??= 'text/event-stream';
headers['cache-control'] ??= 'no-cache';
@@ -414,7 +435,7 @@ export const registerOpenCodeProxy = (app, deps) => {
const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
const headers = req
? {
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
...normalizeForwardedDirectoryHeaders(collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())),
accept: 'application/json',
'accept-encoding': 'identity',
}
@@ -654,6 +675,18 @@ export const registerOpenCodeProxy = (app, deps) => {
proxyReq.setHeader('Authorization', authHeaders.Authorization);
}
if (req.headers?.['x-opencode-directory-encoding'] === 'uri') {
const rawDirectory = req.headers['x-opencode-directory'];
if (typeof rawDirectory === 'string') {
try {
proxyReq.setHeader('x-opencode-directory', decodeURIComponent(rawDirectory));
} catch {
proxyReq.setHeader('x-opencode-directory', rawDirectory);
}
}
proxyReq.removeHeader?.('x-opencode-directory-encoding');
}
// Defensive: request identity encoding from upstream OpenCode.
// This avoids compressed-body/header mismatches in multi-proxy setups.
proxyReq.setHeader('accept-encoding', 'identity');
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createDirectoryQueryCanonicalizer } from './proxy.js';
import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js';
describe('createDirectoryQueryCanonicalizer', () => {
it('canonicalizes directory query params and preserves other params', async () => {
@@ -70,3 +70,26 @@ describe('createDirectoryQueryCanonicalizer', () => {
await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1');
});
});
describe('normalizeForwardedDirectoryHeaders', () => {
it('decodes marked directory headers before forwarding to OpenCode', () => {
const headers = normalizeForwardedDirectoryHeaders({
'x-opencode-directory': encodeURIComponent('/Users/example/project'),
'x-opencode-directory-encoding': 'uri',
});
expect(headers).toEqual({
'x-opencode-directory': '/Users/example/project',
});
});
it('preserves unmarked percent sequences from direct clients', () => {
const headers = normalizeForwardedDirectoryHeaders({
'x-opencode-directory': '/Users/example/project%20literal',
});
expect(headers).toEqual({
'x-opencode-directory': '/Users/example/project%20literal',
});
});
});
@@ -131,9 +131,15 @@ export const createServerStartupRuntime = (dependencies) => {
const handleSignal = async () => {
await gracefulShutdown();
};
// Cover every signal a shell or dev harness may use to stop/restart us, so
// the managed OpenCode child is always torn down gracefully instead of
// orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP
// (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`).
process.on('SIGTERM', handleSignal);
process.on('SIGINT', handleSignal);
process.on('SIGQUIT', handleSignal);
process.on('SIGHUP', handleSignal);
process.on('SIGUSR2', handleSignal);
setSignalsAttached(true);
syncToHmrState();
}
@@ -26,6 +26,9 @@ export const createSettingsHelpers = (dependencies) => {
const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128;
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
const HIDDEN_MODELS_MAX = 1024;
const RECENT_EFFORTS_MAX_KEYS = 128;
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
const sanitizeShortcutOverrides = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -41,6 +44,35 @@ export const createSettingsHelpers = (dependencies) => {
return result;
};
const sanitizeRecentEfforts = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const result = {};
const seenKeys = new Set();
let count = 0;
for (const [rawKey, rawVariants] of Object.entries(value)) {
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
if (!key || seenKeys.has(key)) continue;
if (!Array.isArray(rawVariants)) continue;
const variants = [];
const seenVariants = new Set();
for (const rawVariant of rawVariants) {
const variant = typeof rawVariant === 'string' ? rawVariant.trim() : '';
if (!variant || seenVariants.has(variant)) continue;
seenVariants.add(variant);
variants.push(variant);
if (variants.length >= RECENT_EFFORTS_MAX_VARIANTS_PER_KEY) break;
}
if (variants.length === 0) continue;
seenKeys.add(key);
result[key] = variants;
count += 1;
if (count >= RECENT_EFFORTS_MAX_KEYS) break;
}
return count > 0 ? result : null;
};
const normalizePwaAppName = (value, fallback = '') => {
if (typeof value !== 'string') {
return fallback;
@@ -74,6 +106,20 @@ export const createSettingsHelpers = (dependencies) => {
return fallback;
};
const normalizeFollowUpBehavior = (value, legacyQueueModeEnabled = null) => {
// "immediate" was removed (it was wire-identical to "steer"); collapse it.
if (value === 'immediate') {
return 'steer';
}
if (value === 'steer' || value === 'queue') {
return value;
}
if (legacyQueueModeEnabled === false) {
return 'steer';
}
return 'queue';
};
const sanitizeSettingsUpdate = (payload) => {
if (!payload || typeof payload !== 'object') {
return {};
@@ -132,6 +178,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
}
if (typeof candidate.desktopKeepAwakeEnabled === 'boolean') {
result.desktopKeepAwakeEnabled = candidate.desktopKeepAwakeEnabled;
}
if (typeof candidate.desktopUiPassword === 'string') {
result.desktopUiPassword = candidate.desktopUiPassword.trim();
}
@@ -329,8 +378,10 @@ export const createSettingsHelpers = (dependencies) => {
const trimmed = candidate.defaultGitIdentityId.trim();
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
if (typeof candidate.followUpBehavior === 'string') {
result.followUpBehavior = normalizeFollowUpBehavior(candidate.followUpBehavior);
} else if (typeof candidate.queueModeEnabled === 'boolean') {
result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled);
}
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
@@ -474,6 +525,28 @@ export const createSettingsHelpers = (dependencies) => {
if (recentModels) {
result.recentModels = recentModels;
}
// Cap at 1024: users with several providers (anthropic, openai, google,
// bedrock, azure, etc.) each exposing dozens-to-hundreds of models can
// exceed 256 hidden entries quickly. 1024 covers dense multi-provider
// setups while still bounding persistence/memory.
const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, HIDDEN_MODELS_MAX);
if (hiddenModels) {
result.hiddenModels = hiddenModels;
}
if (Array.isArray(candidate.collapsedModelProviders)) {
result.collapsedModelProviders = normalizeStringArray(candidate.collapsedModelProviders);
}
if (Array.isArray(candidate.recentAgents)) {
result.recentAgents = normalizeStringArray(candidate.recentAgents);
}
const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts);
if (recentEfforts) {
result.recentEfforts = recentEfforts;
}
if (typeof candidate.diffLayoutPreference === 'string') {
const mode = candidate.diffLayoutPreference.trim();
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createSettingsHelpers } from './settings-helpers.js';
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
const createTestHelpers = () => createSettingsHelpers({
normalizePathForPersistence: (value) => value,
@@ -20,6 +21,42 @@ const createTestHelpers = () => createSettingsHelpers({
sanitizeProjects: () => undefined,
});
const createTestHelpersWithRealSanitizers = () => {
const runtime = createSettingsNormalizationRuntime({
os: { homedir: () => '/home/testuser' },
path: {
resolve: (...args) => args[args.length - 1],
sep: '/',
dirname: (p) => p.split('/').slice(0, -1).join('/') || '/',
},
processLike: { platform: 'linux', env: {} },
realpathSync: (p) => p,
tunnelBootstrapTtlDefaultMs: 600000,
tunnelBootstrapTtlMinMs: 60000,
tunnelBootstrapTtlMaxMs: 3600000,
tunnelSessionTtlDefaultMs: 86400000,
tunnelSessionTtlMinMs: 3600000,
tunnelSessionTtlMaxMs: 604800000,
});
return createSettingsHelpers({
normalizePathForPersistence: (value) => value,
normalizeDirectoryPath: (value) => value,
normalizeTunnelBootstrapTtlMs: (value) => value,
normalizeTunnelSessionTtlMs: (value) => value,
normalizeTunnelProvider: (value) => value,
normalizeTunnelMode: (value) => value,
normalizeOptionalPath: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: () => undefined,
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
sanitizeTypographySizesPartial: () => undefined,
normalizeStringArray: runtime.normalizeStringArray,
sanitizeModelRefs: runtime.sanitizeModelRefs,
sanitizeSkillCatalogs: () => undefined,
sanitizeProjects: () => undefined,
});
};
describe('settings helpers', () => {
it('accepts messageStreamTransport as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -52,6 +89,17 @@ describe('settings helpers', () => {
});
});
it('accepts desktopKeepAwakeEnabled as a persisted shared setting', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: true })).toEqual({
desktopKeepAwakeEnabled: true,
});
expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: false })).toEqual({
desktopKeepAwakeEnabled: false,
});
});
it('accepts desktopUiPassword as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -188,4 +236,121 @@ describe('settings helpers', () => {
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
}
});
describe('previously-dropped model selector persistence fields', () => {
it('round-trips hiddenModels through the sanitizer', () => {
const helpers = createTestHelpersWithRealSanitizers();
const input = [
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
{ providerID: 'openai', modelID: 'gpt-5' },
];
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: input })).toEqual({
hiddenModels: input,
});
});
it('handles empty hiddenModels the same way as empty favoriteModels', () => {
const helpers = createTestHelpersWithRealSanitizers();
const hiddenResult = helpers.sanitizeSettingsUpdate({ hiddenModels: [] });
const favoriteResult = helpers.sanitizeSettingsUpdate({ favoriteModels: [] });
expect(hiddenResult.hiddenModels).toEqual([]);
expect(favoriteResult.favoriteModels).toEqual([]);
expect(hiddenResult.hiddenModels).toEqual(favoriteResult.favoriteModels);
});
it('round-trips collapsedModelProviders and recentAgents as string arrays', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: ['anthropic', 'openai'] })).toEqual({
collapsedModelProviders: ['anthropic', 'openai'],
});
expect(helpers.sanitizeSettingsUpdate({ recentAgents: ['build', 'plan'] })).toEqual({
recentAgents: ['build', 'plan'],
});
});
it('round-trips recentEfforts as a Record<string, string[]>', () => {
const helpers = createTestHelpersWithRealSanitizers();
const input = {
'anthropic/claude-opus-4': ['high', 'default'],
'openai/gpt-5': ['low'],
};
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: input })).toEqual({
recentEfforts: input,
});
});
it('rejects garbage hiddenModels input the same way sanitizeModelRefs rejects bad refs', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 'not-an-array' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: null })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 123 })).toEqual({});
expect(
helpers.sanitizeSettingsUpdate({
hiddenModels: [
{ providerID: 'anthropic' },
{ modelID: 'gpt-5' },
'not-an-object',
null,
{ providerID: ' ', modelID: 'x' },
{ providerID: 'openai', modelID: '' },
],
})
).toEqual({ hiddenModels: [] });
});
it('rejects garbage collapsedModelProviders and recentAgents input', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: 'anthropic' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: null })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentAgents: 42 })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentAgents: { build: 1 } })).toEqual({});
});
it('rejects garbage recentEfforts input', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: 'not-an-object' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: [] })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: null })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': 'high' } })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { '': ['high'] } })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [] } })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({});
});
it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => {
const helpers = createTestHelpersWithRealSanitizers();
const payload = {
themeId: 'default',
hiddenModels: [
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
{ providerID: 'openai', modelID: 'gpt-5' },
],
collapsedModelProviders: ['anthropic', 'openai'],
recentAgents: ['build', 'plan'],
recentEfforts: {
'anthropic/claude-opus-4': ['high', 'default'],
'openai/gpt-5': ['low'],
},
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }],
recentModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
};
const sanitized = helpers.sanitizeSettingsUpdate(payload);
expect(sanitized.hiddenModels).toEqual(payload.hiddenModels);
expect(sanitized.collapsedModelProviders).toEqual(payload.collapsedModelProviders);
expect(sanitized.recentAgents).toEqual(payload.recentAgents);
expect(sanitized.recentEfforts).toEqual(payload.recentEfforts);
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
expect(sanitized.recentModels).toEqual(payload.recentModels);
});
});
});
@@ -507,20 +507,14 @@ export {
COMMAND_DIR,
SKILL_DIR,
CONFIG_FILE,
CUSTOM_CONFIG_FILE,
PROMPT_FILE_PATTERN,
AGENT_SCOPE,
COMMAND_SCOPE,
SKILL_SCOPE,
ensureDirs,
parseMdFile,
writeMdFile,
getProjectConfigCandidates,
getProjectConfigPath,
getConfigPaths,
readConfigFile,
isPlainObject,
mergeConfigs,
readConfigLayers,
readConfig,
getConfigForPath,
@@ -594,8 +594,6 @@ function deleteSkill(skillName, workingDirectory) {
export {
getSkillSources,
getSkillScope,
getSkillWritePath,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
@@ -240,5 +240,3 @@ export function expandSnippets(text, workingDirectory) {
const expanded = expandText(text || '', registry, new Map(), collector).trim();
return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n');
}
export { assertValidSnippetName };