import { describe, expect, it } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { createServer } from 'http'; import net from 'net'; import { spawn } from 'child_process'; import { pathToFileURL } from 'url'; import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js'; import { requestJson } from './lib/cli-http.js'; import { requestControlAction } from './lib/cli-control.js'; import { inspectTunnelAttachability } from './lib/cli-lifecycle.js'; import { formatGoal } from './lib/commands-schedule.js'; import { buildSessionCreatePayload, buildSessionPromptPayload, formatSessionLine, sessionCommand, } from './lib/commands-session.js'; import { formatModelsOutput } from './lib/commands-models.js'; import { formatProjectLine } from './lib/commands-projects.js'; import { resolveTargetPort } from './lib/cli-api-target.js'; import { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES } from './lib/cli-tunnel-capabilities.js'; import { TUNNEL_PROVIDER_CLOUDFLARE, TUNNEL_PROVIDER_NGROK, } from '../server/lib/tunnels/types.js'; import { assertAuthenticatedNetworkExposure, commands, discoverOpenChamberInstanceOnPort, discoverLifecycleInstances, discoverRunningInstances, discoverUnconfirmedRegistryInstanceOnPort, ensureTunnelProfilesMigrated, generateUiPassword, getInstanceFilePath, getPidFilePath, isOpenchamberCmdline, isOpenchamberProcessRunning, parseArgs, resolveServeHost, resolveServeUiPassword, } from './cli.js'; import { buildWindowsStartupTaskCommand } from './lib/cli-startup.js'; async function withTempOpenChamberDataDir(fn) { const previous = process.env.OPENCHAMBER_DATA_DIR; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cli-test-')); process.env.OPENCHAMBER_DATA_DIR = dir; try { return await fn(dir); } finally { if (typeof previous === 'string') { process.env.OPENCHAMBER_DATA_DIR = previous; } else { delete process.env.OPENCHAMBER_DATA_DIR; } fs.rmSync(dir, { recursive: true, force: true }); } } function createMockJsonResponse(body, ok = true) { return { ok, json: async () => body, }; } async function captureStdout(fn) { const originalWrite = process.stdout.write; let output = ''; process.stdout.write = (chunk, encoding, callback) => { output += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); if (typeof encoding === 'function') encoding(); if (typeof callback === 'function') callback(); return true; }; try { await fn(); return output; } finally { process.stdout.write = originalWrite; } } async function startMockOpenChamberServer(options = {}) { const runtime = options.runtime || 'web'; const pid = Number.isFinite(options.pid) ? options.pid : null; let shutdownRequested = false; let closed = false; const server = createServer((req, res) => { if (req.method === 'GET' && req.url === '/api/system/info') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ runtime, pid })); return; } if (req.method === 'POST' && req.url === '/api/system/shutdown') { shutdownRequested = true; res.writeHead(200, { 'content-type': 'application/json', connection: 'close' }); res.end(JSON.stringify({ ok: true })); try { server.close(() => { closed = true; }); } catch { closed = true; } return; } res.writeHead(404); res.end('not found'); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; return { port, get shutdownRequested() { return shutdownRequested; }, close: async () => { if (closed || !server.listening) return; await new Promise((resolve) => { try { server.close(() => { closed = true; resolve(); }); } catch { closed = true; resolve(); } }); }, }; } async function allocateLoopbackPort() { const server = net.createServer(); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; await new Promise((resolve) => server.close(resolve)); return port; } async function waitForTcpPort(port, timeoutMs = 3000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const connected = await new Promise((resolve) => { const socket = net.createConnection({ port, host: '127.0.0.1' }); socket.once('connect', () => { socket.destroy(); resolve(true); }); socket.once('error', () => { socket.destroy(); resolve(false); }); socket.setTimeout(250, () => { socket.destroy(); resolve(false); }); }); if (connected) return true; await new Promise((resolve) => setTimeout(resolve, 50)); } return false; } function spawnOpenChamberLikeIdleProcess() { return spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', 'openchamber-idle'], { stdio: 'ignore' }); } function spawnOpenChamberLikeHungServer(port) { const script = ` const net = require('net'); const sockets = new Set(); const server = net.createServer((socket) => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); }); server.listen(${port}, '127.0.0.1'); setInterval(() => {}, 1000); `; return spawn(process.execPath, ['-e', script, 'openchamber-hung-server'], { stdio: 'ignore' }); } describe('cli args', () => { it('loads fallback tunnel provider capabilities for CLI startup', () => { expect(DEFAULT_TUNNEL_PROVIDER_CAPABILITIES.map((provider) => provider.provider)).toEqual([ TUNNEL_PROVIDER_CLOUDFLARE, TUNNEL_PROVIDER_NGROK, ]); }); it('accepts legacy daemon flags as no-ops', () => { expect(parseArgs(['serve', '--daemon']).removedFlagErrors).toEqual([]); expect(parseArgs(['serve', '-d']).removedFlagErrors).toEqual([]); }); it('parses explicit connect-url server overrides', () => { const parsed = parseArgs(['connect-url', '--server', 'https://openchamber.example.com', '--port', '3002']); expect(parsed.command).toBe('connect-url'); expect(parsed.options.server).toBe('https://openchamber.example.com'); expect(parsed.options.port).toBe(3002); }); it('parses connect-url server-url alias', () => { const parsed = parseArgs(['connect-url', '--server-url=http://homebridge:3002']); expect(parsed.options.server).toBe('http://homebridge:3002'); }); it('parses connect-url --relay flag', () => { const parsed = parseArgs(['connect-url', '--relay', '--name', 'My laptop']); expect(parsed.command).toBe('connect-url'); expect(parsed.options.relay).toBe(true); expect(parsed.options.name).toBe('My laptop'); }); it('parses connect-url api-only help', () => { const parsed = parseArgs(['connect-url', '--api-only', '--help']); expect(parsed.command).toBe('connect-url'); expect(parsed.options.apiOnly).toBe(true); expect(parsed.helpRequested).toBe(true); }); it('parses startup api-only option', () => { const parsed = parseArgs(['startup', 'enable', '--api-only', '--port', '3002']); expect(parsed.command).toBe('startup'); expect(parsed.startupAction).toBe('enable'); expect(parsed.options.apiOnly).toBe(true); expect(parsed.options.port).toBe(3002); }); it('parses schedule commands and options', () => { const parsed = parseArgs([ 'schedule', 'create', '--project', 'proj_1', '--name', 'Daily review', '--prompt', 'Review the repo', '--model', 'openai/gpt-5.5', '--daily', '09:30', '--timezone', 'Europe/Kyiv', ]); expect(parsed.command).toBe('schedule'); expect(parsed.scheduleAction).toBe('create'); expect(parsed.options.project).toBe('proj_1'); expect(parsed.options.name).toBe('Daily review'); expect(parsed.options.prompt).toBe('Review the repo'); expect(parsed.options.model).toBe('openai/gpt-5.5'); expect(parsed.options.daily).toBe('09:30'); expect(parsed.options.timezone).toBe('Europe/Kyiv'); }); it('parses goal-enabled scheduled task options', () => { const parsed = parseArgs([ 'schedule', 'create', '--dir', '/repo', '--name', 'Finish migration', '--prompt', 'Complete and verify the migration', '--model', 'openai/gpt-5.5', '--daily', '09:30', '--goal', '--goal-token-budget', '200000', ]); expect(parsed.options.goal).toBe(true); expect(parsed.options.goalTokenBudget).toBe('200000'); }); it('formats scheduled goal state compactly', () => { expect(formatGoal({})).toBe('goal:no'); expect(formatGoal({ goalEnabled: true })).toBe('goal:yes'); expect(formatGoal({ goalEnabled: true, goalTokenBudget: 200000 })).toBe('goal:yes budget:200000'); }); it('parses session create options', () => { const parsed = parseArgs([ 'session', 'create', '--dir', '.', '--name', 'Side task', '--prompt', 'Investigate cache invalidation', '--model', 'openai/gpt-5.5', '--worktree', 'side-task', '--branch', 'openchamber/side-task', '--base', 'main', '--no-upstream', ]); expect(parsed.command).toBe('session'); expect(parsed.sessionAction).toBe('create'); expect(parsed.options.directory).toBe('.'); expect(parsed.options.name).toBe('Side task'); expect(parsed.options.prompt).toBe('Investigate cache invalidation'); expect(parsed.options.model).toBe('openai/gpt-5.5'); expect(parsed.options.worktree).toBe('side-task'); expect(parsed.options.branch).toBe('openchamber/side-task'); expect(parsed.options.startRef).toBe('main'); expect(parsed.options.setUpstream).toBe(false); }); it('parses control help command', () => { const parsed = parseArgs(['control', 'help']); expect(parsed.command).toBe('control'); expect(parsed.controlAction).toBe('help'); }); it('builds session create payloads from CLI options', () => { expect(buildSessionCreatePayload({ directory: '.', name: 'Side task', prompt: 'Investigate cache invalidation', model: 'openai/gpt-5.5', agent: 'build', worktree: 'side-task', branch: 'openchamber/side-task', startRef: 'main', setUpstream: true, })).toEqual({ directory: '.', title: 'Side task', worktree: { name: 'side-task', branchName: 'openchamber/side-task', startRef: 'main', }, prompt: 'Investigate cache invalidation', model: 'openai/gpt-5.5', agent: 'build', setUpstream: true, }); }); it('allows session create prompts without an explicit model', () => { expect(buildSessionCreatePayload({ directory: '.', prompt: 'Investigate cache invalidation', })).toEqual({ directory: '.', prompt: 'Investigate cache invalidation', }); }); it('builds goal-enabled session create payloads', () => { const parsed = parseArgs([ 'session', 'create', '--dir', '/repo', '--prompt', 'Finish and verify the migration', '--goal', '--goal-token-budget', '200000', ]); expect(buildSessionCreatePayload(parsed.options)).toEqual({ directory: '/repo', prompt: 'Finish and verify the migration', goal: true, goalTokenBudget: 200000, }); }); it('validates session goal options before HTTP', () => { expect(() => buildSessionCreatePayload({ directory: '/repo', goal: true })).toThrow('--goal requires --prompt.'); expect(() => buildSessionCreatePayload({ directory: '/repo', prompt: 'Run', goalTokenBudget: '200000', })).toThrow('--goal-token-budget requires --goal.'); for (const value of ['999', '1.5', '100000001', 'nope']) { expect(() => buildSessionCreatePayload({ directory: '/repo', prompt: 'Run', goal: true, goalTokenBudget: value, })).toThrow('--goal-token-budget must be an integer from 1000 to 100000000.'); } }); it('parses session list filters', () => { const parsed = parseArgs(['session', 'list', '--dir', '/repo', '--limit', '5']); expect(parsed.command).toBe('session'); expect(parsed.sessionAction).toBe('list'); expect(parsed.options.directory).toBe('/repo'); expect(parsed.options.limit).toBe(5); }); it('parses session status and message options', () => { const status = parseArgs(['session', 'status', '--session', 'ses_123', '--dir', '/repo']); expect(status.sessionAction).toBe('status'); expect(status.options.session).toBe('ses_123'); expect(status.options.directory).toBe('/repo'); const messages = parseArgs([ 'session', 'messages', '--session', 'ses_123', '--dir', '/repo', '--last', '--role', 'assistant', ]); expect(messages.sessionAction).toBe('messages'); expect(messages.options.last).toBe(true); expect(messages.options.role).toBe('assistant'); const waiting = parseArgs([ 'session', 'messages', '--session', 'ses_123', '--dir', '/repo', '--wait', '--timeout', '30', '--last-assistant', ]); expect(waiting.options.wait).toBe(true); expect(waiting.options.timeout).toBe('30'); expect(waiting.options.lastAssistant).toBe(true); const list = parseArgs(['session', 'list', '--dir', '/repo', '--with-status']); expect(list.options.withStatus).toBe(true); }); it('parses session send and fork actions', () => { const send = parseArgs([ 'session', 'send', '--session', 'ses_123', '--dir', '/repo', '--prompt', 'Continue', '--goal', '--wait', '--last-assistant', ]); expect(send.sessionAction).toBe('send'); expect(send.options).toMatchObject({ session: 'ses_123', directory: '/repo', prompt: 'Continue', goal: true, wait: true, lastAssistant: true, }); const fork = parseArgs([ 'session', 'fork', '--session', 'ses_123', '--dir', '/repo', '--message', 'msg_123', '--prompt', 'Try another approach', ]); expect(fork.sessionAction).toBe('fork'); expect(fork.options.message).toBe('msg_123'); }); it('builds session send and fork prompt payloads', () => { expect(buildSessionPromptPayload({ session: 'ses_123', directory: '/repo', prompt: 'Continue', model: 'openai/gpt-5.5', agent: 'build', goal: true, goalTokenBudget: '200000', }, 'send')).toEqual({ directory: '/repo', prompt: 'Continue', model: 'openai/gpt-5.5', agent: 'build', goal: true, goalTokenBudget: 200000, }); expect(buildSessionPromptPayload({ session: 'ses_123', directory: '/repo', message: 'msg_123', prompt: 'Try another approach', }, 'fork')).toEqual({ directory: '/repo', messageId: 'msg_123', prompt: 'Try another approach', }); }); it('validates session message selectors before HTTP', async () => { await expect(sessionCommand({ session: 'ses_123', directory: '/repo', all: true, last: true }, 'messages')) .rejects.toThrow('--all cannot be combined with --last or --limit.'); await expect(sessionCommand({ session: 'ses_123', directory: '/repo', role: 'tool' }, 'messages')) .rejects.toThrow('--role must be one of: all, user, assistant.'); await expect(sessionCommand({ session: 'ses_123' }, 'status')) .rejects.toThrow('Missing required --dir.'); await expect(sessionCommand({ session: 'ses_123', directory: '/repo', timeout: '30' }, 'messages')) .rejects.toThrow('--timeout requires --wait.'); await expect(sessionCommand({ directory: '/repo', lastAssistant: true }, 'create')) .rejects.toThrow('--last-assistant requires --wait for session create.'); await expect(sessionCommand({ directory: '/repo', timeout: '30' }, 'create')) .rejects.toThrow('--timeout requires --wait.'); await expect(sessionCommand({ session: 'ses_123', directory: '/repo' }, 'send')) .rejects.toThrow('Missing required --prompt.'); await expect(sessionCommand({ session: 'ses_123', directory: '/repo', prompt: 'Run', message: 'msg_1' }, 'send')) .rejects.toThrow('--message is only valid for session fork.'); await expect(sessionCommand({ session: 'ses_123', directory: '/repo', prompt: 'Run', lastAssistant: true }, 'fork')) .rejects.toThrow('--last-assistant requires --wait for session fork.'); }); it('parses models command', () => { const parsed = parseArgs(['models', '--json']); expect(parsed.command).toBe('models'); expect(parsed.options.json).toBe(true); }); it('parses projects command', () => { const parsed = parseArgs(['projects', '--json']); expect(parsed.command).toBe('projects'); expect(parsed.options.json).toBe(true); }); it('formats projects compactly', () => { expect(formatProjectLine({ id: 'path_repo', label: 'Openchamber', path: '/repo/openchamber', })).toBe('- `Openchamber` — `path_repo` — `/repo/openchamber`'); }); it('formats model defaults and favorites compactly', () => { expect(formatModelsOutput({ defaultModel: 'opencode-go/deepseek-v4-flash', defaultAgent: 'build', favoriteModels: [ { providerID: 'openai', modelID: 'gpt-5.5' }, { providerID: 'opencode-go', modelID: 'deepseek-v4-pro' }, ], recentModels: [ { providerID: 'zai-coding-plan', modelID: 'glm-5.2' }, ], })).toBe('Default: `opencode-go/deepseek-v4-flash` / `build`\n\nFavorites:\n- `openai/gpt-5.5`\n- `opencode-go/deepseek-v4-pro`\n\nRecent:\n- `zai-coding-plan/glm-5.2`\n'); }); it('formats compact session list lines', () => { expect(formatSessionLine({ title: 'Default model smoke test', agent: 'build', directory: '/repo', model: { providerID: 'opencode-go', id: 'deepseek-v4-flash', variant: 'default' }, })).toBe('- `Default model smoke test` — `opencode-go/deepseek-v4-flash`, `build` — `/repo`'); expect(formatSessionLine({ title: 'Working session', agent: 'build', directory: '/repo', model: { providerID: 'openai', id: 'gpt-5.4-mini' }, status: { type: 'busy' }, })).toContain('status:busy'); }); it('parses tunnel auto-start server options', () => { const parsed = parseArgs(['tunnel', 'start', '--port', '3002', '--api-only', '--lan', '--ui-password', 'secret']); expect(parsed.command).toBe('tunnel'); expect(parsed.subcommand).toBe('start'); expect(parsed.options.port).toBe(3002); expect(parsed.options.apiOnly).toBe(true); expect(parsed.options.host).toBe('0.0.0.0'); expect(parsed.options.uiPassword).toBe('secret'); }); it('maps --lan to wildcard bind host', () => { const parsed = parseArgs(['serve', '--lan', '--port', '3002']); expect(parsed.options.host).toBe('0.0.0.0'); expect(parsed.options.lan).toBe(true); }); it('supports --hostname as top-level bind alias', () => { const parsed = parseArgs(['serve', '--hostname', '0.0.0.0']); expect(parsed.options.host).toBe('0.0.0.0'); }); it('keeps --hostname for tunnel commands', () => { const parsed = parseArgs(['tunnel', 'start', '--hostname', 'app.example.com']); expect(parsed.options.hostname).toBe('app.example.com'); expect(parsed.options.host).toBeUndefined(); }); }); describe('cli API target resolution', () => { it('uses an explicit port without discovery', async () => { await expect(resolveTargetPort( { explicitPort: true, port: 4567 }, { discoverDesktopInstance: async () => { throw new Error('should not discover desktop'); }, discoverLifecycleInstances: async () => { throw new Error('should not discover lifecycle'); }, }, )).resolves.toBe(4567); }); it('prefers a desktop instance when no port is explicit', async () => { await expect(resolveTargetPort({}, { discoverDesktopInstance: async () => ({ port: 4500 }), discoverLifecycleInstances: async () => [{ port: 3001 }], isServerHealthReady: async () => false, })).resolves.toBe(4500); }); it('uses the only discovered lifecycle instance', async () => { await expect(resolveTargetPort({}, { discoverDesktopInstance: async () => null, discoverLifecycleInstances: async () => [{ port: 3002 }], isServerHealthReady: async () => false, })).resolves.toBe(3002); }); it('uses healthy default port when discovery finds no instances', async () => { await expect(resolveTargetPort({}, { discoverDesktopInstance: async () => null, discoverLifecycleInstances: async () => [], isServerHealthReady: async (port) => port === 3000, })).resolves.toBe(3000); }); it('fails when multiple non-default instances are running', async () => { await expect(resolveTargetPort({}, { discoverDesktopInstance: async () => null, discoverLifecycleInstances: async () => [{ port: 3001 }, { port: 3002 }], isServerHealthReady: async () => false, })).rejects.toThrow('Multiple OpenChamber instances are running'); }); }); describe('network-exposed auth validation', () => { it('allows loopback without a UI password', () => { expect(() => assertAuthenticatedNetworkExposure({ host: '127.0.0.1' })).not.toThrow(); expect(() => assertAuthenticatedNetworkExposure({ host: 'localhost' })).not.toThrow(); expect(() => assertAuthenticatedNetworkExposure({ host: '::1' })).not.toThrow(); }); it('requires a UI password for LAN and wildcard bind hosts', () => { expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0' })).toThrow(/refuses to bind/); expect(() => assertAuthenticatedNetworkExposure({ host: '192.168.1.10' })).toThrow(/refuses to bind/); }); it('allows network-exposed bind hosts with a UI password', () => { expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0', uiPassword: 'secret' })).not.toThrow(); }); it('allows explicit unsafe LAN override from process env only', () => { const previous = process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN; process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN = 'true'; try { expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0' })).not.toThrow(); } finally { if (typeof previous === 'string') { process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN = previous; } else { delete process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN; } } }); }); describe('serve UI password resolution', () => { it('keeps a configured password untouched', () => { expect(resolveServeUiPassword({ uiPassword: 'secret', explicitUiPassword: true })) .toEqual({ password: 'secret', generated: false }); }); it('generates a password for an explicit --ui-password flag without a value', () => { const resolved = resolveServeUiPassword({ uiPassword: '', explicitUiPassword: true }); expect(resolved.generated).toBe(true); expect(typeof resolved.password).toBe('string'); expect(resolved.password.length).toBe(16); }); it('does not generate a password when the flag is absent', () => { expect(resolveServeUiPassword({ uiPassword: undefined, explicitUiPassword: false })) .toEqual({ password: undefined, generated: false }); }); it('generates passwords from an ambiguity-free charset', () => { const resolved = resolveServeUiPassword({ uiPassword: '', explicitUiPassword: true }); expect(resolved.password).toMatch(/^[A-HJ-NP-Za-km-z2-9]{16}$/); expect(resolved.password).not.toMatch(/[0O1Il]/); }); it('generates distinct passwords on repeated calls', () => { const a = generateUiPassword(); const b = generateUiPassword(); expect(a).not.toBe(b); }); it('parses --ui-password without a value as explicit but empty', () => { const parsed = parseArgs(['serve', '--ui-password']); expect(parsed.options.explicitUiPassword).toBe(true); expect(parsed.options.uiPassword).toBe(''); }); }); describe('serve host resolution', () => { it('uses OPENCHAMBER_HOST when --host is not provided', () => { const previous = process.env.OPENCHAMBER_HOST; process.env.OPENCHAMBER_HOST = '192.0.2.20'; try { expect(resolveServeHost(undefined)).toBe('192.0.2.20'); } finally { if (typeof previous === 'string') { process.env.OPENCHAMBER_HOST = previous; } else { delete process.env.OPENCHAMBER_HOST; } } }); it('prefers explicit --host over OPENCHAMBER_HOST', () => { const previous = process.env.OPENCHAMBER_HOST; process.env.OPENCHAMBER_HOST = '192.0.2.20'; try { expect(resolveServeHost('192.0.2.21')).toBe('192.0.2.21'); } finally { if (typeof previous === 'string') { process.env.OPENCHAMBER_HOST = previous; } else { delete process.env.OPENCHAMBER_HOST; } } }); }); describe('compatibility exports', () => { it('allows tunnel profile migration before command options are initialized', async () => { await withTempOpenChamberDataDir(async () => { const store = ensureTunnelProfilesMigrated(); expect(store).toEqual({ version: 1, profiles: [] }); }); }); it('includes ngrok in fallback tunnel providers when no server is reachable', async () => { await withTempOpenChamberDataDir(async () => { const port = await allocateLoopbackPort(); const output = await captureStdout(async () => { await commands.tunnel({ json: true, explicitPort: true, port }, 'providers'); }); const body = JSON.parse(output); expect(body.source).toBe('fallback'); expect(body.providers.map((entry) => entry.provider)).toContain('ngrok'); }); }); it('supports ngrok quick dry-run with an explicit port', async () => { await withTempOpenChamberDataDir(async () => { const output = await captureStdout(async () => { await commands.tunnel({ json: true, dryRun: true, explicitPort: true, port: 3003, provider: 'ngrok', mode: 'quick', }, 'start'); }); const body = JSON.parse(output); expect(body).toEqual(expect.objectContaining({ ok: true, dryRun: true, provider: 'ngrok', mode: 'quick', })); }); }); }); describe('CLI HTTP helpers', () => { it('sends one typed request to the shared control endpoint', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async (url, options = {}) => { expect(new URL(String(url)).pathname).toBe('/api/openchamber/control'); expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ action: 'session.status', input: { sessionId: 'ses_1', directory: '/repo' }, }); return createMockJsonResponse({ status: 'ok', sessionStatus: { type: 'idle' } }); }; try { await expect(requestControlAction(45677, 'session.status', { sessionId: 'ses_1', directory: '/repo', })).resolves.toEqual({ status: 'ok', sessionStatus: { type: 'idle' } }); } finally { globalThis.fetch = originalFetch; } }); it('retries UI-authenticated API requests with the stored instance password', async () => { await withTempOpenChamberDataDir(async () => { const port = 45678; fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, uiPassword: 'secret' }, null, 2)); const originalFetch = globalThis.fetch; const calls = []; globalThis.fetch = async (url, options = {}) => { calls.push({ url: String(url), options }); if (String(url).endsWith('/auth/session')) { expect(JSON.parse(options.body)).toEqual({ password: 'secret' }); return { ok: true, headers: { get: (name) => name.toLowerCase() === 'set-cookie' ? 'oc_ui_session=session-token; Path=/; HttpOnly' : null }, json: async () => ({ authenticated: true }), }; } if (options.headers?.Cookie === 'oc_ui_session=session-token') { return createMockJsonResponse({ ok: true }); } return { ok: false, status: 401, json: async () => ({ error: 'UI authentication required', locked: true }), }; }; try { const { response, body } = await requestJson(port, '/api/openchamber/tunnel/start', { method: 'POST', body: JSON.stringify({ provider: 'ngrok', mode: 'quick' }), }); expect(response.ok).toBe(true); expect(body).toEqual({ ok: true }); expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ '/api/openchamber/tunnel/start', '/auth/session', '/api/openchamber/tunnel/start', ]); } finally { globalThis.fetch = originalFetch; } }); }); it('prefers the stored instance password over a non-explicit env password', async () => { await withTempOpenChamberDataDir(async () => { const port = 45679; fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, uiPassword: 'stored-secret' }, null, 2)); const originalFetch = globalThis.fetch; globalThis.fetch = async (url, options = {}) => { if (String(url).endsWith('/auth/session')) { expect(JSON.parse(options.body)).toEqual({ password: 'stored-secret' }); return { ok: true, headers: { getSetCookie: () => ['oc_ui_session=session-token; Path=/; HttpOnly'] }, json: async () => ({ authenticated: true }), }; } if (options.headers?.Cookie === 'oc_ui_session=session-token') { return createMockJsonResponse({ ok: true }); } return { ok: false, status: 401, json: async () => ({ error: 'UI authentication required', locked: true }), }; }; try { const { response, body } = await requestJson(port, '/api/openchamber/scheduled-tasks/status', { uiPassword: 'stale-env-secret', explicitUiPassword: false, }); expect(response.ok).toBe(true); expect(body).toEqual({ ok: true }); } finally { globalThis.fetch = originalFetch; } }); }); it('authenticates desktop-local API requests with the stored client token', async () => { await withTempOpenChamberDataDir(async (dir) => { const port = 57123; fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ desktopLocalPort: port, desktopLocalClientToken: 'oc_client_test', }, null, 2)); const originalFetch = globalThis.fetch; globalThis.fetch = async (_url, options = {}) => { if (options.headers?.Authorization === 'Bearer oc_client_test') { return createMockJsonResponse({ ok: true }); } return { ok: false, status: 401, json: async () => ({ error: 'Client authentication required', locked: true }), }; }; try { const { response, body } = await requestJson(port, '/api/openchamber/scheduled-tasks/status'); expect(response.ok).toBe(true); expect(body).toEqual({ ok: true }); } finally { globalThis.fetch = originalFetch; } }); }); }); describe('cli entry detection', () => { const modulePath = '/tmp/openchamber/bin/cli.js'; const moduleUrl = pathToFileURL(modulePath).href; it('resolves symlinked entry paths before comparing', () => { const symlinkPath = '/usr/local/bin/openchamber'; const realpath = (filePath) => { if (filePath === path.resolve(symlinkPath)) { return modulePath; } return filePath; }; expect(isModuleCliExecution(symlinkPath, moduleUrl, realpath)).toBe(true); }); it('falls back to resolved paths when realpath fails', () => { const realpath = () => { throw new Error('realpath unavailable'); }; expect(isModuleCliExecution(modulePath, moduleUrl, realpath)).toBe(true); }); it('returns false for non-matching entry path', () => { expect(isModuleCliExecution('/tmp/other-cli.js', moduleUrl)).toBe(false); }); it('returns false for empty entry path', () => { expect(isModuleCliExecution('', moduleUrl)).toBe(false); }); it('returns false when module url is not provided', () => { expect(isModuleCliExecution(modulePath)).toBe(false); }); it('accepts wrapper binary name fallback when requested', () => { const wrapperPath = '/home/user/.local/bin/openchamber'; expect(isModuleCliExecution(wrapperPath, moduleUrl, undefined, 'openchamber')).toBe(true); }); it('normalizes direct paths when realpath fails', () => { const unresolvedPath = './packages/web/bin/cli.js'; const realpath = () => { throw new Error('no symlink resolution'); }; expect(normalizeCliEntryPath(unresolvedPath, realpath)).toBe(path.resolve(unresolvedPath)); }); }); describe('isOpenchamberCmdline', () => { it('accepts OpenChamber CLI and daemon cmdlines', () => { expect(isOpenchamberCmdline('node /x/@openchamber/web/bin/cli.js serve')).toBe(true); expect(isOpenchamberCmdline('node /x/@openchamber/web/server/index.js --port 9090')).toBe(true); expect(isOpenchamberCmdline('bun /home/u/projects/openchamber/packages/web/server/index.js --port 3001')).toBe(true); }); it('rejects recycled and unrelated processes (issue #1721)', () => { expect(isOpenchamberCmdline('node /home/herjarsa/npm-global/bin/agentmemory')).toBe(false); expect(isOpenchamberCmdline('node /usr/lib/node_modules/npm/bin/npm-cli.js install')).toBe(false); expect(isOpenchamberCmdline('')).toBe(false); expect(isOpenchamberCmdline(null)).toBe(false); }); }); describe('isOpenchamberProcessRunning', () => { it('returns false for a dead PID', () => { expect(isOpenchamberProcessRunning(2147483646)).toBe(false); }); // Identity verification is available on Linux (/proc) and macOS (ps); on those // platforms a live but unrelated process (a recycled stale PID) must read as // not-running so it can't trip the "already running" guard (issue #1721). it.skipIf(process.platform !== 'linux' && process.platform !== 'darwin')( 'returns false for a live non-OpenChamber PID', async () => { const child = spawn('sleep', ['30'], { stdio: 'ignore' }); try { await new Promise((resolve) => setTimeout(resolve, 150)); expect(isOpenchamberProcessRunning(child.pid)).toBe(false); } finally { child.kill('SIGKILL'); } } ); }); describe('lifecycle instance discovery', () => { it('does not attribute a desktop runtime response to a different explicit port', async () => { await withTempOpenChamberDataDir(async (dir) => { fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ desktopLocalPort: 57123 }, null, 2)); const instance = await discoverOpenChamberInstanceOnPort(3003, { fetchImpl: async () => createMockJsonResponse({ runtime: 'desktop', pid: 934 }), }); expect(instance).toBeNull(); }); }); it('attributes a desktop runtime response to its configured desktop port', async () => { await withTempOpenChamberDataDir(async (dir) => { fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ desktopLocalPort: 57123 }, null, 2)); const instance = await discoverOpenChamberInstanceOnPort(57123, { fetchImpl: async () => createMockJsonResponse({ runtime: 'desktop', pid: 934 }), }); expect(instance).toEqual(expect.objectContaining({ port: 57123, pid: 934, runtime: 'desktop', })); }); }); it('does not mark tunnel attachability as desktop for a different explicit port', async () => { await withTempOpenChamberDataDir(async (dir) => { fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ desktopLocalPort: 57123 }, null, 2)); const originalFetch = globalThis.fetch; globalThis.fetch = async () => createMockJsonResponse({ runtime: 'desktop', pid: 934 }); try { const attachability = await inspectTunnelAttachability(3004, { requireHealthy: false }); expect(attachability.reason).not.toBe('desktop'); } finally { globalThis.fetch = originalFetch; } }); }); it('keeps pid and instance files when live port probe confirms a cmdline mismatch', async () => { await withTempOpenChamberDataDir(async () => { const port = 45123; const pid = 12345; const pidFile = await getPidFilePath(port); const instanceFile = await getInstanceFilePath(port); fs.writeFileSync(pidFile, String(pid)); fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon', startedAt: 123 }, null, 2)); const instances = await discoverRunningInstances({ fetchImpl: async () => createMockJsonResponse({ runtime: 'web', pid }), getOpenchamberProcessState: () => 'mismatched', }); expect(instances).toEqual([ expect.objectContaining({ port, pid, runtime: 'web', source: 'registry+probe' }), ]); expect(fs.existsSync(pidFile)).toBe(true); expect(fs.existsSync(instanceFile)).toBe(true); }); }); it('removes stale pid and instance files when a cmdline mismatch is not confirmed by live probe', async () => { await withTempOpenChamberDataDir(async () => { const port = 45124; const pid = 12346; const pidFile = await getPidFilePath(port); const instanceFile = await getInstanceFilePath(port); fs.writeFileSync(pidFile, String(pid)); fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); const instances = await discoverRunningInstances({ fetchImpl: async () => createMockJsonResponse(null, false), getOpenchamberProcessState: () => 'mismatched', }); expect(instances).toEqual([]); expect(fs.existsSync(pidFile)).toBe(false); expect(fs.existsSync(instanceFile)).toBe(false); }); }); it('preserves matched pid and instance files when the recorded port probe is inconclusive', async () => { await withTempOpenChamberDataDir(async () => { const port = 45126; const pid = 12347; const pidFile = await getPidFilePath(port); const instanceFile = await getInstanceFilePath(port); fs.writeFileSync(pidFile, String(pid)); fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); const instances = await discoverRunningInstances({ fetchImpl: async () => createMockJsonResponse(null, false), getOpenchamberProcessState: () => 'matched', }); expect(instances).toEqual([]); expect(fs.existsSync(pidFile)).toBe(true); expect(fs.existsSync(instanceFile)).toBe(true); }); }); it('preserves unknown-identity pid and instance files when the recorded port probe is inconclusive', async () => { await withTempOpenChamberDataDir(async () => { const port = 45129; const pid = 12350; const pidFile = await getPidFilePath(port); const instanceFile = await getInstanceFilePath(port); fs.writeFileSync(pidFile, String(pid)); fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); const instances = await discoverRunningInstances({ fetchImpl: async () => createMockJsonResponse(null, false), getOpenchamberProcessState: () => 'unknown', }); expect(instances).toEqual([]); expect(fs.existsSync(pidFile)).toBe(true); expect(fs.existsSync(instanceFile)).toBe(true); }); }); it('uses the live system-info pid instead of a stale OpenChamber-looking pid-file pid', async () => { await withTempOpenChamberDataDir(async () => { const port = 45127; const stalePid = 12348; const livePid = 54321; const pidFile = await getPidFilePath(port); const instanceFile = await getInstanceFilePath(port); fs.writeFileSync(pidFile, String(stalePid)); fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); const instances = await discoverRunningInstances({ fetchImpl: async () => createMockJsonResponse({ runtime: 'web', pid: livePid }), getOpenchamberProcessState: () => 'matched', }); expect(instances).toEqual([ expect.objectContaining({ port, pid: livePid, runtime: 'web', source: 'registry+probe' }), ]); }); }); it('uses the explicit host when probing a pid-file entry without a stored host', async () => { await withTempOpenChamberDataDir(async () => { const port = 45128; const pid = 12349; const host = '192.0.2.10'; const urls = []; fs.writeFileSync(await getPidFilePath(port), String(pid)); fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); const instances = await discoverLifecycleInstances( { explicitPort: true, port, host }, { fetchImpl: async (url) => { urls.push(String(url)); return createMockJsonResponse({ runtime: 'web', pid }); }, getOpenchamberProcessState: () => 'matched', }, ); expect(instances).toEqual([ expect.objectContaining({ port, pid, runtime: 'web', source: 'registry+probe' }), ]); expect(new URL(urls[0]).hostname).toBe(host); }); }); it('tries loopback before treating an explicit-host pid-file probe as inconclusive', async () => { await withTempOpenChamberDataDir(async () => { const port = 45130; const pid = 12351; const host = '192.0.2.11'; const urls = []; fs.writeFileSync(await getPidFilePath(port), String(pid)); fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); const instances = await discoverLifecycleInstances( { explicitPort: true, port, host }, { fetchImpl: async (url) => { urls.push(String(url)); return new URL(String(url)).hostname === '127.0.0.1' ? createMockJsonResponse({ runtime: 'web', pid }) : createMockJsonResponse(null, false); }, getOpenchamberProcessState: () => 'matched', }, ); expect(urls.map((url) => new URL(url).hostname)).toContain(host); expect(urls.map((url) => new URL(url).hostname)).toContain('127.0.0.1'); expect(instances).toEqual([ expect.objectContaining({ port, pid, runtime: 'web', source: 'registry+probe' }), ]); }); }); it('does not accept a fallback loopback probe with a different pid for a concrete host registry', async () => { await withTempOpenChamberDataDir(async () => { const port = 45131; const pid = 12352; const otherPid = 54322; const host = '192.0.2.12'; const pidFile = await getPidFilePath(port); const instanceFile = await getInstanceFilePath(port); fs.writeFileSync(pidFile, String(pid)); fs.writeFileSync(instanceFile, JSON.stringify({ port, host, launchMode: 'daemon' }, null, 2)); const instances = await discoverLifecycleInstances( { explicitPort: true, port, host }, { fetchImpl: async (url) => { return new URL(String(url)).hostname === '127.0.0.1' ? createMockJsonResponse({ runtime: 'web', pid: otherPid }) : createMockJsonResponse(null, false); }, getOpenchamberProcessState: () => 'matched', }, ); expect(instances).toEqual([]); expect(fs.existsSync(pidFile)).toBe(true); expect(fs.existsSync(instanceFile)).toBe(true); }); }); it('discovers an explicit live OpenChamber port without a pid-file registry entry', async () => { await withTempOpenChamberDataDir(async () => { const port = 45125; const instances = await discoverLifecycleInstances( { explicitPort: true, port }, { fetchImpl: async () => createMockJsonResponse({ runtime: 'web', pid: null }) }, ); expect(instances).toEqual([ expect.objectContaining({ port, pid: null, runtime: 'web', source: 'probe' }), ]); }); }); it('cleans a matched pid-file entry without stopping it when the recorded port is free', async () => { await withTempOpenChamberDataDir(async () => { const port = await allocateLoopbackPort(); const child = spawnOpenChamberLikeIdleProcess(); const pidFile = await getPidFilePath(port); const instanceFile = await getInstanceFilePath(port); try { await new Promise((resolve) => setTimeout(resolve, 150)); fs.writeFileSync(pidFile, String(child.pid)); fs.writeFileSync(instanceFile, JSON.stringify({ port, host: '127.0.0.1', launchMode: 'daemon' }, null, 2)); const instance = await discoverUnconfirmedRegistryInstanceOnPort(port, { host: '127.0.0.1' }); expect(instance).toBeNull(); expect(fs.existsSync(pidFile)).toBe(false); expect(fs.existsSync(instanceFile)).toBe(false); expect(child.exitCode).toBeNull(); } finally { child.kill('SIGKILL'); } }); }); }); describe('lifecycle commands with unmanaged explicit ports', () => { it('serve refuses to start on a live OpenChamber port without requiring pid files', async () => { await withTempOpenChamberDataDir(async () => { const server = await startMockOpenChamberServer(); try { await expect(commands.serve({ explicitPort: true, port: server.port, quiet: true })).rejects.toThrow( /already running on port/ ); } finally { await server.close(); } }); }); it('status --port reports a live unmanaged server when the registry is empty', async () => { await withTempOpenChamberDataDir(async () => { const server = await startMockOpenChamberServer(); try { const output = await captureStdout(() => commands.status({ explicitPort: true, port: server.port, json: true })); const payload = JSON.parse(output); expect(payload.state).toBe('running'); expect(payload.runningCount).toBe(1); expect(payload.instances).toEqual([ expect.objectContaining({ runtime: 'unmanaged', port: server.port, pid: null }), ]); } finally { await server.close(); } }); }); it('stop --port reaches unmanaged shutdown when the registry is empty', async () => { await withTempOpenChamberDataDir(async () => { const server = await startMockOpenChamberServer(); try { await commands.stop({ explicitPort: true, port: server.port, quiet: true, suppressQuietOutput: true }); expect(server.shutdownRequested).toBe(true); } finally { await server.close(); } }); }); it('stop --port can recover a matched pid-file instance whose HTTP endpoint is unresponsive', async () => { await withTempOpenChamberDataDir(async () => { const port = await allocateLoopbackPort(); const child = spawnOpenChamberLikeHungServer(port); const pidFile = await getPidFilePath(port); const instanceFile = await getInstanceFilePath(port); try { expect(await waitForTcpPort(port)).toBe(true); fs.writeFileSync(pidFile, String(child.pid)); fs.writeFileSync(instanceFile, JSON.stringify({ port, host: '127.0.0.1', launchMode: 'daemon' }, null, 2)); await commands.stop({ explicitPort: true, port, host: '127.0.0.1', quiet: true, suppressQuietOutput: true }); expect(fs.existsSync(pidFile)).toBe(false); expect(fs.existsSync(instanceFile)).toBe(false); expect(child.exitCode !== null || child.signalCode !== null).toBe(true); } finally { child.kill('SIGKILL'); } }); }); it('plain stop ignores a stale CLI registry entry that resolves to desktop runtime', async () => { await withTempOpenChamberDataDir(async () => { const server = await startMockOpenChamberServer({ runtime: 'desktop' }); const child = spawn('sleep', ['30'], { stdio: 'ignore' }); const pidFile = await getPidFilePath(server.port); const instanceFile = await getInstanceFilePath(server.port); try { await new Promise((resolve) => setTimeout(resolve, 150)); fs.writeFileSync(pidFile, String(child.pid)); fs.writeFileSync(instanceFile, JSON.stringify({ port: server.port, launchMode: 'daemon' }, null, 2)); await commands.stop({ quiet: true, suppressQuietOutput: true }); expect(server.shutdownRequested).toBe(false); expect(fs.existsSync(pidFile)).toBe(false); expect(fs.existsSync(instanceFile)).toBe(false); } finally { child.kill('SIGKILL'); await server.close(); } }); }); it('restart --port restarts a live unmanaged server through the shared explicit-port discovery path', async () => { await withTempOpenChamberDataDir(async () => { const server = await startMockOpenChamberServer(); const calls = []; const host = '127.0.0.1'; try { const output = await captureStdout(() => commands.restart.call({ stop: async (options) => { calls.push(['stop', options.port, options.host]); }, serve: async (options) => { calls.push(['serve', options.port, options.host]); return options.port; }, }, { explicitPort: true, port: server.port, host, json: true })); const payload = JSON.parse(output); expect(calls).toEqual([ ['stop', server.port, host], ['serve', server.port, host], ]); expect(payload.restartedCount).toBe(1); expect(payload.results).toEqual([ expect.objectContaining({ fromPort: server.port, toPort: server.port, ok: true }), ]); } finally { await server.close(); } }); }); }); describe('Windows startup task command builder', () => { it('default-path length stays under 200 chars', () => { const cmd = buildWindowsStartupTaskCommand( 'C:\\Users\\test\\.config\\openchamber\\bin\\OpenChamber.ps1' ); expect(cmd).toMatch(/^powershell\.exe -NoProfile -ExecutionPolicy Bypass -File /); expect(cmd.length).toBeLessThan(200); }); it('worst-case long path stays under 261-char Task Scheduler ceiling', () => { // Build a wrapper path >= 180 chars (simulates long OPENCHAMBER_DATA_DIR) // Overhead = 57 chars (prefix + closing quote), so max wrapper for <261 total is 203 const longPath = 'C:\\Users\\' + 'a'.repeat(139) + '\\.config\\openchamber\\bin\\OpenChamber.ps1'; expect(longPath.length).toBeGreaterThanOrEqual(180); const cmd = buildWindowsStartupTaskCommand(longPath); expect(cmd.length).toBeLessThan(261); }); it('does NOT inline SetEnvironmentVariable (externalization invariant)', () => { const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1'); expect(cmd).not.toContain('SetEnvironmentVariable'); }); it('uses -File form, not -Command', () => { const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1'); expect(cmd).toContain('-File '); expect(cmd).not.toContain('-Command '); }); });