Merge origin/main into deferred OpenCode restart branch

This commit is contained in:
Bohdan Triapitsyn
2026-08-07 10:08:50 +03:00
218 changed files with 12131 additions and 1293 deletions
@@ -114,7 +114,7 @@ This module provides OpenCode server integration utilities for the web server ru
The runtime maintains active-session count incrementally from idempotent activity phase transitions. Upstream stall-timeout and lifecycle health checks read it in O(1); the hourly cleanup removes activity phases older than 24 hours without broadcasting synthetic state transitions. Snapshot generation remains reserved for the session-activity API.
## Public exports (lifecycle.js)
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration.
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart; `index.js` wires it to `messageStreamRuntime.rebindUpstream()` so event-stream readers rebind to the possibly-new port (a restart can land on a new port while an orphaned process keeps the old one, which would otherwise leave the chat UI silent — issue #2638).
- Returned API:
- `startOpenCode()`
- `restartOpenCode()`
@@ -356,6 +356,9 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
- `GET /api/openchamber/update-check`
- `POST /api/openchamber/update-install`
- Foreground servers running under a systemd user unit queue installation in
a separate transient unit and restart the configured service afterwards.
`OPENCHAMBER_SYSTEMD_UNIT` overrides the default `openchamber.service`.
- `GET /api/openchamber/models-metadata`
- `GET /api/zen/models`
@@ -388,6 +391,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- SSE forwarders: `GET /api/global/event`, `GET /api/event`
- Downstream heartbeats keep clients and intermediaries alive, while a separate upstream-only stall watchdog closes the downstream response when OpenCode stops producing bytes so clients reconnect instead of trusting synthetic heartbeats indefinitely. Each watchdog reset uses the current load-aware timeout, matching the shared event transport.
- Session message forwarder: `POST /api/session/:sessionId/message`
- Interactive OAuth forwarder: `POST /api/provider/:providerID/oauth/callback`
- Upstream blocks inside this call for the whole browser sign-in (device-code polling or a loopback redirect), so it is exempt from the ordinary request deadline and uses a 15-minute proxy timeout instead of `LONG_REQUEST_TIMEOUT_MS`. All other `/api/provider/*` routes, including `oauth/authorize`, keep the ordinary deadline.
- Generic `/api/*` forwarding with hop-by-hop header filtering
- Windows `/session` merge fallback path behavior
- OpenCode readiness gate for proxied `/api` requests
+4
View File
@@ -19,6 +19,8 @@ export const createBootstrapRuntime = (dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
getServerPort,
getTunnelUrl,
verboseRequestLogs,
uiPassword,
tunnelAuthController,
@@ -81,6 +83,8 @@ export const createBootstrapRuntime = (dependencies) => {
gracefulShutdown,
getHealthSnapshot,
getServerId,
getServerPort,
getTunnelUrl,
tunnelAuthController,
uiAuthController,
});
@@ -69,6 +69,12 @@ export const registerServerStatusRoutes = (app, dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
// Port this OpenChamber instance serves on and the tunnel public URL (if
// a tunnel is active). Exposed on /api/system/info so the UI can surface
// the active instance's service URLs. Optional: older wiring omits them
// and the endpoint reports null.
getServerPort = () => null,
getTunnelUrl = () => null,
// Stable server identity (hash of the public signing key — not a secret).
// Exposed on /health and /api/version so a client can verify that a
// learned/probed address belongs to the expected server BEFORE sending its
@@ -358,6 +364,8 @@ export const registerServerStatusRoutes = (app, dependencies) => {
runtime: runtimeName,
pid: process.pid,
startedAt: serverStartedAt,
port: getServerPort(),
tunnelUrl: getTunnelUrl(),
});
});
@@ -791,4 +791,46 @@ describe('client auth routes', () => {
socket: { remoteAddress: '203.0.113.10' },
})).toBe('unknown-public');
});
it('reports null port and tunnel URL on /api/system/info when no getters are wired', async () => {
const app = express();
registerServerStatusRoutes(app, {
process,
serverStartedAt: '2026-01-01T00:00:00.000Z',
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
});
const response = await request(app).get('/api/system/info');
expect(response.status).toBe(200);
expect(response.body.openchamberVersion).toBe('1.0.0');
expect(response.body.runtime).toBe('test');
expect(response.body.pid).toBeTypeOf('number');
expect(response.body.startedAt).toBeTypeOf('string');
expect(response.body.port).toBeNull();
expect(response.body.tunnelUrl).toBeNull();
});
it('reports the instance port and tunnel URL on /api/system/info from the wired getters', async () => {
const app = express();
registerServerStatusRoutes(app, {
process,
serverStartedAt: '2026-01-01T00:00:00.000Z',
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
getServerPort: () => 9988,
getTunnelUrl: () => 'https://worktree-a.example.trycloudflare.com',
});
const response = await request(app).get('/api/system/info');
expect(response.status).toBe(200);
expect(response.body.port).toBe(9988);
expect(response.body.tunnelUrl).toBe('https://worktree-a.example.trycloudflare.com');
});
});
@@ -1,3 +1,26 @@
import { isIP } from 'node:net';
const MAX_HOSTNAME_LENGTH = 253;
const HOSTNAME_LABEL_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
// All-numeric dotted values must be a real IPv4 address; otherwise typo'd IPs
// like "0.0.0.0.0" would slip through as (technically valid) hostnames.
const ALL_NUMERIC_DOTTED_RE = /^\d+(?:\.\d+)*$/;
// Valid bind hostnames for the managed OpenCode server: IPv4, IPv6 (with or
// without brackets), or a DNS-style hostname. Everything else (URLs, ports,
// paths, whitespace, underscores) is rejected.
export const isValidOpenCodeHostname = (value) => {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_HOSTNAME_LENGTH) return false;
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return isIP(trimmed.slice(1, -1)) === 6;
}
if (isIP(trimmed) !== 0) return true;
if (ALL_NUMERIC_DOTTED_RE.test(trimmed)) return false;
return trimmed.split('.').every((label) => HOSTNAME_LABEL_RE.test(label));
};
export const resolveOpenCodeEnvConfig = (options = {}) => {
const env = options.env && typeof options.env === 'object' ? options.env : {};
const logger = options.logger ?? console;
@@ -60,6 +83,14 @@ export const resolveOpenCodeEnvConfig = (options = {}) => {
);
return '127.0.0.1';
}
if (!isValidOpenCodeHostname(trimmed)) {
logger.error(
`[config] Rejecting OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: `
+ 'must be a valid hostname or IP address (for example 127.0.0.1, 0.0.0.0, localhost, [::1]); '
+ 'falling back to 127.0.0.1 (loopback only)',
);
return '127.0.0.1';
}
return trimmed;
})();
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest';
import { isValidOpenCodeHostname, resolveOpenCodeEnvConfig } from './env-config.js';
describe('isValidOpenCodeHostname', () => {
it('accepts IPv4 addresses', () => {
expect(isValidOpenCodeHostname('127.0.0.1')).toBe(true);
expect(isValidOpenCodeHostname('0.0.0.0')).toBe(true);
expect(isValidOpenCodeHostname('192.168.1.10')).toBe(true);
});
it('accepts IPv6 addresses with and without brackets', () => {
expect(isValidOpenCodeHostname('::1')).toBe(true);
expect(isValidOpenCodeHostname('[::1]')).toBe(true);
expect(isValidOpenCodeHostname('::')).toBe(true);
expect(isValidOpenCodeHostname('[::]')).toBe(true);
});
it('accepts DNS-style hostnames', () => {
expect(isValidOpenCodeHostname('localhost')).toBe(true);
expect(isValidOpenCodeHostname('tailscale-host')).toBe(true);
expect(isValidOpenCodeHostname('my.host.example')).toBe(true);
});
it('rejects malformed values', () => {
const invalid = [
'',
' ',
'http://localhost',
'https://host:4096',
'host:4096',
'host/path',
'bad host',
'bad_host',
'0.0.0.0.0',
'999.999.999.999',
'[::1',
'::1]',
'a'.repeat(254),
'1.2.3.4.5.6.7.8.9',
];
for (const value of invalid) {
expect(isValidOpenCodeHostname(value), JSON.stringify(value)).toBe(false);
}
});
it('rejects non-string values', () => {
expect(isValidOpenCodeHostname(undefined)).toBe(false);
expect(isValidOpenCodeHostname(null)).toBe(false);
expect(isValidOpenCodeHostname(42)).toBe(false);
});
});
describe('resolveOpenCodeEnvConfig hostname', () => {
it('defaults to loopback when the env var is absent', () => {
expect(resolveOpenCodeEnvConfig({ env: {} }).configuredOpenCodeHostname).toBe('127.0.0.1');
});
it('reads OPENCHAMBER_OPENCODE_HOSTNAME', () => {
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0' } });
expect(result.configuredOpenCodeHostname).toBe('0.0.0.0');
});
it('trims surrounding whitespace', () => {
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' tailscale-host ' } });
expect(result.configuredOpenCodeHostname).toBe('tailscale-host');
});
it('warns and falls back for an empty value', () => {
const logger = { warn: vi.fn(), error: vi.fn() };
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' ' }, logger });
expect(result.configuredOpenCodeHostname).toBe('127.0.0.1');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty after trimming'));
expect(logger.error).not.toHaveBeenCalled();
});
it('rejects invalid values with a clear error and falls back to loopback', () => {
const logger = { warn: vi.fn(), error: vi.fn() };
const result = resolveOpenCodeEnvConfig({
env: { OPENCHAMBER_OPENCODE_HOSTNAME: 'http://nope:4096' },
logger,
});
expect(result.configuredOpenCodeHostname).toBe('127.0.0.1');
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Rejecting OPENCHAMBER_OPENCODE_HOSTNAME'),
);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('127.0.0.1'));
});
it('keeps other env config intact when the hostname is validated', () => {
const result = resolveOpenCodeEnvConfig({
env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0', OPENCODE_PORT: '4096' },
});
expect(result.configuredOpenCodeHostname).toBe('0.0.0.0');
expect(result.configuredOpenCodePort).toBe(4096);
expect(result.effectivePort).toBe(4096);
});
});
@@ -49,6 +49,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
getActiveSessionCount = () => 0,
reapManagedOrphanedProcesses = reapOrphanedProcesses,
getWarmupDirectories = async () => [],
onOpenCodeRestarted = null,
now = Date.now,
} = deps;
@@ -695,6 +696,17 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
setupProxy(state.expressApp);
ensureOpenCodeApiPrefix();
}
// The restart may have landed on a NEW port (the old one can remain
// occupied by an orphaned process, e.g. Windows killProcessOnPort is a
// no-op). Upstream event readers pinned to the old process would keep
// the UI silent forever, so rebind them to the current port. Best
// effort: a failure here must not fail the restart itself.
try {
onOpenCodeRestarted?.();
} catch (error) {
console.warn('Failed to rebind event stream after OpenCode restart:', error?.message ?? error);
}
})();
try {
@@ -50,7 +50,7 @@ const createMockChild = () => {
return child;
};
const createRuntime = (overrides = {}, stateOverrides = {}) => {
const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) => {
const state = {
openCodeWorkingDirectory: '/tmp/project',
openCodeProcess: null,
@@ -83,6 +83,7 @@ const createRuntime = (overrides = {}, stateOverrides = {}) => {
ENV_EFFECTIVE_PORT: 3001,
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
ENV_SKIP_OPENCODE_START: false,
...envOverrides,
},
syncToHmrState: vi.fn(),
syncFromHmrState: vi.fn(),
@@ -286,6 +287,70 @@ describe('OpenCode lifecycle', () => {
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('calls onOpenCodeRestarted after a successful managed restart', async () => {
const close = vi.fn(async () => {});
const replacement = createMockChild();
const onOpenCodeRestarted = vi.fn();
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return replacement;
});
const runtime = createRuntime({ onOpenCodeRestarted }, {
openCodePort: 45678,
openCodeProcess: {
pid: null,
exitCode: 1,
signalCode: null,
close,
},
});
await runtime.triggerHealthCheck();
expect(close).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(1);
// The restart completed on a (possibly new) port — the event-stream
// upstreams must rebind so the UI keeps receiving events (#2638).
expect(onOpenCodeRestarted).toHaveBeenCalledTimes(1);
});
it('does not call onOpenCodeRestarted when a managed restart fails', async () => {
const close = vi.fn(async () => {});
const onOpenCodeRestarted = vi.fn();
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
spawnMock.mockImplementation(() => {
const child = createMockChild();
queueMicrotask(() => {
child.emit('error', new Error('spawn failed'));
});
return child;
});
const runtime = createRuntime({ onOpenCodeRestarted }, {
openCodePort: 45678,
openCodeProcess: {
pid: null,
exitCode: 1,
signalCode: null,
close,
},
});
// triggerHealthCheck logs instead of rethrowing; call restartOpenCode
// directly to observe the failure result.
await expect(runtime.restartOpenCode()).rejects.toThrow();
expect(onOpenCodeRestarted).not.toHaveBeenCalled();
});
it('launches managed OpenCode with the managed PATH', async () => {
delete process.env.OPENCODE_BINARY;
const child = createMockChild();
@@ -312,6 +377,27 @@ describe('OpenCode lifecycle', () => {
expect(server.signalCode).toBe('SIGTERM');
});
it('launches managed OpenCode on the configured bind hostname', async () => {
delete process.env.OPENCODE_BINARY;
const child = createMockChild();
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
child.stdout.emit('data', 'opencode server listening on http://0.0.0.0:45678\n');
});
return child;
});
const runtime = createRuntime({}, {}, { ENV_CONFIGURED_OPENCODE_HOSTNAME: '0.0.0.0' });
const server = await runtime.startOpenCode();
const [binary, args] = spawnMock.mock.calls[0];
expect(binary).toBe('opencode');
expect(args).toEqual(['serve', '--hostname', '0.0.0.0', '--port', '45678']);
await server.close();
expect(server.signalCode).toBe('SIGTERM');
});
it('strips AppImage ARGV0 from managed OpenCode launch env', async () => {
delete process.env.OPENCODE_BINARY;
const previousArgv0 = process.env.ARGV0;
@@ -1,3 +1,21 @@
const SYSTEMD_SERVICE_UNIT_PATTERN = /^[A-Za-z0-9:_.@-]+\.service$/;
function resolveSystemdServiceUnit(environment) {
if (!environment.INVOCATION_ID) {
return null;
}
const configuredUnit = typeof environment.OPENCHAMBER_SYSTEMD_UNIT === 'string'
? environment.OPENCHAMBER_SYSTEMD_UNIT.trim()
: '';
const unit = configuredUnit || 'openchamber.service';
return SYSTEMD_SERVICE_UNIT_PATTERN.test(unit) ? unit : null;
}
function quotePosixShell(value) {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}
export const registerOpenChamberRoutes = (app, dependencies) => {
const {
fs,
@@ -54,7 +72,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
app.post('/api/openchamber/update-install', async (_req, res) => {
try {
const { spawn: spawnChild } = await import('child_process');
const { spawn: spawnChild, spawnSync } = await import('child_process');
const {
checkForUpdates,
getUpdateCommand,
@@ -110,6 +128,55 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
}
const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon';
const isForegroundService = launchMode === 'foreground';
const systemdServiceUnit = isForegroundService ? resolveSystemdServiceUnit(process.env) : null;
if (isForegroundService) {
if (!systemdServiceUnit) {
return res.status(409).json({
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
});
}
const updateJobName = `openchamber-update-${Date.now()}`;
const updateLogPath = `journalctl --user-unit ${updateJobName}.service`;
const updateScript = [
'set -eu',
updateCmd,
`systemctl --user restart ${quotePosixShell(systemdServiceUnit)}`,
].join('\n');
const systemdRun = spawnSync('systemd-run', [
'--user',
`--unit=${updateJobName}`,
'--collect',
'--service-type=exec',
`--setenv=PATH=${process.env.PATH || ''}`,
'/bin/sh',
'-c',
updateScript,
], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
});
if (systemdRun.status !== 0) {
const detail = (systemdRun.stderr || systemdRun.stdout || '').trim();
return res.status(409).json({
error: detail || `Could not queue update job for ${systemdServiceUnit}`,
});
}
return res.json({
success: true,
message: 'Update queued; OpenChamber will restart after installation completes',
version: updateInfo.version,
packageManager: pm,
autoRestart: true,
restartManager: 'systemd',
jobId: updateJobName,
logPath: updateLogPath,
});
}
const isWindows = process.platform === 'win32';
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import path from 'node:path';
import request from 'supertest';
vi.mock('child_process', () => ({
spawn: vi.fn(),
spawnSync: vi.fn(),
}));
vi.mock('../package-manager.js', () => ({
checkForUpdates: vi.fn(),
getUpdateCommand: vi.fn(),
detectPackageManagerDetails: vi.fn(),
}));
const childProcess = await import('child_process');
const packageManager = await import('../package-manager.js');
const { registerOpenChamberRoutes } = await import('./openchamber-routes.js');
const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
const app = express();
const dependencies = {
fs: {
existsSync: vi.fn(() => false),
promises: {
readFile: vi.fn(async () => JSON.stringify({
launchMode: 'foreground',
port: 7897,
...storedOptions,
})),
},
},
path,
process: {
env: environment,
platform: 'linux',
execPath: '/usr/bin/node',
},
server: {
address: () => ({ port: 7897 }),
},
__dirname: '/opt/openchamber/server',
openchamberDataDir: '/tmp/openchamber',
modelsDevApiUrl: 'https://models.example.test',
modelsMetadataCacheTtl: 0,
readSettingsFromDiskMigrated: vi.fn(),
fetchFreeZenModels: vi.fn(),
getCachedZenModels: vi.fn(),
};
registerOpenChamberRoutes(app, dependencies);
return { app, dependencies };
};
beforeEach(() => {
packageManager.checkForUpdates.mockResolvedValue({
available: true,
version: '1.17.1',
});
packageManager.detectPackageManagerDetails.mockReturnValue({
packageManager: 'npm',
});
packageManager.getUpdateCommand.mockReturnValue('npm install -g @openchamber/web@latest');
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('OpenChamber foreground update route', () => {
it('rejects a foreground update when the server is not owned by systemd', async () => {
const { app } = createApp();
await request(app)
.post('/api/openchamber/update-install')
.expect(409, {
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
});
expect(childProcess.spawnSync).not.toHaveBeenCalled();
});
it('rejects an unsafe systemd unit override before starting an update job', async () => {
const { app } = createApp({
environment: {
INVOCATION_ID: 'systemd-invocation',
OPENCHAMBER_SYSTEMD_UNIT: 'openchamber.service; rm -rf /',
},
});
await request(app)
.post('/api/openchamber/update-install')
.expect(409, {
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
});
expect(childProcess.spawnSync).not.toHaveBeenCalled();
});
it('queues the install in a transient systemd unit and returns its job identifier', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
childProcess.spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
const { app } = createApp({
environment: {
INVOCATION_ID: 'systemd-invocation',
OPENCHAMBER_SYSTEMD_UNIT: 'openchamber@wsl.service',
PATH: '/home/syu/.npm-global/bin:/usr/bin:/bin',
},
});
await request(app)
.post('/api/openchamber/update-install')
.expect(200, {
success: true,
message: 'Update queued; OpenChamber will restart after installation completes',
version: '1.17.1',
packageManager: 'npm',
autoRestart: true,
restartManager: 'systemd',
jobId: 'openchamber-update-1700000000000',
logPath: 'journalctl --user-unit openchamber-update-1700000000000.service',
});
expect(childProcess.spawnSync).toHaveBeenCalledWith('systemd-run', [
'--user',
'--unit=openchamber-update-1700000000000',
'--collect',
'--service-type=exec',
'--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin',
'/bin/sh',
'-c',
"set -eu\nnpm install -g @openchamber/web@latest\nsystemctl --user restart 'openchamber@wsl.service'",
], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
});
});
});
+21 -3
View File
@@ -309,6 +309,16 @@ export const registerOpenCodeProxy = (app, deps) => {
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
// A provider OAuth callback blocks upstream for as long as the user takes to
// sign in in their browser (device-code polling, or a loopback redirect), so
// it cannot share the ordinary request deadline. Bounded by the shortest
// upstream expiry we know of — GitHub device codes last ~15 minutes.
const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000;
const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/;
const isInteractiveOAuthCallback = (req) =>
req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path);
const isProxyTimeoutError = (error) => {
const code = typeof error?.code === 'string' ? error.code : '';
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
@@ -327,6 +337,10 @@ export const registerOpenCodeProxy = (app, deps) => {
};
const applyProxyResponseDeadline = (req, res, next) => {
if (isInteractiveOAuthCallback(req)) {
return next();
}
const timeout = setTimeout(() => {
req[PROXY_TIMEOUT_MARKER] = true;
if (sendProxyErrorResponse(res, 504)) {
@@ -753,12 +767,12 @@ export const registerOpenCodeProxy = (app, deps) => {
});
// Generic proxy for non-SSE OpenCode API routes.
const apiProxy = createProxyMiddleware({
const createApiProxy = (timeoutMs) => createProxyMiddleware({
target: resolveProxyTarget(),
changeOrigin: true,
pathRewrite: { '^/api': '' },
timeout: PROXY_REQUEST_TIMEOUT_MS,
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
timeout: timeoutMs,
proxyTimeout: timeoutMs,
// Dynamic target — port can change after restart
router: () => resolveProxyTarget(),
on: {
@@ -805,6 +819,9 @@ export const registerOpenCodeProxy = (app, deps) => {
},
});
const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS);
const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS);
// Best-effort fallback for stale clients still sending symlink paths.
// Settings and project selection normalize at source; this cached async path
// avoids blocking the proxy hot path on every directory-scoped request.
@@ -821,5 +838,6 @@ export const registerOpenCodeProxy = (app, deps) => {
});
app.use('/api', applyProxyResponseDeadline);
app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy);
app.use('/api', apiProxy);
};
@@ -565,6 +565,9 @@ export const createSettingsHelpers = (dependencies) => {
result.userMessageRenderingMode = mode;
}
}
if (typeof candidate.collapsibleUserMessages === 'boolean') {
result.collapsibleUserMessages = candidate.collapsibleUserMessages;
}
if (typeof candidate.stickyUserHeader === 'boolean') {
result.stickyUserHeader = candidate.stickyUserHeader;
}
@@ -74,6 +74,14 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: 'true' })).toEqual({});
});
it('accepts only booleans for collapsible user messages', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: true })).toEqual({ collapsibleUserMessages: true });
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: false })).toEqual({ collapsibleUserMessages: false });
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: 'true' })).toEqual({});
});
it('accepts messageStreamTransport as a persisted shared setting', () => {
const helpers = createTestHelpers();
+37 -4
View File
@@ -49,9 +49,36 @@ function ensureDirs() {
// ============== MARKDOWN FILE OPERATIONS ==============
// Mirror of OpenCode's markdown frontmatter sanitizer (packages/opencode/src/
// config/markdown.ts): other coding agents accept unquoted colons in YAML
// values (e.g. `description: Build agent: creates builds`), which strict YAML
// rejects. Rewrite those values as block scalars and retry the parse, so files
// OpenCode accepts are parsed identically here.
function sanitizeFrontmatter(frontmatter) {
return frontmatter
.split(/\r?\n/)
.flatMap((line) => {
if (line.trim().startsWith('#') || line.trim() === '' || /^\s+/.test(line)) return [line];
const entry = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
if (!entry) return [line];
const value = entry[2].trim();
if (value === '' || value === '>' || value === '|' || value.startsWith('"') || value.startsWith("'")) return [line];
if (!value.includes(':')) return [line];
return [`${entry[1]}: |-`, ` ${value}`];
})
.join('\n');
}
function parseMdFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
const rawContent = fs.readFileSync(filePath, 'utf8');
// Strip a UTF-8 BOM so frontmatter is recognized regardless of the editor
// that saved the file.
const content = rawContent.charCodeAt(0) === 0xfeff ? rawContent.slice(1) : rawContent;
// The closing `---` may sit at end-of-file without a trailing newline.
// gray-matter (used by OpenCode) accepts that, so we must too: otherwise the
// whole file is treated as the prompt body and a later save rewrites the
// existing YAML block into the body, duplicating the frontmatter.
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, body: content.trim() };
@@ -61,8 +88,14 @@ function parseMdFile(filePath) {
try {
frontmatter = yaml.parse(match[1]) || {};
} catch (error) {
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
frontmatter = {};
// Lenient fallback for frontmatter that strict YAML rejects but OpenCode
// still accepts (unquoted colons in scalar values).
try {
frontmatter = yaml.parse(sanitizeFrontmatter(match[1])) || {};
} catch {
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
frontmatter = {};
}
}
const body = match[2].trim();
@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parseMdFile, writeMdFile } from './shared.js';
import { updateAgent } from './agents.js';
const FIXTURE_DIR = path.join(os.tmpdir(), `openchamber-shared-test-${process.pid}`);
const STANDARD_MD = [
'---',
'description: My build agent',
'model: anthropic/claude-sonnet-4',
'mode: primary',
'---',
'',
'This is the prompt body.',
'',
].join('\n');
const writeFixture = (name, content) => {
const filePath = path.join(FIXTURE_DIR, name);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
return filePath;
};
describe('parseMdFile', () => {
beforeEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
});
afterEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
it('parses standard YAML frontmatter', () => {
const file = writeFixture('standard.md', STANDARD_MD);
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter).toEqual({
description: 'My build agent',
model: 'anthropic/claude-sonnet-4',
mode: 'primary',
});
expect(body).toBe('This is the prompt body.');
});
it('parses frontmatter whose closing --- is at end-of-file without a trailing newline', () => {
// gray-matter (used by OpenCode) accepts this shape; OpenChamber must too,
// otherwise a later save duplicates the YAML block.
const file = writeFixture('eof-close.md', [
'---',
'description: My build agent',
'model: anthropic/claude-sonnet-4',
'---',
].join('\n'));
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter).toEqual({
description: 'My build agent',
model: 'anthropic/claude-sonnet-4',
});
expect(body).toBe('');
});
it('parses frontmatter with CRLF line endings', () => {
const file = writeFixture('crlf.md', STANDARD_MD.replace(/\n/g, '\r\n'));
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter.model).toBe('anthropic/claude-sonnet-4');
expect(body).toBe('This is the prompt body.');
});
it('parses frontmatter preceded by a UTF-8 BOM', () => {
const file = writeFixture('bom.md', `\uFEFF${STANDARD_MD}`);
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter.description).toBe('My build agent');
expect(body).toBe('This is the prompt body.');
});
it('falls back to lenient YAML for unquoted colons in values, matching OpenCode', () => {
const file = writeFixture('colon.md', [
'---',
'description: Build agent: creates builds',
'model: anthropic/claude-sonnet-4',
'---',
'',
'Body',
'',
].join('\n'));
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter).toEqual({
description: 'Build agent: creates builds',
model: 'anthropic/claude-sonnet-4',
});
expect(body).toBe('Body');
});
it('treats files without frontmatter as a plain body', () => {
const file = writeFixture('plain.md', 'Just a prompt body.');
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter).toEqual({});
expect(body).toBe('Just a prompt body.');
});
});
describe('writeMdFile', () => {
beforeEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
});
afterEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
it('round-trips a canonical single frontmatter block', () => {
const file = writeFixture('roundtrip.md', STANDARD_MD);
const parsed = parseMdFile(file);
parsed.frontmatter.model = 'openai/gpt-5';
writeMdFile(file, parsed.frontmatter, parsed.body);
const content = fs.readFileSync(file, 'utf8');
// Exactly one frontmatter block.
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
const reparsed = parseMdFile(file);
expect(reparsed.frontmatter).toEqual({
description: 'My build agent',
model: 'openai/gpt-5',
mode: 'primary',
});
expect(reparsed.body).toBe('This is the prompt body.');
});
});
describe('updateAgent frontmatter preservation', () => {
beforeEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
});
afterEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
it('updates the model in place without duplicating YAML for a file with EOF-closed frontmatter', () => {
// Repro of OPE-178: the file's closing --- sits at EOF (no trailing
// newline). OpenCode parses it; OpenChamber previously treated the whole
// file as the prompt body and prepended a second frontmatter block on save.
const projectDir = path.join(FIXTURE_DIR, 'project');
const agentPath = path.join(projectDir, '.opencode', 'agents', 'strateg.md');
writeFixture(path.join('project', '.opencode', 'agents', 'strateg.md'), [
'---',
'description: Strategy agent',
'model: anthropic/claude-sonnet-4',
'temperature: 0.7',
'---',
].join('\n'));
updateAgent('strateg', { model: 'openai/gpt-5' }, projectDir);
const content = fs.readFileSync(agentPath, 'utf8');
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
const parsed = parseMdFile(agentPath);
expect(parsed.frontmatter).toEqual({
description: 'Strategy agent',
model: 'openai/gpt-5',
temperature: 0.7,
});
expect(parsed.body).toBe('');
});
it('preserves unrelated frontmatter fields when saving one field', () => {
const projectDir = path.join(FIXTURE_DIR, 'project');
const agentPath = path.join(projectDir, '.opencode', 'agents', 'strateg.md');
writeFixture(path.join('project', '.opencode', 'agents', 'strateg.md'), [
'---',
'description: Strategy agent',
'mode: primary',
'temperature: 0.7',
'---',
'',
'Body of strateg.',
'',
].join('\n'));
updateAgent('strateg', { description: 'Updated strategy agent' }, projectDir);
const content = fs.readFileSync(agentPath, 'utf8');
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
const parsed = parseMdFile(agentPath);
expect(parsed.frontmatter).toEqual({
description: 'Updated strategy agent',
mode: 'primary',
temperature: 0.7,
});
expect(parsed.body).toBe('Body of strateg.');
});
});