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