Merge origin/main into deferred OpenCode restart branch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user