Merge branch 'main' into reproduce/issue-1720

Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
Mayuresh K
2026-07-01 18:15:18 +01:00
committed by GitHub
725 changed files with 34139 additions and 23393 deletions
+2 -7
View File
@@ -10,14 +10,13 @@ import {
intro,
outro,
log,
note,
box,
progress,
spinner,
confirm,
select,
text,
password,
spinner,
progress,
cancel,
isCancel,
} from '@clack/prompts';
@@ -122,17 +121,13 @@ export {
intro,
outro,
log,
note,
box,
progress,
spinner,
confirm,
select,
text,
password,
cancel,
isCancel,
isTTY,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
async function withInteractiveTty(fn) {
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true });
Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true });
try {
return await fn();
} finally {
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, 'isTTY', stdoutDescriptor);
} else {
delete process.stdout.isTTY;
}
if (stdinDescriptor) {
Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor);
} else {
delete process.stdin.isTTY;
}
}
}
describe('cli output', () => {
it('creates interactive clack spinner and progress helpers', async () => {
await withInteractiveTty(async () => {
const output = await import('./cli-output.js?interactive-test');
expect(output.createSpinner({})).toBeTruthy();
await expect(output.createProgress({}, { max: 2 })).resolves.toBeTruthy();
});
});
});
+114 -5459
View File
File diff suppressed because it is too large Load Diff
+730 -1
View File
@@ -1,11 +1,190 @@
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 { assertAuthenticatedNetworkExposure, parseArgs } from './cli.js';
import { requestJson } from './lib/cli-http.js';
import { inspectTunnelAttachability } from './lib/cli-lifecycle.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,
getInstanceFilePath,
getPidFilePath,
isOpenchamberCmdline,
isOpenchamberProcessRunning,
parseArgs,
resolveServeHost,
} from './cli.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([]);
@@ -105,6 +284,128 @@ describe('network-exposed auth validation', () => {
});
});
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 output = await captureStdout(async () => {
await commands.tunnel({ json: true }, '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('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;
}
});
});
});
describe('cli entry detection', () => {
const modulePath = '/tmp/openchamber/bin/cli.js';
const moduleUrl = pathToFileURL(modulePath).href;
@@ -155,3 +456,431 @@ describe('cli entry detection', () => {
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();
}
});
});
});
+121
View File
@@ -0,0 +1,121 @@
# CLI Module Map
This directory contains the non-entrypoint implementation for the OpenChamber CLI. `packages/web/bin/cli.js` should stay thin: it owns bootstrap, command wiring, top-level dispatch, signal/cancel handling, and compatibility exports. Domain logic belongs in these modules.
## Entrypoint Boundary
- `../cli.js`
- Owns process bootstrap, package/version lookup, command table wiring, signal handlers, top-level error handling, and legacy exports used by tests or external consumers.
- Injects runtime dependencies into command factories, such as `serveCommand`, `stopCommand`, package-manager loading, cancel cleanup, and foreground server state setters.
- Should not grow command-specific behavior. If a new branch needs more than dispatch/wiring, move it here into a command or helper module instead.
## Command Modules
Command modules implement user-facing commands and preserve output contracts across interactive, non-TTY, `--quiet`, and `--json` modes. They should use `../cli-output.js` for presentation helpers and keep safety validation in command logic, not prompts.
- `commands-serve.js`
- Implements `openchamber serve`.
- Owns OpenCode CLI checks, port resolution, log rotation, PID/instance registry writes, foreground/background server launch, startup summaries, and foreground shutdown behavior.
- `commands-lifecycle.js`
- Implements `openchamber stop` and `openchamber restart`.
- Owns lifecycle stop/restart semantics, desktop-managed port rejection, unmanaged instance shutdown attempts, PID/instance cleanup, and restart reuse of stored instance options.
- `commands-status.js`
- Implements `openchamber status`.
- Formats discovered instances and tunnel readiness/status for human, quiet, and JSON output.
- `commands-logs.js`
- Implements `openchamber logs`.
- Resolves log files, tails recent lines, and follows log output.
- `commands-startup.js`
- Implements `openchamber startup`.
- Handles startup subcommand dispatch and presentation around the lower-level startup service helpers.
- `commands-connect-url.js`
- Implements `openchamber connect-url`.
- Finds or starts a local instance and prints the browser/connect URL according to the selected output mode.
- `commands-update.js`
- Implements `openchamber update`.
- Loads the package-manager helper, performs update flow, and coordinates restart behavior after updates.
- `commands-tunnel.js`
- Implements `openchamber tunnel` and its subcommands: `profile`, `providers`, `ready`, `doctor`, `status`, `start`, `stop`, and `completion`.
- Owns tunnel-specific command flow, interactive prompt decisions, managed-local/managed-remote startup, QR display rules, tunnel start/stop API calls, and tunnel profile command handling.
- Receives `serveCommand` and `stopCommand` by dependency injection. Do not reach back into `cli.js` command globals from this module.
## Shared Helper Modules
These modules hold reusable, non-presentational logic for commands.
- `cli-args.js`
- Argument parsing, defaults, help text, completion script generation, and typo suggestions.
- `cli-errors.js`
- CLI exit codes and typed tunnel CLI errors.
- `cli-paths.js`
- Data, run, log, settings, tunnel profile, and managed-local config paths.
- `cli-process.js`
- PID files, instance registry files, process identity checks, runtime metadata checks, and process termination helpers.
- `cli-lifecycle.js`
- Instance discovery, live health probing, attachability checks, provider discovery, and status aggregation used by lifecycle/status/tunnel commands.
- `cli-http.js`
- HTTP helpers for health checks, shutdown requests, JSON API calls, tunnel provider fetches, and system info fetches.
- `cli-network.js`
- Host resolution, URL building, LAN detection, unsafe browser port validation, and UI password/network exposure checks.
- `cli-ports.js`
- Port availability checks and available-port resolution.
- `cli-log-files.js`
- Log rotation, tail reads, and file-follow streaming.
- `cli-executables.js`
- Executable path resolution and PATH lookup helpers.
- `cli-startup.js`
- Native startup service detection, install/uninstall/status helpers, and platform-specific startup command execution.
- `cli-tunnel-profiles.js`
- Tunnel profile normalization, token resolution/redaction, profile storage, migration, file-permission warnings, and managed-remote pair persistence.
- `cli-tunnel-utils.js`
- Tunnel-specific command string builders, TTL parsing/formatting, and replay command helpers.
- `cli-tunnel-capabilities.js`
- Built-in tunnel provider capability fallbacks used when a live server cannot provide tunnel metadata.
## Placement Rules
- Add new CLI commands as `commands-*.js` modules and wire them from `cli.js`.
- Add reusable logic to the narrow helper module that owns the domain. Create a new helper module before mixing unrelated domains into an existing one.
- Keep command modules responsible for user-visible behavior and mode-specific output. Keep helper modules mostly output-free unless the helper exists specifically for CLI rendering.
- Preserve output contracts when moving code:
- `--json` emits JSON only.
- `--quiet` emits concise essential output.
- Prompts are gated by `canPrompt(options)`.
- Validation and policy run in every mode.
- Prefer dependency injection from `cli.js` for cross-command behavior, especially when one command needs another command's implementation.
- Do not import `cli.js` from modules in this directory. The dependency direction is `cli.js` -> command modules -> helper modules.
## Verification
For CLI behavior changes, run the focused CLI suite from `packages/web`:
```sh
bun run test -- bin/cli.test.js
```
Before finalizing source changes that affect CLI behavior, also run:
```sh
bun run type-check
bun run lint
```
+726
View File
@@ -0,0 +1,726 @@
import { TunnelCliError, EXIT_CODE } from './cli-errors.js';
const DEFAULT_PORT = 3000;
const DEFAULT_TAIL_LINES = 200;
function levenshteinDistance(a, b) {
const m = a.length;
const n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
function findClosestMatch(input, candidates, maxDistance = 3) {
if (typeof input !== 'string' || input.length === 0 || !Array.isArray(candidates)) {
return null;
}
const normalized = input.toLowerCase();
let bestCandidate = null;
let bestDistance = maxDistance + 1;
for (const candidate of candidates) {
const distance = levenshteinDistance(normalized, candidate.toLowerCase());
if (distance < bestDistance) {
bestDistance = distance;
bestCandidate = candidate;
}
}
return bestDistance <= maxDistance ? bestCandidate : null;
}
function splitOptionToken(arg) {
if (!arg.startsWith('-')) return null;
if (arg.startsWith('--')) {
const eqIndex = arg.indexOf('=');
return {
name: eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2),
inlineValue: eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined,
long: true,
};
}
return {
name: arg.slice(1),
inlineValue: undefined,
long: false,
};
}
function parseArgs(argv = process.argv.slice(2)) {
const args = Array.isArray(argv) ? [...argv] : [];
const options = {
port: DEFAULT_PORT,
host: undefined,
uiPassword: process.env.OPENCHAMBER_UI_PASSWORD || undefined,
json: false,
all: false,
follow: true,
lines: DEFAULT_TAIL_LINES,
provider: undefined,
mode: undefined,
profile: undefined,
name: undefined,
configPath: undefined,
token: undefined,
tokenFile: undefined,
tokenStdin: false,
hostname: undefined,
server: undefined,
connectTtl: undefined,
sessionTtl: undefined,
qr: false,
explicitQr: false,
force: false,
showSecrets: false,
dryRun: false,
plain: false,
quiet: false,
explicitPort: false,
explicitUiPassword: false,
envSnapshot: true,
foreground: false,
lan: false,
apiOnly: false,
};
const removedFlagErrors = [];
const positional = [];
let helpRequested = false;
let versionRequested = false;
const consumeValue = (index, inlineValue) => {
if (typeof inlineValue === 'string' && inlineValue.length > 0) {
return { value: inlineValue, nextIndex: index };
}
const candidate = args[index + 1];
if (typeof candidate === 'string' && !candidate.startsWith('-')) {
return { value: candidate, nextIndex: index + 1 };
}
return { value: undefined, nextIndex: index };
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const parsedToken = splitOptionToken(arg);
if (!parsedToken) {
positional.push(arg);
continue;
}
const { name, inlineValue, long } = parsedToken;
switch (name) {
case 'port':
case 'p': {
const { value: consumedValue, nextIndex: consumedIndex } = consumeValue(i, inlineValue);
let value = consumedValue;
let nextIndex = consumedIndex;
// Support explicit negative numeric values like `-p -1` so we can report
// a clear range validation error instead of "Unknown option".
if (value === undefined && typeof inlineValue !== 'string') {
const candidate = args[i + 1];
if (typeof candidate === 'string' && /^-\d+$/.test(candidate)) {
value = candidate;
nextIndex = i + 1;
}
}
i = nextIndex;
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TunnelCliError('Missing value for --port.', EXIT_CODE.USAGE_ERROR);
}
if (!/^-?\d+$/.test(value.trim())) {
throw new TunnelCliError(`Invalid port value: ${value}`, EXIT_CODE.USAGE_ERROR);
}
const parsed = parseInt(value, 10);
if (parsed < 1 || parsed > 65535) {
throw new TunnelCliError(`Invalid port value: ${parsed}`, EXIT_CODE.USAGE_ERROR);
}
options.port = parsed;
options.explicitPort = true;
break;
}
case 'host': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TunnelCliError('Missing value for --host.', EXIT_CODE.USAGE_ERROR);
}
options.host = value.trim();
break;
}
case 'lan':
options.lan = true;
break;
case 'ui-password': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.uiPassword = typeof value === 'string' ? value : '';
options.explicitUiPassword = true;
break;
}
case 'provider': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.provider = typeof value === 'string' ? value : options.provider;
break;
}
case 'mode': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.mode = typeof value === 'string' ? value : options.mode;
break;
}
case 'profile': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.profile = typeof value === 'string' ? value : options.profile;
break;
}
case 'name': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.name = typeof value === 'string' ? value : options.name;
break;
}
case 'config': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.configPath = typeof value === 'string' ? value : null;
break;
}
case 'token': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.token = typeof value === 'string' ? value : options.token;
break;
}
case 'token-file': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.tokenFile = typeof value === 'string' ? value : options.tokenFile;
break;
}
case 'token-stdin':
options.tokenStdin = true;
break;
case 'hostname': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.hostname = typeof value === 'string' ? value : options.hostname;
break;
}
case 'server':
case 'server-url': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TunnelCliError('Missing value for --server.', EXIT_CODE.USAGE_ERROR);
}
options.server = value.trim();
break;
}
case 'connect-ttl': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.connectTtl = typeof value === 'string' ? value : options.connectTtl;
break;
}
case 'session-ttl': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.sessionTtl = typeof value === 'string' ? value : options.sessionTtl;
break;
}
case 'json':
options.json = true;
break;
case 'all':
options.all = true;
break;
case 'no-follow':
options.follow = false;
break;
case 'no-env-snapshot':
options.envSnapshot = false;
break;
case 'lines': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
const parsed = parseInt(value ?? '', 10);
if (Number.isFinite(parsed) && parsed > 0) {
options.lines = parsed;
}
break;
}
case 'qr':
options.qr = true;
options.explicitQr = true;
break;
case 'no-qr':
options.qr = false;
options.explicitQr = true;
break;
case 'force':
options.force = true;
break;
case 'show-secrets':
options.showSecrets = true;
break;
case 'dry-run':
options.dryRun = true;
break;
case 'plain':
options.plain = true;
break;
case 'quiet':
case 'q':
options.quiet = true;
break;
case 'help':
case 'h':
helpRequested = true;
break;
case 'version':
case 'v':
versionRequested = true;
break;
case 'foreground':
case 'no-daemon':
options.foreground = true;
break;
case 'api-only':
options.apiOnly = true;
break;
case 'daemon':
case 'd':
// Legacy no-op: daemon mode is already the default, but older clients
// may still pass this when starting a remote server.
break;
case 'try-cf-tunnel':
removedFlagErrors.push('`--try-cf-tunnel` was removed. Use: openchamber tunnel start --provider cloudflare --mode quick');
break;
case 'tunnel-qr':
removedFlagErrors.push('`--tunnel-qr` was removed. Use: openchamber tunnel start ... --qr');
break;
case 'tunnel-password-url':
removedFlagErrors.push('`--tunnel-password-url` was removed. Use UI password auth directly after tunnel start.');
break;
case 'tunnel-provider':
case 'tunnel-mode':
case 'tunnel-config':
case 'tunnel-token':
case 'tunnel-hostname':
case 'tunnel':
removedFlagErrors.push(`\`--${name}\` was removed from top-level serve flow. Use: openchamber tunnel start ...`);
break;
default:
if (!long && name.length === 1) {
removedFlagErrors.push(`Unknown option: -${name}`);
} else {
removedFlagErrors.push(`Unknown option: --${name}`);
}
break;
}
}
const command = positional[0] || 'serve';
const subcommand = command === 'tunnel' ? (positional[1] || 'help') : null;
const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null;
const startupAction = command === 'startup' ? (positional[1] || 'status') : null;
if (options.lan && typeof options.host !== 'string') {
options.host = '0.0.0.0';
}
if (command !== 'tunnel' && typeof options.hostname === 'string' && typeof options.host !== 'string') {
options.host = options.hostname;
}
return {
command,
subcommand,
tunnelAction,
startupAction,
options,
removedFlagErrors,
helpRequested,
versionRequested,
};
}
function showHelp() {
console.log(`
OpenChamber - Web interface for the OpenCode AI coding agent
USAGE:
openchamber [COMMAND] [OPTIONS]
COMMANDS:
serve Start the web server (daemon default)
stop Stop running instance(s)
restart Stop and start the server
status Show server status
tunnel Tunnel lifecycle commands
startup Manage launch at system startup
logs Tail OpenChamber logs
connect-url Generate URL/QR for connecting another client
update Check for and install updates
OPTIONS:
-p, --port Web server port (default: ${DEFAULT_PORT})
--host Bind address (default: 127.0.0.1)
--hostname Alias for --host outside tunnel commands
--lan Bind to 0.0.0.0 for LAN access
--server <url> Public/server URL for connect-url links
--ui-password Protect browser UI with single password
--api-only Start API routes only, without serving browser UI assets
--foreground Run server in foreground (use with systemd/process managers)
--no-daemon Alias for --foreground
-h, --help Show help
-v, --version Show version
ENVIRONMENT:
OPENCHAMBER_HOST Bind address (e.g. 0.0.0.0 for all interfaces)
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
OPENCHAMBER_API_ONLY Set to true/1 to start API routes only
OPENCHAMBER_DATA_DIR Override OpenChamber data directory
OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096
OPENCODE_PORT Port of external OpenCode server to connect to
OPENCODE_SKIP_START Skip starting OpenCode, use external server
OPENCHAMBER_OPENCODE_HOSTNAME Bind hostname for managed OpenCode server (default: 127.0.0.1)
EXAMPLES:
openchamber # Start in daemon mode on default port 3000 (or free port)
openchamber --port 8080 # Start on port 8080 (daemon)
openchamber --lan --port 3002 # Start on LAN at 0.0.0.0:3002
openchamber serve --foreground # Start in foreground (for systemd Type=simple)
openchamber connect-url --port 3000 --qr
openchamber connect-url --server https://openchamber.example.com
openchamber startup enable # Start OpenChamber at user login
openchamber tunnel help # Show tunnel lifecycle help
openchamber logs # Follow logs for latest running instance
`);
}
function showStartupHelp() {
console.log(`
OpenChamber Startup Commands
USAGE:
openchamber startup <SUBCOMMAND> [OPTIONS]
SUBCOMMANDS:
status Show startup integration status
enable Install and start native user startup integration
disable Stop and remove native user startup integration
OPTIONS:
-p, --port Web server port used by startup service
--host Bind address used by startup service
--ui-password Protect browser UI with single password
--api-only Start API routes only, without serving browser UI assets
--no-env-snapshot Do not save current environment for startup service
--json Output machine-readable JSON
-q, --quiet Suppress non-essential output
EXAMPLES:
openchamber startup enable
openchamber startup enable --port 3000
openchamber startup enable --port 3000 --api-only --host 0.0.0.0
openchamber startup status --json
`);
}
function showConnectUrlHelp() {
console.log(`
OpenChamber Connect URL
USAGE:
openchamber connect-url [OPTIONS]
DESCRIPTION:
Generate an openchamber:// connection link for adding this server to another
OpenChamber app. If no server is running on the selected port, it starts one.
OPTIONS:
-p, --port <port> Server port to use or start (default: ${DEFAULT_PORT})
--host <address> Bind address when starting the server
--hostname <address> Alias for --host
--lan Bind to 0.0.0.0 for LAN access when starting
--server <url> Public URL saved into the connection link
--server-url <url> Alias for --server
--name <label> Label saved with the remote client token
--ui-password <value> Protect browser access when UI routes are enabled
--api-only Start in headless/API-only mode when starting
--qr Print a QR code for the connection link
--json Output machine-readable JSON
-q, --quiet Print only the connection link
-h, --help Show this help
EXAMPLES:
openchamber connect-url --port 3000 --qr
openchamber connect-url --port 3000 --api-only --lan --server http://workstation.local:3000 --qr
openchamber connect-url --server https://openchamber.example.com --name Workstation
`);
}
function showTunnelHelp() {
console.log(`
Tunnel Lifecycle Commands
USAGE:
openchamber tunnel <SUBCOMMAND> [OPTIONS]
SUBCOMMANDS:
help Show this tunnel help
providers Show available tunnel providers and capabilities
ready Check tunnel readiness for a provider
doctor Run deep tunnel diagnostics
status Show tunnel status
start Start a tunnel
stop Stop active tunnel (keep server running)
profile Manage saved managed-remote profiles
COMMON OPTIONS:
-p, --port Target OpenChamber instance port
--host Bind address when auto-starting an instance
--lan Bind to 0.0.0.0 when auto-starting an instance
--ui-password Protect browser UI when auto-starting an instance
--api-only Start API routes only when auto-starting an instance
--json Output machine-readable JSON
--all Apply to all running instances (doctor default, stop)
START OPTIONS:
--provider <id> Tunnel provider id (default: cloudflare)
--mode <id> Tunnel mode (default: quick)
--profile <name> Start tunnel from saved profile name
--config [path] Managed-local config path (optional)
--token <token> Managed-remote token (visible in process list)
--token-file <path> Read token from file (recommended)
--token-stdin Read token from stdin
--hostname <hostname> Managed-remote hostname
--connect-ttl <value> Connect-link TTL (e.g. 30m, 24h, 1d)
--session-ttl <value> Session TTL (e.g. 8h, 24h, 1d)
--qr Print QR code for resulting tunnel URL
--no-qr Disable QR output
--dry-run Validate inputs without applying changes
OUTPUT OPTIONS:
--show-secrets Show full tokens in output (default: redacted)
--plain Disable colors and decorations
-q, --quiet Suppress non-essential output
--json Output machine-readable JSON
BEHAVIOR NOTES:
- One active tunnel per OpenChamber instance.
- Starting a different mode/provider replaces the current tunnel and revokes old connect links/sessions.
- Connect links are one-time; generating a new link revokes the previous unused link.
PROFILE USAGE:
openchamber tunnel profile list [--provider <id>] [--json]
openchamber tunnel profile show --name <name> [--provider <id>] [--json]
openchamber tunnel profile add --provider <id> --mode managed-remote --name <name> --hostname <host> --token <token> [--force] [--json]
openchamber tunnel profile add --provider <id> --mode managed-remote --name <name> --hostname <host> --token-file <path> [--force] [--json]
openchamber tunnel profile remove --name <name> [--provider <id>] [--json]
SHELL COMPLETION:
openchamber tunnel completion bash Generate Bash completion script
openchamber tunnel completion zsh Generate Zsh completion script
openchamber tunnel completion fish Generate Fish completion script
EXAMPLES:
openchamber tunnel providers
openchamber tunnel ready --provider cloudflare
openchamber tunnel doctor --provider cloudflare
openchamber tunnel status
openchamber tunnel start --qr
openchamber tunnel start --profile prod-main
openchamber tunnel start --provider cloudflare --mode managed-remote --token-file ~/.secrets/cf-token --hostname app.example.com
openchamber tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml
openchamber tunnel start --dry-run --provider cloudflare --mode managed-remote --token-file ~/.secrets/cf-token --hostname app.example.com
echo "$TOKEN" | openchamber tunnel profile add --provider cloudflare --mode managed-remote --name prod-main --hostname app.example.com --token-stdin
openchamber tunnel profile list --provider cloudflare
openchamber tunnel profile list --json --show-secrets
openchamber tunnel stop --port 3000
`);
}
function generateCompletionScript(shell) {
const normalized = typeof shell === 'string' ? shell.trim().toLowerCase() : '';
if (normalized === 'bash') {
return `# Bash completion for openchamber tunnel
# Add to ~/.bashrc: eval "$(openchamber tunnel completion bash)"
_openchamber_tunnel() {
local cur prev commands tunnel_commands profile_commands common_flags start_flags
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
commands="serve stop restart status tunnel logs update"
tunnel_commands="help providers ready doctor status start stop profile completion"
profile_commands="list show add remove"
common_flags="--port --foreground --no-daemon --json --all --help --version --plain --quiet"
start_flags="--provider --mode --profile --config --token --token-file --token-stdin --hostname --connect-ttl --session-ttl --qr --no-qr --dry-run --show-secrets"
if [[ \${COMP_CWORD} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
return 0
fi
if [[ "\${COMP_WORDS[1]}" == "tunnel" ]]; then
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "\${tunnel_commands}" -- "\${cur}") )
return 0
fi
if [[ "\${COMP_WORDS[2]}" == "profile" && \${COMP_CWORD} -eq 3 ]]; then
COMPREPLY=( $(compgen -W "\${profile_commands}" -- "\${cur}") )
return 0
fi
if [[ "\${COMP_WORDS[2]}" == "completion" && \${COMP_CWORD} -eq 3 ]]; then
COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
return 0
fi
if [[ "\${COMP_WORDS[2]}" == "start" ]]; then
COMPREPLY=( $(compgen -W "\${start_flags} \${common_flags}" -- "\${cur}") )
return 0
fi
COMPREPLY=( $(compgen -W "\${common_flags}" -- "\${cur}") )
return 0
fi
COMPREPLY=( $(compgen -W "\${common_flags}" -- "\${cur}") )
return 0
}
complete -F _openchamber_tunnel openchamber
`;
}
if (normalized === 'zsh') {
return `#compdef openchamber
# Zsh completion for openchamber tunnel
# Add to ~/.zshrc: eval "$(openchamber tunnel completion zsh)"
_openchamber() {
local -a commands tunnel_commands profile_commands
commands=(
'serve:Start the web server'
'stop:Stop running instance(s)'
'restart:Stop and start the server'
'status:Show server status'
'tunnel:Tunnel lifecycle commands'
'logs:Tail OpenChamber logs'
'update:Check for and install updates'
)
tunnel_commands=(
'help:Show tunnel help'
'providers:Show available providers'
'ready:Check tunnel readiness'
'doctor:Run tunnel diagnostics'
'status:Show tunnel status'
'start:Start a tunnel'
'stop:Stop active tunnel'
'profile:Manage saved profiles'
'completion:Generate shell completion'
)
profile_commands=(
'list:List profiles'
'show:Show profile details'
'add:Add a profile'
'remove:Remove a profile'
)
_arguments -C \\
'1:command:->command' \\
'*::arg:->args'
case \$state in
command)
_describe 'command' commands
;;
args)
case \$words[1] in
tunnel)
if (( CURRENT == 2 )); then
_describe 'tunnel command' tunnel_commands
elif [[ \$words[2] == "profile" ]] && (( CURRENT == 3 )); then
_describe 'profile action' profile_commands
elif [[ \$words[2] == "completion" ]] && (( CURRENT == 3 )); then
_values 'shell' bash zsh fish
fi
;;
esac
;;
esac
}
compdef _openchamber openchamber
`;
}
if (normalized === 'fish') {
return `# Fish completion for openchamber tunnel
# Save to ~/.config/fish/completions/openchamber.fish
complete -c openchamber -n '__fish_use_subcommand' -a 'serve' -d 'Start the web server'
complete -c openchamber -n '__fish_seen_subcommand_from serve' -l foreground -d 'Run in foreground (for systemd/process managers)'
complete -c openchamber -n '__fish_seen_subcommand_from serve' -l no-daemon -d 'Run in foreground (alias for --foreground)'
complete -c openchamber -n '__fish_use_subcommand' -a 'stop' -d 'Stop running instance(s)'
complete -c openchamber -n '__fish_use_subcommand' -a 'restart' -d 'Stop and start the server'
complete -c openchamber -n '__fish_use_subcommand' -a 'status' -d 'Show server status'
complete -c openchamber -n '__fish_use_subcommand' -a 'tunnel' -d 'Tunnel lifecycle commands'
complete -c openchamber -n '__fish_use_subcommand' -a 'logs' -d 'Tail logs'
complete -c openchamber -n '__fish_use_subcommand' -a 'update' -d 'Check for updates'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'help' -d 'Show tunnel help'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'providers' -d 'Show providers'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'ready' -d 'Check readiness'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'doctor' -d 'Run diagnostics'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'status' -d 'Show tunnel status'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'start' -d 'Start a tunnel'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'stop' -d 'Stop tunnel'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'profile' -d 'Manage profiles'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'completion' -d 'Generate completions'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l provider -d 'Provider id'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l mode -d 'Tunnel mode'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l profile -d 'Profile name'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l config -d 'Config path'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token -d 'Token'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token-file -d 'Token file path'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token-stdin -d 'Read token from stdin'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l hostname -d 'Hostname'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l dry-run -d 'Validate without applying'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l qr -d 'Show QR code'
`;
}
return null;
}
export {
DEFAULT_PORT,
parseArgs,
showHelp,
showStartupHelp,
showConnectUrlHelp,
showTunnelHelp,
generateCompletionScript,
findClosestMatch,
};
+18
View File
@@ -0,0 +1,18 @@
const EXIT_CODE = {
SUCCESS: 0,
GENERAL_ERROR: 1,
USAGE_ERROR: 2,
MISSING_DEPENDENCY: 3,
AUTH_CONFIG_ERROR: 4,
NETWORK_RUNTIME_ERROR: 5,
};
class TunnelCliError extends Error {
constructor(message, exitCode = EXIT_CODE.GENERAL_ERROR) {
super(message);
this.name = 'TunnelCliError';
this.exitCode = exitCode;
}
}
export { EXIT_CODE, TunnelCliError };
+54
View File
@@ -0,0 +1,54 @@
import fs from 'fs';
import path from 'path';
const WINDOWS_EXTENSIONS = process.platform === 'win32'
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
.split(';')
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean)
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
: [''];
function isExecutable(filePath) {
try {
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
return false;
}
if (process.platform === 'win32') {
return true;
}
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function resolveExplicitBinary(candidate) {
if (!candidate) {
return null;
}
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
return isExecutable(resolved) ? resolved : null;
}
return null;
}
function searchPathFor(command) {
const pathValue = process.env.PATH || '';
const segments = pathValue.split(path.delimiter).filter(Boolean);
for (const dir of segments) {
for (const ext of WINDOWS_EXTENSIONS) {
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
const candidate = path.join(dir, fileName);
if (isExecutable(candidate)) {
return candidate;
}
}
}
return null;
}
export { resolveExplicitBinary, searchPathFor };
+216
View File
@@ -0,0 +1,216 @@
import { buildLocalUrl } from './cli-network.js';
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
const UI_SESSION_COOKIE_NAME = 'oc_ui_session';
function extractUiSessionCookie(response) {
const setCookie = response?.headers?.get?.('set-cookie');
if (typeof setCookie !== 'string' || setCookie.length === 0) {
return null;
}
const match = setCookie.match(new RegExp(`(?:^|,\\s*)(${UI_SESSION_COOKIE_NAME}=[^;]+)`));
return match?.[1] || null;
}
async function resolveUiPasswordForPort(port, options = {}) {
if (typeof options.uiPassword === 'string' && options.uiPassword.trim().length > 0) {
return options.uiPassword;
}
const instanceOptions = readInstanceOptions(await getInstanceFilePath(port));
return typeof instanceOptions?.uiPassword === 'string' && instanceOptions.uiPassword.trim().length > 0
? instanceOptions.uiPassword
: null;
}
async function createUiSessionCookie(port, password, timeoutMs) {
if (typeof password !== 'string' || password.length === 0) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(buildLocalUrl(port, '/auth/session'), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
signal: controller.signal,
});
if (!response.ok) {
return null;
}
return extractUiSessionCookie(response);
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
async function requestServerShutdown(port, hostOverride) {
if (!Number.isFinite(port) || port <= 0) return false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const resp = await fetch(buildLocalUrl(port, '/api/system/shutdown', hostOverride), {
method: 'POST',
signal: controller.signal,
});
return resp.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function requestJson(port, endpoint, options = {}) {
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
? Math.trunc(options.timeoutMs)
: 4000;
const fetchOptions = { ...options };
delete fetchOptions.timeoutMs;
delete fetchOptions.uiPassword;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const requestUrl = buildLocalUrl(port, endpoint);
const requestHeaders = {
Accept: 'application/json',
...(fetchOptions.body ? { 'Content-Type': 'application/json' } : {}),
...(fetchOptions.headers || {}),
};
const response = await fetch(requestUrl, {
...fetchOptions,
headers: requestHeaders,
signal: controller.signal,
});
const body = await response.json().catch(() => null);
if (response.status === 401 && body?.error === 'UI authentication required') {
const uiPassword = await resolveUiPasswordForPort(port, options);
const cookie = await createUiSessionCookie(port, uiPassword, timeoutMs);
if (cookie) {
const retryResponse = await fetch(requestUrl, {
...fetchOptions,
headers: {
...requestHeaders,
Cookie: cookie,
},
signal: controller.signal,
});
const retryBody = await retryResponse.json().catch(() => null);
return { response: retryResponse, body: retryBody };
}
}
return { response, body };
} catch (error) {
if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) {
throw new Error(`Request to ${endpoint} timed out after ${timeoutMs}ms.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function isServerHealthReady(port, timeoutMs = 1000) {
if (!Number.isFinite(port) || port <= 0) {
return false;
}
const requestTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.trunc(timeoutMs) : 1000;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), requestTimeout);
try {
const response = await fetch(buildLocalUrl(port, '/health'), {
headers: { Accept: 'text/plain' },
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function waitForServerHealth(port, {
timeoutMs = 60000,
intervalMs = 250,
onTick,
} = {}) {
const start = Date.now();
const deadline = start + timeoutMs;
while (Date.now() < deadline) {
const elapsedMs = Date.now() - start;
if (typeof onTick === 'function') {
onTick({ elapsedMs, timeoutMs });
}
if (await isServerHealthReady(port, Math.min(1000, intervalMs * 2))) {
if (typeof onTick === 'function') {
onTick({ elapsedMs: Math.min(Date.now() - start, timeoutMs), timeoutMs, complete: true });
}
return true;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
if (typeof onTick === 'function') {
onTick({ elapsedMs: timeoutMs, timeoutMs, timedOut: true });
}
return false;
}
async function fetchTunnelProvidersFromPort(port, fetchImpl = globalThis.fetch) {
if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') {
return null;
}
try {
const response = await fetchImpl(buildLocalUrl(port, '/api/openchamber/tunnel/providers'));
if (!response.ok) return null;
const body = await response.json().catch(() => null);
if (!body || !Array.isArray(body.providers)) return null;
return body.providers;
} catch {
return null;
}
}
async function fetchSystemInfoFromPort(port, fetchImpl = globalThis.fetch, hostOverride) {
if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const response = await fetchImpl(buildLocalUrl(port, '/api/system/info', hostOverride), {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) return null;
const body = await response.json().catch(() => null);
if (!body || typeof body.runtime !== 'string') return null;
return {
runtime: body.runtime,
pid: Number.isFinite(body.pid) ? body.pid : null,
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
export {
requestServerShutdown,
requestJson,
isServerHealthReady,
waitForServerHealth,
fetchTunnelProvidersFromPort,
fetchSystemInfoFromPort,
};
+421
View File
@@ -0,0 +1,421 @@
import fs from 'fs';
import path from 'path';
import { DEFAULT_PORT } from './cli-args.js';
import { getRunDir, readDesktopLocalPortFromSettings } from './cli-paths.js';
import { resolveApiHost, buildLocalUrl } from './cli-network.js';
import { fetchTunnelProvidersFromPort, fetchSystemInfoFromPort, isServerHealthReady } from './cli-http.js';
import { isPortAvailable } from './cli-ports.js';
import {
getPidFilePath,
getInstanceFilePath,
readPidFile,
removePidFile,
readInstanceOptions,
removeInstanceFile,
getOpenchamberProcessState,
hasOpenchamberRuntimeInfo,
} from './cli-process.js';
import { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES } from './cli-tunnel-capabilities.js';
function createLivePortInstance(port, info, host) {
if (!hasOpenchamberRuntimeInfo(info)) return null;
return {
port,
pid: Number.isFinite(info.pid) ? info.pid : null,
pidFilePath: path.join(getRunDir(), `openchamber-${port}.pid`),
instanceFilePath: path.join(getRunDir(), `openchamber-${port}.json`),
mtime: 0,
startedAt: 0,
launchMode: 'daemon',
runtime: info.runtime,
source: 'probe',
host: typeof host === 'string' && host.length > 0 ? host : undefined,
};
}
function normalizeProbeHost(host) {
return typeof host === 'string' && host.trim().length > 0 ? host.trim() : undefined;
}
function isWildcardProbeHost(host) {
const normalized = normalizeProbeHost(host);
return normalized === '0.0.0.0' || normalized === '::' || normalized === '[::]';
}
function isLoopbackProbeHost(host) {
const normalized = normalizeProbeHost(host);
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1' || normalized === '[::1]';
}
function isConcreteProbeHost(host) {
const normalized = normalizeProbeHost(host);
return Boolean(normalized && !isWildcardProbeHost(normalized) && !isLoopbackProbeHost(normalized));
}
function getSystemInfoProbeHosts(...hosts) {
const out = [];
const hasConcreteAuthoritativeHost = hosts.some(isConcreteProbeHost);
const pushHost = (host, requiresPidMatch = false) => {
const normalized = normalizeProbeHost(host);
const key = resolveApiHost(normalized);
if (!out.some((entry) => resolveApiHost(entry.host) === key)) {
out.push({ host: normalized, requiresPidMatch });
}
};
for (const host of hosts) {
if (normalizeProbeHost(host)) {
pushHost(host, false);
}
}
pushHost(undefined, hasConcreteAuthoritativeHost);
pushHost('127.0.0.1', hasConcreteAuthoritativeHost);
return out;
}
async function fetchSystemInfoFromPortCandidates(port, fetchImpl, hosts, expectedPid) {
for (const { host, requiresPidMatch } of hosts) {
const info = await fetchSystemInfoFromPort(port, fetchImpl, host);
if (hasOpenchamberRuntimeInfo(info)) {
if (requiresPidMatch && info.pid !== expectedPid) {
continue;
}
return { info, host };
}
}
return { info: null, host: null };
}
async function resolveDoctorPortStatuses(options = {}) {
const runningEntries = await discoverRunningInstances();
const desktopEntry = await discoverDesktopInstance();
const statuses = [];
if (options.explicitPort) {
const requestedPort = options.port;
const runningMatch = runningEntries.find((entry) => entry.port === requestedPort);
if (runningMatch) {
statuses.push({
port: requestedPort,
available: true,
status: 'success',
line: `port ${requestedPort} available for tunneling`,
detail: 'Double-check this same port is configured in your provider dashboard/config.',
});
return { statuses, availableEntries: [runningMatch] };
}
if (desktopEntry && desktopEntry.port === requestedPort) {
statuses.push({
port: requestedPort,
available: false,
status: 'warning',
line: `port ${requestedPort} not available (desktop runtime)`,
detail: 'Use a CLI instance port from `openchamber serve` for tunneling.',
});
return { statuses, availableEntries: [] };
}
statuses.push({
port: requestedPort,
available: false,
status: 'error',
line: `port ${requestedPort} not available (no running instance)`,
detail: `Start one with \`openchamber serve --port ${requestedPort}\`.`,
});
return { statuses, availableEntries: [] };
}
for (const entry of runningEntries) {
statuses.push({
port: entry.port,
available: true,
status: 'success',
line: `port ${entry.port} available for tunneling`,
detail: 'Double-check this same port is configured in your provider dashboard/config.',
});
}
if (desktopEntry && !runningEntries.some((entry) => entry.port === desktopEntry.port)) {
statuses.push({
port: desktopEntry.port,
available: false,
status: 'warning',
line: `port ${desktopEntry.port} not available (desktop runtime)`,
detail: 'Use a CLI instance port from `openchamber serve` for tunneling.',
});
}
if (runningEntries.length === 0) {
statuses.push({
port: null,
available: false,
status: 'warning',
line: 'no CLI ports available for tunneling',
detail: 'Start one with `openchamber serve`.',
});
}
return { statuses, availableEntries: runningEntries };
}
async function discoverRunningInstances(options = {}) {
const instances = [];
const runDir = getRunDir();
const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch;
const getProcessState = typeof options.getOpenchamberProcessState === 'function'
? options.getOpenchamberProcessState
: (pid) => getOpenchamberProcessState(pid, options);
try {
const files = fs.readdirSync(runDir);
const pidFiles = files.filter((file) => file.startsWith('openchamber-') && file.endsWith('.pid'));
for (const file of pidFiles) {
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''), 10);
if (!Number.isFinite(port) || port <= 0) continue;
const pidFilePath = path.join(runDir, file);
const pid = readPidFile(pidFilePath);
if (!pid) {
removePidFile(pidFilePath);
removeInstanceFile(path.join(runDir, `openchamber-${port}.json`));
continue;
}
const instanceFilePath = path.join(runDir, `openchamber-${port}.json`);
const storedOptions = readInstanceOptions(instanceFilePath);
const processState = getProcessState(pid);
if (processState === 'dead') {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
continue;
}
// A live PID-file is only the right instance if the recorded port also
// confirms OpenChamber. Cmdline identity alone can match a recycled PID
// from another OpenChamber process on a different port. Try all plausible
// hosts first; if matched/unknown identity still can't be confirmed, keep
// the registry files but don't claim the instance is running.
const { info: liveInfo, host: confirmedHost } = await fetchSystemInfoFromPortCandidates(
port,
fetchImpl,
getSystemInfoProbeHosts(storedOptions?.host, options.host),
pid,
);
const livePid = Number.isFinite(liveInfo?.pid) ? liveInfo.pid : null;
if (!hasOpenchamberRuntimeInfo(liveInfo)) {
if (processState === 'mismatched') {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
}
continue;
}
if (liveInfo.runtime === 'desktop') {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
continue;
}
let mtime = 0;
let startedAt = 0;
try {
mtime = fs.statSync(pidFilePath).mtimeMs;
} catch {
}
if (Number.isFinite(storedOptions?.startedAt)) {
startedAt = storedOptions.startedAt;
}
const launchMode = storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon';
instances.push({
port,
pid: livePid || (processState === 'matched' ? pid : null),
pidFilePath,
instanceFilePath,
mtime,
startedAt,
launchMode,
runtime: liveInfo.runtime,
source: 'registry+probe',
host: typeof confirmedHost === 'string' && confirmedHost.length > 0
? confirmedHost
: (typeof storedOptions?.host === 'string' && storedOptions.host.length > 0 ? storedOptions.host : undefined),
});
}
} catch {
}
instances.sort((a, b) => a.port - b.port);
return instances;
}
async function discoverOpenChamberInstanceOnPort(port, options = {}) {
if (!Number.isFinite(port) || port <= 0) return null;
const runningInstances = Array.isArray(options.runningInstances)
? options.runningInstances
: await discoverRunningInstances(options);
const registryMatch = runningInstances.find((entry) => entry.port === port);
if (registryMatch) return registryMatch;
const info = await fetchSystemInfoFromPort(
port,
typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch,
options.host,
);
if (info?.runtime === 'desktop' && !isDesktopRuntimeForPort(info, port)) {
return null;
}
return createLivePortInstance(port, info, options.host);
}
async function discoverLifecycleInstances(options = {}, deps = {}) {
const runningInstances = await discoverRunningInstances({ ...deps, host: options.host });
if (!options.explicitPort) {
return runningInstances;
}
const found = runningInstances.find((entry) => entry.port === options.port);
if (found) return [found];
const liveInstance = await discoverOpenChamberInstanceOnPort(options.port, {
...deps,
host: options.host,
runningInstances,
});
return liveInstance ? [liveInstance] : [];
}
async function discoverUnconfirmedRegistryInstanceOnPort(port, options = {}) {
if (!Number.isFinite(port) || port <= 0) return null;
const pidFilePath = await getPidFilePath(port);
const pid = readPidFile(pidFilePath);
if (!pid) return null;
const instanceFilePath = await getInstanceFilePath(port);
const storedOptions = readInstanceOptions(instanceFilePath);
const processState = getOpenchamberProcessState(pid);
if (processState === 'dead') {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
return null;
}
if (processState !== 'matched') {
return null;
}
const host = storedOptions?.host || options.host;
if (await isPortAvailable(port, host)) {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
return null;
}
return {
port,
pid,
pidFilePath,
instanceFilePath,
mtime: 0,
startedAt: Number.isFinite(storedOptions?.startedAt) ? storedOptions.startedAt : 0,
launchMode: storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon',
runtime: 'cli',
source: 'registry-unconfirmed',
host: typeof host === 'string' && host.length > 0 ? host : undefined,
};
}
function getLatestInstance(instances) {
if (!instances.length) return null;
return [...instances].sort((a, b) => {
const startedDelta = (b.startedAt || 0) - (a.startedAt || 0);
if (startedDelta !== 0) return startedDelta;
const mtimeDelta = (b.mtime || 0) - (a.mtime || 0);
if (mtimeDelta !== 0) return mtimeDelta;
return b.port - a.port;
})[0];
}
function isDesktopRuntimeForPort(info, port) {
if (info?.runtime !== 'desktop') {
return false;
}
const desktopPort = readDesktopLocalPortFromSettings();
return !desktopPort || desktopPort === port;
}
async function inspectTunnelAttachability(port, { requireHealthy = true } = {}) {
const info = await fetchSystemInfoFromPort(port);
if (!info || typeof info.runtime !== 'string') {
return { attachable: false, reason: 'unreachable' };
}
if (isDesktopRuntimeForPort(info, port)) {
return { attachable: false, reason: 'desktop', info };
}
if (requireHealthy) {
const healthy = await isServerHealthReady(port, 1200);
if (!healthy) {
return { attachable: false, reason: 'unhealthy', info };
}
}
return { attachable: true, reason: 'ok', info };
}
async function discoverDesktopInstance(fetchImpl = globalThis.fetch) {
const port = readDesktopLocalPortFromSettings();
if (!port) {
return null;
}
const info = await fetchSystemInfoFromPort(port, fetchImpl);
if (!info || info.runtime !== 'desktop') {
return null;
}
return {
port,
pid: info.pid,
runtime: info.runtime,
};
}
async function resolveTunnelProviders(options = {}, deps = {}) {
const readPorts = typeof deps.readPorts === 'function'
? deps.readPorts
: async () => (await discoverRunningInstances()).map((entry) => entry.port);
const fetchImpl = typeof deps.fetchImpl === 'function' ? deps.fetchImpl : globalThis.fetch;
const candidatePorts = [];
if (Number.isFinite(options.port) && options.port > 0) {
candidatePorts.push(options.port);
}
const discoveredPorts = await Promise.resolve(readPorts());
if (Array.isArray(discoveredPorts)) {
candidatePorts.push(...discoveredPorts);
}
if (!candidatePorts.includes(DEFAULT_PORT)) {
candidatePorts.push(DEFAULT_PORT);
}
for (const port of candidatePorts) {
const providers = await fetchTunnelProvidersFromPort(port, fetchImpl);
if (providers) {
return { providers, source: `api:${port}` };
}
}
return { providers: DEFAULT_TUNNEL_PROVIDER_CAPABILITIES, source: 'fallback' };
}
export {
resolveDoctorPortStatuses,
discoverRunningInstances,
discoverOpenChamberInstanceOnPort,
discoverLifecycleInstances,
discoverUnconfirmedRegistryInstanceOnPort,
getLatestInstance,
isDesktopRuntimeForPort,
inspectTunnelAttachability,
discoverDesktopInstance,
resolveTunnelProviders,
};
+93
View File
@@ -0,0 +1,93 @@
import fs from 'fs';
const DEFAULT_TAIL_LINES = 200;
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
const LOG_ROTATE_KEEP = 5;
function rotateLogFile(logPath) {
try {
const stats = fs.statSync(logPath);
if (stats.size < LOG_ROTATE_MAX_BYTES) {
return;
}
} catch {
return;
}
for (let i = LOG_ROTATE_KEEP - 1; i >= 1; i--) {
const src = `${logPath}.${i}`;
const dst = `${logPath}.${i + 1}`;
if (fs.existsSync(src)) {
try {
fs.renameSync(src, dst);
} catch {
}
}
}
try {
if (fs.existsSync(logPath)) {
fs.renameSync(logPath, `${logPath}.1`);
}
} catch {
}
}
function readTailLines(filePath, lineCount = DEFAULT_TAIL_LINES) {
if (!fs.existsSync(filePath)) {
return [];
}
const raw = fs.readFileSync(filePath, 'utf8');
const lines = raw.split(/\r?\n/);
if (lines.length && lines[lines.length - 1] === '') {
lines.pop();
}
return lines.slice(Math.max(0, lines.length - lineCount));
}
function followFile(filePath, onLine) {
let position = 0;
try {
position = fs.statSync(filePath).size;
} catch {
position = 0;
}
let remainder = '';
const interval = setInterval(() => {
try {
const stats = fs.statSync(filePath);
if (stats.size < position) {
position = 0;
}
if (stats.size === position) {
return;
}
const fd = fs.openSync(filePath, 'r');
try {
const length = stats.size - position;
const buffer = Buffer.alloc(length);
fs.readSync(fd, buffer, 0, length, position);
position = stats.size;
const chunk = remainder + buffer.toString('utf8');
const parts = chunk.split(/\r?\n/);
remainder = parts.pop() || '';
for (const line of parts) {
onLine(line);
}
} finally {
fs.closeSync(fd);
}
} catch {
}
}, 400);
return () => {
clearInterval(interval);
};
}
export { rotateLogFile, readTailLines, followFile };
+154
View File
@@ -0,0 +1,154 @@
import dgram from 'dgram';
import os from 'os';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import {
getUnauthenticatedLanErrorMessage,
isNetworkExposedBindHost,
isUnsafeUnauthenticatedLanAllowed,
} from '../../server/lib/security/bind-host.js';
// Browser-unsafe ports (Fetch/Chromium restricted ports).
const UNSAFE_BROWSER_PORTS = new Set([
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69,
77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119,
123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515,
526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990,
993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566,
6665, 6666, 6667, 6668, 6669, 6697, 10080,
]);
function isUnsafeBrowserPort(port) {
return Number.isFinite(port) && UNSAFE_BROWSER_PORTS.has(Math.trunc(port));
}
function resolveConfiguredBindHost(hostOverride) {
const configured = typeof hostOverride === 'string' && hostOverride.trim()
? hostOverride.trim()
: typeof process.env.OPENCHAMBER_HOST === 'string'
? process.env.OPENCHAMBER_HOST.trim()
: '';
return configured || '127.0.0.1';
}
function resolveServeHost(hostOverride) {
return resolveConfiguredBindHost(hostOverride);
}
function resolveApiHost(hostOverride) {
const configured = resolveConfiguredBindHost(hostOverride);
if (!configured) {
return '127.0.0.1';
}
// Wildcard bind hosts are not valid destination hosts.
if (configured === '0.0.0.0') {
return '127.0.0.1';
}
if (configured === '::' || configured === '[::]') {
return '::1';
}
// Strip brackets if user provided [::1]
if (configured.startsWith('[') && configured.endsWith(']')) {
return configured.slice(1, -1);
}
return configured;
}
function formatHostForUrl(host) {
if (typeof host !== 'string') return '127.0.0.1';
// Bracket IPv6 for URL usage.
return host.includes(':') ? `[${host}]` : host;
}
function buildLocalUrl(port, endpoint = '', hostOverride) {
const host = formatHostForUrl(resolveApiHost(hostOverride));
const pathPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `http://${host}:${port}${pathPart}`;
}
async function detectLanIPv4Address() {
const ip = await new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
const finish = (value) => {
try { socket.close(); } catch {}
resolve(value);
};
socket.once('error', () => finish(null));
try {
socket.connect(80, '8.8.8.8', (error) => {
if (error) return finish(null);
try {
const addr = socket.address();
finish(addr && typeof addr.address === 'string' ? addr.address : null);
} catch {
finish(null);
}
});
} catch {
finish(null);
}
});
if (ip && ip !== '0.0.0.0' && !ip.startsWith('127.')) return ip;
for (const entries of Object.values(os.networkInterfaces() || {})) {
for (const entry of entries || []) {
if (entry.family === 'IPv4' && !entry.internal && entry.address) {
return entry.address;
}
}
}
return null;
}
function formatUnsafePortWarning(port) {
return `Port ${port} is browser-unsafe (ERR_UNSAFE_PORT) and is not supported for OpenChamber UI at ${buildLocalUrl(port, '/')}.`;
}
function assertSafeBrowserPort(port, { context = 'This action' } = {}) {
if (!isUnsafeBrowserPort(port)) {
return;
}
throw new TunnelCliError(
`${context} cannot use port ${port}. ${formatUnsafePortWarning(port)} Use a safe port such as 3000, 5173, 8080, or a high ephemeral port.`,
EXIT_CODE.USAGE_ERROR,
);
}
function hasUiPasswordConfigured(password) {
return typeof password === 'string' && password.trim().length > 0;
}
function assertAuthenticatedNetworkExposure({ host, uiPassword }) {
const bindHost = resolveConfiguredBindHost(host);
if (hasUiPasswordConfigured(uiPassword)) {
return;
}
if (!isNetworkExposedBindHost(bindHost)) {
return;
}
if (isUnsafeUnauthenticatedLanAllowed(process.env)) {
return;
}
throw new TunnelCliError(getUnauthenticatedLanErrorMessage(bindHost), EXIT_CODE.AUTH_CONFIG_ERROR);
}
export {
resolveConfiguredBindHost,
resolveServeHost,
resolveApiHost,
formatHostForUrl,
isUnsafeBrowserPort,
buildLocalUrl,
detectLanIPv4Address,
assertSafeBrowserPort,
hasUiPasswordConfigured,
assertAuthenticatedNetworkExposure,
};
+113
View File
@@ -0,0 +1,113 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
const TUNNEL_PROFILES_FILE_NAME = 'tunnel-profiles.json';
const LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME = 'cloudflare-managed-remote-tunnels.json';
const TUNNEL_CLI_STATE_FILE_NAME = 'tunnel-cli-state.json';
function getDataDir() {
if (typeof process.env.OPENCHAMBER_DATA_DIR === 'string' && process.env.OPENCHAMBER_DATA_DIR.trim().length > 0) {
return path.resolve(process.env.OPENCHAMBER_DATA_DIR.trim());
}
return path.join(os.homedir(), '.config', 'openchamber');
}
function getLogsDir() {
return path.join(getDataDir(), 'logs');
}
function getSettingsFilePath() {
return path.join(getDataDir(), 'settings.json');
}
function readDesktopLocalPortFromSettings() {
try {
const raw = fs.readFileSync(getSettingsFilePath(), 'utf8');
const parsed = JSON.parse(raw);
const value = parsed?.desktopLocalPort;
if (Number.isFinite(value) && value > 0 && value <= 65535) {
return value;
}
return null;
} catch {
return null;
}
}
function ensureLogsDir() {
fs.mkdirSync(getLogsDir(), { recursive: true });
}
function getLogFilePath(port) {
return path.join(getLogsDir(), `openchamber-${port}.log`);
}
function getTunnelProfilesFilePath() {
return path.join(getDataDir(), TUNNEL_PROFILES_FILE_NAME);
}
function getLegacyCloudflareManagedRemoteFilePath() {
return path.join(getDataDir(), LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME);
}
function getTunnelCliStateFilePath() {
return path.join(getDataDir(), TUNNEL_CLI_STATE_FILE_NAME);
}
function readTunnelCliState() {
const filePath = getTunnelCliStateFilePath();
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return parsed;
} catch {
return {};
}
}
function readLastManagedLocalConfigPath() {
const state = readTunnelCliState();
if (typeof state.lastManagedLocalConfigPath !== 'string') {
return '';
}
return state.lastManagedLocalConfigPath.trim();
}
function writeLastManagedLocalConfigPath(configPath) {
if (typeof configPath !== 'string' || configPath.trim().length === 0) {
return;
}
const filePath = getTunnelCliStateFilePath();
const current = readTunnelCliState();
const next = {
...current,
lastManagedLocalConfigPath: configPath.trim(),
updatedAt: Date.now(),
};
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2), 'utf8');
}
function getRunDir() {
const dir = path.join(getDataDir(), 'run');
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
return dir;
}
export {
getDataDir,
readDesktopLocalPortFromSettings,
ensureLogsDir,
getLogFilePath,
getTunnelProfilesFilePath,
getLegacyCloudflareManagedRemoteFilePath,
readLastManagedLocalConfigPath,
writeLastManagedLocalConfigPath,
getRunDir,
};
+51
View File
@@ -0,0 +1,51 @@
import net from 'net';
import { DEFAULT_PORT } from './cli-args.js';
import { fetchSystemInfoFromPort } from './cli-http.js';
async function isPortAvailable(port, host) {
if (!Number.isFinite(port) || port <= 0) {
return false;
}
return await new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.on('error', () => resolve(false));
server.listen({ port, host }, () => {
server.close(() => resolve(true));
});
});
}
async function resolveAvailablePort(desiredPort, explicitPort = false, onNotice) {
const startPort = Number.isFinite(desiredPort) ? Math.trunc(desiredPort) : DEFAULT_PORT;
if (explicitPort) {
return startPort;
}
if (await isPortAvailable(startPort)) {
return startPort;
}
const occupant = await fetchSystemInfoFromPort(startPort);
let message;
if (occupant?.runtime === 'desktop') {
message = `Port ${startPort} is used by OpenChamber Desktop; using a free port`;
} else if (occupant?.runtime) {
message = `Port ${startPort} is used by an existing OpenChamber instance; using a free port`;
} else {
message = `Port ${startPort} in use; using a free port`;
}
if (typeof onNotice === 'function' && message) {
onNotice({
level: 'warning',
code: 'PORT_REASSIGNED',
message,
});
} else if (message) {
console.warn(message);
}
return 0;
}
export { isPortAvailable, resolveAvailablePort };
+292
View File
@@ -0,0 +1,292 @@
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { getRunDir } from './cli-paths.js';
async function getPidFilePath(port) {
return path.join(getRunDir(), `openchamber-${port}.pid`);
}
async function getInstanceFilePath(port) {
return path.join(getRunDir(), `openchamber-${port}.json`);
}
function readPidFile(pidFilePath) {
try {
const content = fs.readFileSync(pidFilePath, 'utf8').trim();
const pid = parseInt(content, 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
function writePidFile(pidFilePath, pid, onNotice) {
try {
fs.writeFileSync(pidFilePath, String(pid), { mode: 0o600 });
} catch (error) {
const message = `Could not write PID file: ${error.message}`;
if (typeof onNotice === 'function') {
onNotice({ level: 'warning', code: 'PID_FILE_WRITE_FAILED', message });
} else {
console.warn(`Warning: ${message}`);
}
}
}
function removePidFile(pidFilePath) {
try {
if (fs.existsSync(pidFilePath)) {
fs.unlinkSync(pidFilePath);
}
} catch {
}
}
function readInstanceOptions(instanceFilePath) {
try {
return JSON.parse(fs.readFileSync(instanceFilePath, 'utf8'));
} catch {
return null;
}
}
function writeInstanceOptions(instanceFilePath, options, onNotice) {
try {
const toStore = {
port: options.port,
host: typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined,
launchMode: options.launchMode === 'foreground' ? 'foreground' : 'daemon',
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : undefined,
hasUiPassword: typeof options.uiPassword === 'string',
apiOnly: options.apiOnly === true,
startedAt: Number.isFinite(options.startedAt) ? options.startedAt : Date.now(),
};
fs.writeFileSync(instanceFilePath, JSON.stringify(toStore, null, 2), { mode: 0o600 });
} catch (error) {
const message = `Could not write instance file: ${error.message}`;
if (typeof onNotice === 'function') {
onNotice({ level: 'warning', code: 'INSTANCE_FILE_WRITE_FAILED', message });
} else {
console.warn(`Warning: ${message}`);
}
}
}
function removeInstanceFile(instanceFilePath) {
try {
if (fs.existsSync(instanceFilePath)) {
fs.unlinkSync(instanceFilePath);
}
} catch {
}
}
// Liveness only — "is *some* process alive with this PID". Use this when the
// PID is known to be ours (a child we just spawned, or a process we are
// stopping). Do NOT use it to validate a PID read from a pid file: after an
// ungraceful shutdown the pid file is stale and the kernel may have recycled
// that PID to an unrelated process — see isOpenchamberProcessRunning.
function isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// Best-effort command line for a live PID, used for identity verification.
// Returns the cmdline string, '' when the process has no readable cmdline, or
// null when identity can't be determined on this platform (caller falls back to
// liveness — so behaviour is unchanged where we can't check).
function readProcessCmdline(pid) {
try {
if (process.platform === 'linux') {
// /proc/<pid>/cmdline is a NUL-delimited argv list.
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
}
if (process.platform === 'darwin') {
const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
const out = (result.stdout || '').trim();
return out.length > 0 ? out : null;
}
} catch {
return null;
}
// Windows / other: a process's full command line isn't cheaply available, so
// we can't verify identity — fall back to liveness-only.
return null;
}
function isOpenchamberCmdline(cmdline) {
if (typeof cmdline !== 'string' || cmdline.length === 0) {
return false;
}
// Every install path contains the "openchamber" segment — the npm package
// (@openchamber/web) and the source checkout both do, for the foreground
// (bin/cli.js) and daemon (server/index.js) entrypoints alike. Matching the
// path segment (not a generic "cli.js") keeps a recycled stranger such as
// "npm-cli.js" or "agentmemory" from being mistaken for us.
return cmdline.toLowerCase().includes('openchamber');
}
// Liveness + identity — "is the OpenChamber instance recorded in a pid file
// still the process running under this PID". Use this (not isProcessRunning)
// when validating a PID read from a pid file. After an ungraceful shutdown
// removePidFile never runs, so the stale PID can be recycled to an unrelated
// process; a liveness-only check then reports us as "already running" and aborts
// startup, which loops forever under systemd Restart=always (issue #1721).
// Where identity can't be determined (Windows, unreadable /proc or ps), we fall
// back to liveness so there are no false negatives on those platforms.
function isOpenchamberProcessRunning(pid) {
const state = getOpenchamberProcessState(pid);
return state === 'matched' || state === 'unknown';
}
function getOpenchamberProcessState(pid, options = {}) {
const checkProcessRunning = typeof options.isProcessRunning === 'function'
? options.isProcessRunning
: isProcessRunning;
if (!Number.isFinite(pid) || pid <= 0 || !checkProcessRunning(pid)) {
return 'dead';
}
const readCmdline = typeof options.readProcessCmdline === 'function'
? options.readProcessCmdline
: readProcessCmdline;
const cmdline = readCmdline(pid);
if (cmdline === null) {
return 'unknown';
}
return isOpenchamberCmdline(cmdline) ? 'matched' : 'mismatched';
}
function hasOpenchamberRuntimeInfo(info) {
return Boolean(info && typeof info.runtime === 'string' && info.runtime.length > 0);
}
function waitForProcessExit(pid, timeoutMs) {
if (!Number.isFinite(pid) || pid <= 0) {
return Promise.resolve(true);
}
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const check = () => {
if (!isProcessRunning(pid)) {
resolve(true);
return;
}
if (Date.now() >= deadline) {
resolve(false);
return;
}
setTimeout(check, 150);
};
check();
});
}
async function terminateProcessTree(pid, options = {}) {
if (!Number.isFinite(pid) || pid <= 0) {
return true;
}
const gracefulTimeoutMs = Number.isFinite(options.gracefulTimeoutMs) && options.gracefulTimeoutMs >= 0
? Math.trunc(options.gracefulTimeoutMs)
: 2500;
const forceTimeoutMs = Number.isFinite(options.forceTimeoutMs) && options.forceTimeoutMs >= 0
? Math.trunc(options.forceTimeoutMs)
: 3000;
if (process.platform === 'win32') {
try {
process.kill(pid);
} catch {
}
if (await waitForProcessExit(pid, 800)) {
return true;
}
try {
spawnSync('taskkill', ['/pid', String(pid), '/t'], {
stdio: 'ignore',
timeout: 3000,
windowsHide: true,
});
} catch {
}
if (await waitForProcessExit(pid, gracefulTimeoutMs)) {
return true;
}
try {
spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], {
stdio: 'ignore',
timeout: 5000,
windowsHide: true,
});
} catch {
}
return waitForProcessExit(pid, forceTimeoutMs);
}
try {
process.kill(pid, 'SIGTERM');
} catch {
}
if (await waitForProcessExit(pid, gracefulTimeoutMs)) {
return true;
}
try {
process.kill(pid, 'SIGKILL');
} catch {
}
return waitForProcessExit(pid, forceTimeoutMs);
}
async function stopInstanceProcess(pid, options = {}) {
if (!Number.isFinite(pid) || pid <= 0) {
return true;
}
const shutdownWaitMs = Number.isFinite(options.shutdownWaitMs) && options.shutdownWaitMs >= 0
? Math.trunc(options.shutdownWaitMs)
: 5000;
if (await waitForProcessExit(pid, shutdownWaitMs)) {
return true;
}
return terminateProcessTree(pid, options);
}
export {
getPidFilePath,
getInstanceFilePath,
readPidFile,
writePidFile,
removePidFile,
readInstanceOptions,
writeInstanceOptions,
removeInstanceFile,
isProcessRunning,
isOpenchamberCmdline,
isOpenchamberProcessRunning,
getOpenchamberProcessState,
hasOpenchamberRuntimeInfo,
terminateProcessTree,
stopInstanceProcess,
};
+370
View File
@@ -0,0 +1,370 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import { DEFAULT_PORT } from './cli-args.js';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { getDataDir } from './cli-paths.js';
import { hasUiPasswordConfigured } from './cli-network.js';
import { searchPathFor } from './cli-executables.js';
const STARTUP_SERVICE_ID = 'dev.openchamber.web';
function getStartupServicePaths() {
if (process.platform === 'darwin') {
return {
platform: 'macos',
servicePath: path.join(os.homedir(), 'Library', 'LaunchAgents', `${STARTUP_SERVICE_ID}.plist`),
};
}
if (process.platform === 'linux') {
return {
platform: 'linux',
servicePath: path.join(os.homedir(), '.config', 'systemd', 'user', 'openchamber.service'),
};
}
if (process.platform === 'win32') {
return { platform: 'windows', servicePath: STARTUP_SERVICE_ID };
}
return { platform: process.platform, servicePath: null };
}
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function systemdEscapeArg(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function startupShellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function systemdUnitPath(value) {
return String(value).replace(/\\/g, '\\\\').replace(/ /g, '\\x20');
}
function powershellQuote(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function startupEnvFileQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function systemdEnvFileQuote(value) {
return `"${String(value)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$')}"`;
}
function getStartupEnvFilePath() {
return path.join(getDataDir(), 'startup.env');
}
function getMacosStartupWrapperPath() {
return path.join(getDataDir(), 'bin', 'OpenChamber');
}
function collectStartupEnv(options = {}) {
const env = options.envSnapshot === false ? {} : Object.fromEntries(
Object.entries(process.env)
.filter(([key, value]) => shouldPersistStartupEnv(key, value))
.map(([key, value]) => [key, String(value)])
);
if (options.envSnapshot !== false) {
const opencodeBinary = process.env.OPENCODE_BINARY || searchPathFor('opencode');
if (typeof opencodeBinary === 'string' && opencodeBinary.trim().length > 0) {
env.OPENCODE_BINARY = opencodeBinary.trim();
}
}
const uiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
if (uiPassword) {
env.OPENCHAMBER_UI_PASSWORD = uiPassword;
}
if (options.apiOnly === true) {
env.OPENCHAMBER_API_ONLY = 'true';
}
if (typeof process.env.OPENCHAMBER_DATA_DIR === 'string' && process.env.OPENCHAMBER_DATA_DIR.trim().length > 0) {
env.OPENCHAMBER_DATA_DIR = path.resolve(process.env.OPENCHAMBER_DATA_DIR.trim());
}
return env;
}
function shouldPersistStartupEnv(key, value) {
if (typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return false;
if (typeof value !== 'string') return false;
if (/[\r\n]/.test(value)) return false;
// These are shell/session implementation details, not app configuration.
const volatileKeys = new Set([
'_',
'BASH_ENV',
'COLUMNS',
'CONDA_DEFAULT_ENV',
'CONDA_PREFIX',
'CONDA_PROMPT_MODIFIER',
'CONDA_SHLVL',
'ENV',
'HISTFILE',
'HISTFILESIZE',
'HISTSIZE',
'LINES',
'OLDPWD',
'PROMPT',
'PROMPT_COMMAND',
'PS1',
'PS2',
'PS3',
'PS4',
'PWD',
'PYENV_VERSION',
'SHLVL',
'TERM',
'TERM_PROGRAM',
'TERM_PROGRAM_VERSION',
'TTY',
'VIRTUAL_ENV',
'VIRTUAL_ENV_PROMPT',
]);
return !volatileKeys.has(key);
}
function writeStartupEnvFile(options = {}, fileOptions = {}) {
const envFilePath = getStartupEnvFilePath();
const lines = [];
const env = collectStartupEnv(options);
const quoteValue = typeof fileOptions.quoteValue === 'function' ? fileOptions.quoteValue : startupEnvFileQuote;
for (const [key, value] of Object.entries(env)) {
lines.push(`${key}=${quoteValue(value)}`);
}
fs.mkdirSync(path.dirname(envFilePath), { recursive: true, mode: 0o700 });
fs.writeFileSync(envFilePath, lines.length > 0 ? `${lines.join('\n')}\n` : '', { mode: 0o600 });
return envFilePath;
}
function removeStartupEnvFile() {
try { fs.unlinkSync(getStartupEnvFilePath()); } catch {}
}
function resolveCliEntrypoint() {
const entry = typeof process.argv[1] === 'string' && process.argv[1].trim().length > 0
? process.argv[1]
: path.join(__dirname, 'cli.js');
try {
return fs.realpathSync(entry);
} catch {
return path.resolve(entry);
}
}
function buildStartupArgs(options = {}) {
const args = [resolveCliEntrypoint(), 'serve', '--foreground', '--port', String(options.port || DEFAULT_PORT)];
if (typeof options.host === 'string' && options.host.length > 0) {
args.push('--host', options.host);
}
if (options.apiOnly === true) {
args.push('--api-only');
}
return args;
}
function writeMacosStartupWrapper(options = {}) {
const wrapperPath = getMacosStartupWrapperPath();
const args = buildStartupArgs(options).map(startupShellQuote).join(' ');
const content = `#!/bin/sh
exec ${startupShellQuote(process.execPath)} ${args}
`;
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(wrapperPath, content, { mode: 0o700 });
return wrapperPath;
}
function buildMacosLaunchAgent(options = {}) {
const wrapperPath = writeMacosStartupWrapper(options);
const args = [wrapperPath];
const env = collectStartupEnv(options);
const logDir = path.join(os.homedir(), 'Library', 'Logs', 'OpenChamber');
const argXml = args.map((arg) => ` <string>${escapeXml(arg)}</string>`).join('\n');
const envXml = Object.entries(env).length > 0
? ` <key>EnvironmentVariables</key>\n <dict>\n${Object.entries(env).map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`).join('\n')}\n </dict>\n`
: '';
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${STARTUP_SERVICE_ID}</string>
<key>ProgramArguments</key>
<array>
${argXml}
</array>
${envXml} <key>ProcessType</key>
<string>Background</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>${escapeXml(os.homedir())}</string>
<key>StandardOutPath</key>
<string>${escapeXml(path.join(logDir, 'startup.log'))}</string>
<key>StandardErrorPath</key>
<string>${escapeXml(path.join(logDir, 'startup.err.log'))}</string>
</dict>
</plist>
`;
}
function buildSystemdUserService(options = {}) {
const args = buildStartupArgs(options).map((arg) => `"${systemdEscapeArg(arg)}"`).join(' ');
const envFilePath = getStartupEnvFilePath();
return `[Unit]
Description=OpenChamber web server
After=network-online.target
[Service]
Type=simple
EnvironmentFile=-${systemdEscapeArg(envFilePath)}
ExecStart="${systemdEscapeArg(process.execPath)}" ${args}
WorkingDirectory=${systemdUnitPath(os.homedir())}
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
`;
}
function runStartupCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: options.stdio || 'pipe',
windowsHide: true,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0 && options.allowFailure !== true) {
const detail = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
}
return result;
}
function getStartupStatus() {
const paths = getStartupServicePaths();
if (!paths.servicePath) {
return { supported: false, platform: paths.platform, enabled: false, servicePath: null };
}
if (paths.platform === 'windows') {
const result = runStartupCommand('schtasks.exe', ['/Query', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
return { supported: true, platform: paths.platform, enabled: result.status === 0, active: null, servicePath: paths.servicePath };
}
if (paths.platform === 'linux') {
const enabledResult = runStartupCommand('systemctl', ['--user', 'is-enabled', 'openchamber.service'], { allowFailure: true });
const activeResult = runStartupCommand('systemctl', ['--user', 'is-active', 'openchamber.service'], { allowFailure: true });
const activeState = (activeResult.stdout || '').trim() || 'inactive';
return {
supported: true,
platform: paths.platform,
enabled: enabledResult.status === 0 || fs.existsSync(paths.servicePath),
active: activeState === 'active',
activeState,
servicePath: paths.servicePath,
};
}
return {
supported: true,
platform: paths.platform,
enabled: fs.existsSync(paths.servicePath),
active: null,
servicePath: paths.servicePath,
};
}
function enableStartupService(options = {}) {
const paths = getStartupServicePaths();
if (!paths.servicePath) {
throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
}
if (paths.platform === 'macos') {
removeStartupEnvFile();
fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
fs.mkdirSync(path.join(os.homedir(), 'Library', 'Logs', 'OpenChamber'), { recursive: true, mode: 0o700 });
fs.writeFileSync(paths.servicePath, buildMacosLaunchAgent(options), { mode: 0o600 });
runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
runStartupCommand('/bin/launchctl', ['bootstrap', `gui/${process.getuid()}`, paths.servicePath]);
runStartupCommand('/bin/launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${STARTUP_SERVICE_ID}`], { allowFailure: true });
return getStartupStatus();
}
if (paths.platform === 'linux') {
writeStartupEnvFile(options, { quoteValue: systemdEnvFileQuote });
fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
fs.writeFileSync(paths.servicePath, buildSystemdUserService(options), { mode: 0o600 });
runStartupCommand('systemctl', ['--user', 'daemon-reload']);
runStartupCommand('systemctl', ['--user', 'enable', '--now', 'openchamber.service']);
return getStartupStatus();
}
const envFilePath = writeStartupEnvFile(options);
const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', ');
const powerShellCommand = [
`$envFile=${powershellQuote(envFilePath)}`,
`if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
`& ${powershellQuote(process.execPath)} ${startupArgs}`,
].join('; ');
const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`;
runStartupCommand('schtasks.exe', [
'/Create',
'/TN', STARTUP_SERVICE_ID,
'/SC', 'ONLOGON',
'/RL', 'LIMITED',
'/F',
'/TR', taskArgs,
]);
runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
return getStartupStatus();
}
function disableStartupService() {
const paths = getStartupServicePaths();
if (!paths.servicePath) {
throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
}
if (paths.platform === 'macos') {
runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
try { fs.unlinkSync(paths.servicePath); } catch {}
return getStartupStatus();
}
if (paths.platform === 'linux') {
runStartupCommand('systemctl', ['--user', 'disable', '--now', 'openchamber.service'], { allowFailure: true });
try { fs.unlinkSync(paths.servicePath); } catch {}
runStartupCommand('systemctl', ['--user', 'daemon-reload'], { allowFailure: true });
return getStartupStatus();
}
runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true });
return getStartupStatus();
}
export {
getStartupStatus,
enableStartupService,
disableStartupService,
};
@@ -0,0 +1,9 @@
import { cloudflareTunnelProviderCapabilities } from '../../server/lib/tunnels/providers/cloudflare.js';
import { ngrokTunnelProviderCapabilities } from '../../server/lib/tunnels/providers/ngrok.js';
const DEFAULT_TUNNEL_PROVIDER_CAPABILITIES = [
cloudflareTunnelProviderCapabilities,
ngrokTunnelProviderCapabilities,
];
export { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES };
+366
View File
@@ -0,0 +1,366 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import {
getTunnelProfilesFilePath,
getLegacyCloudflareManagedRemoteFilePath,
} from './cli-paths.js';
const TUNNEL_PROFILES_VERSION = 1;
const MAX_TOKEN_FILE_BYTES = 8 * 1024;
function normalizeProfileProvider(value) {
if (typeof value !== 'string') return '';
return value.trim().toLowerCase();
}
function normalizeProfileMode(value) {
if (typeof value !== 'string') return '';
return value.trim().toLowerCase();
}
function normalizeProfileName(value) {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeProfileHostname(value) {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeProfileToken(value) {
if (typeof value !== 'string') return '';
return value.trim();
}
function suggestProfileNameFromHostname(hostname) {
const normalizedHost = normalizeProfileHostname(hostname);
if (!normalizedHost) return 'prod-main';
const firstLabel = normalizedHost.split('.')[0] || normalizedHost;
const sanitized = firstLabel.replace(/[^a-zA-Z0-9-_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
return sanitized || 'prod-main';
}
function maskToken(token) {
if (typeof token !== 'string' || token.length === 0) {
return '***';
}
if (token.length <= 4) {
return '*'.repeat(token.length);
}
return `${'*'.repeat(Math.max(4, token.length - 4))}${token.slice(-4)}`;
}
function readTokenFromFileSafely(tokenFilePath) {
const absolutePath = path.resolve(tokenFilePath);
let realPath;
try {
realPath = fs.realpathSync(absolutePath);
} catch (error) {
if (error?.code === 'ENOENT') {
throw new Error(`Token file '${absolutePath}' not found.`);
}
if (error?.code === 'EACCES') {
throw new Error(`Token file '${absolutePath}' is not readable. Check file permissions.`);
}
throw error;
}
let stats;
try {
stats = fs.statSync(realPath);
} catch (error) {
if (error?.code === 'EACCES') {
throw new Error(`Token file '${absolutePath}' is not readable. Check file permissions.`);
}
throw error;
}
if (!stats.isFile()) {
throw new Error(`Token file '${absolutePath}' must be a regular file.`);
}
if (stats.size <= 0) {
throw new Error(`Token file '${absolutePath}' is empty.`);
}
if (stats.size > MAX_TOKEN_FILE_BYTES) {
throw new Error(`Token file '${absolutePath}' is too large (max ${MAX_TOKEN_FILE_BYTES} bytes).`);
}
const raw = fs.readFileSync(realPath, 'utf8');
if (raw.includes('\u0000')) {
throw new Error(`Token file '${absolutePath}' appears to be binary. Use a plain text token file.`);
}
const value = raw.trim();
if (!value) {
throw new Error(`Token file '${absolutePath}' is empty.`);
}
return value;
}
function resolveToken(options) {
const sources = [
options.tokenStdin ? 'stdin' : null,
options.tokenFile ? 'file' : null,
options.token ? 'flag' : null,
].filter(Boolean);
if (sources.length > 1) {
throw new Error(`Multiple token sources specified (${sources.join(', ')}). Use only one of --token, --token-file, or --token-stdin.`);
}
if (options.tokenStdin) {
const fd = fs.openSync('/dev/stdin', 'r');
try {
const buf = Buffer.alloc(65536);
const bytesRead = fs.readSync(fd, buf, 0, buf.length, null);
const value = buf.slice(0, bytesRead).toString('utf8').trim();
if (!value) {
throw new Error('No token received from stdin.');
}
return value;
} finally {
fs.closeSync(fd);
}
}
if (options.tokenFile) {
return readTokenFromFileSafely(options.tokenFile);
}
return typeof options.token === 'string' ? options.token.trim() : undefined;
}
function redactProfileForOutput(profile, showSecrets = false) {
if (!profile || typeof profile !== 'object') {
return profile;
}
return {
...profile,
token: showSecrets ? profile.token : maskToken(profile.token),
};
}
function redactProfilesForOutput(profiles, showSecrets = false) {
if (!Array.isArray(profiles)) {
return profiles;
}
return profiles.map((entry) => redactProfileForOutput(entry, showSecrets));
}
function formatProfileTokenStatus(profile, showSecrets = false) {
const token = typeof profile?.token === 'string' ? profile.token.trim() : '';
if (!token) {
return 'token:missing';
}
if (showSecrets) {
return `token:${token}`;
}
return 'token:present';
}
function sanitizeTunnelProfilesData(data) {
const parsed = data && typeof data === 'object' ? data : {};
const list = Array.isArray(parsed.profiles) ? parsed.profiles : [];
const seen = new Set();
const profiles = [];
for (const entry of list) {
if (!entry || typeof entry !== 'object') continue;
const id = typeof entry.id === 'string' && entry.id.trim().length > 0 ? entry.id.trim() : crypto.randomUUID();
const provider = normalizeProfileProvider(entry.provider);
const mode = normalizeProfileMode(entry.mode);
const name = normalizeProfileName(entry.name);
const hostname = normalizeProfileHostname(entry.hostname);
const token = normalizeProfileToken(entry.token);
if (!provider || !mode || !name || !hostname || !token) continue;
const key = `${provider}::${name.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
profiles.push({
id,
name,
provider,
mode,
hostname,
token,
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
});
}
return { version: TUNNEL_PROFILES_VERSION, profiles };
}
function warnIfUnsafeFilePermissions(filePath, { shouldWarn = true } = {}) {
if (process.platform === 'win32') {
return;
}
if (!shouldWarn) {
return;
}
try {
const stats = fs.statSync(filePath);
const perms = stats.mode & 0o777;
if (perms & 0o077) {
const octal = perms.toString(8).padStart(3, '0');
console.warn(
`Warning: Profile file '${filePath}' has permissions ${octal} (should be 600). ` +
`Other users may be able to read tunnel tokens. Fix with: chmod 600 '${filePath}'`
);
}
} catch {
// File may not exist yet — not an error
}
}
function readTunnelProfilesFromDisk(options = {}) {
const filePath = getTunnelProfilesFilePath();
try {
warnIfUnsafeFilePermissions(filePath, options);
const raw = fs.readFileSync(filePath, 'utf8');
return sanitizeTunnelProfilesData(JSON.parse(raw));
} catch {
return { version: TUNNEL_PROFILES_VERSION, profiles: [] };
}
}
function writeTunnelProfilesToDisk(data) {
const filePath = getTunnelProfilesFilePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(sanitizeTunnelProfilesData(data), null, 2), { encoding: 'utf8', mode: 0o600 });
}
function writeManagedRemotePairsToDiskFromProfiles(profilesData) {
const profiles = sanitizeTunnelProfilesData(profilesData).profiles;
const cloudflareManagedRemote = profiles.filter(
(entry) => entry.provider === 'cloudflare' && entry.mode === 'managed-remote'
);
const tunnels = cloudflareManagedRemote.map((entry) => ({
id: entry.id,
name: entry.name,
hostname: entry.hostname,
token: entry.token,
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
}));
const filePath = getLegacyCloudflareManagedRemoteFilePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ version: 1, tunnels }, null, 2), { encoding: 'utf8', mode: 0o600 });
}
function readLegacyManagedRemoteEntries() {
try {
const raw = fs.readFileSync(getLegacyCloudflareManagedRemoteFilePath(), 'utf8');
const parsed = JSON.parse(raw);
const tunnels = Array.isArray(parsed?.tunnels) ? parsed.tunnels : [];
return tunnels
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const id = typeof entry.id === 'string' && entry.id.trim().length > 0 ? entry.id.trim() : crypto.randomUUID();
const name = normalizeProfileName(entry.name);
const hostname = normalizeProfileHostname(entry.hostname);
const token = normalizeProfileToken(entry.token);
if (!name || !hostname || !token) return null;
return {
id,
name,
provider: 'cloudflare',
mode: 'managed-remote',
hostname,
token,
createdAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
};
})
.filter(Boolean);
} catch {
return [];
}
}
function makeUniqueProfileName(provider, desiredName, existingProfiles) {
const normalizedDesired = normalizeProfileName(desiredName);
if (!normalizedDesired) {
return '';
}
const existingNames = new Set(
existingProfiles
.filter((entry) => entry.provider === provider)
.map((entry) => entry.name.toLowerCase())
);
if (!existingNames.has(normalizedDesired.toLowerCase())) {
return normalizedDesired;
}
let index = 2;
while (true) {
const candidate = `${normalizedDesired}-${index}`;
if (!existingNames.has(candidate.toLowerCase())) {
return candidate;
}
index += 1;
}
}
function ensureTunnelProfilesMigrated(options = {}) {
const current = readTunnelProfilesFromDisk(options);
if (current.profiles.length > 0) {
return current;
}
const legacyEntries = readLegacyManagedRemoteEntries();
if (legacyEntries.length === 0) {
return current;
}
const migratedProfiles = [];
for (const entry of legacyEntries) {
const name = makeUniqueProfileName(entry.provider, entry.name, migratedProfiles);
migratedProfiles.push({ ...entry, name });
}
const migrated = sanitizeTunnelProfilesData({ version: TUNNEL_PROFILES_VERSION, profiles: migratedProfiles });
writeTunnelProfilesToDisk(migrated);
writeManagedRemotePairsToDiskFromProfiles(migrated);
return migrated;
}
function resolveProfileByName(profiles, profileName, provider) {
const normalizedName = normalizeProfileName(profileName).toLowerCase();
const normalizedProvider = normalizeProfileProvider(provider);
const matches = profiles.filter((entry) => {
if (entry.name.toLowerCase() !== normalizedName) return false;
if (!normalizedProvider) return true;
return entry.provider === normalizedProvider;
});
if (matches.length === 0) {
return { profile: null, error: `No tunnel profile found for name '${profileName}'. Run 'openchamber tunnel profile list'.` };
}
if (matches.length > 1) {
return { profile: null, error: `Profile name '${profileName}' exists for multiple providers. Use --provider <id>.` };
}
return { profile: matches[0], error: null };
}
export {
normalizeProfileProvider,
normalizeProfileMode,
normalizeProfileName,
normalizeProfileHostname,
normalizeProfileToken,
suggestProfileNameFromHostname,
maskToken,
resolveToken,
redactProfileForOutput,
redactProfilesForOutput,
formatProfileTokenStatus,
warnIfUnsafeFilePermissions,
writeTunnelProfilesToDisk,
writeManagedRemotePairsToDiskFromProfiles,
ensureTunnelProfilesMigrated,
resolveProfileByName,
};
+300
View File
@@ -0,0 +1,300 @@
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { canPrompt, select as clackSelect, text as clackText, cancel as clackCancel, isCancel as clackIsCancel } from '../cli-output.js';
const TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000;
const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000;
const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000;
const TUNNEL_SESSION_TTL_DEFAULT_MS = 8 * 60 * 60 * 1000;
const TUNNEL_SESSION_TTL_MIN_MS = 5 * 60 * 1000;
const TUNNEL_SESSION_TTL_MAX_MS = 30 * 24 * 60 * 60 * 1000;
const CONNECT_TTL_PICKER_OPTIONS = [
{ value: String(3 * 60 * 1000), label: '3m' },
{ value: String(TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS), label: '30m' },
{ value: String(2 * 60 * 60 * 1000), label: '2h' },
{ value: String(8 * 60 * 60 * 1000), label: '8h' },
{ value: String(24 * 60 * 60 * 1000), label: '24h' },
{ value: '__custom__', label: 'Custom' },
];
const SESSION_TTL_PICKER_OPTIONS = [
{ value: String(60 * 60 * 1000), label: '1h' },
{ value: String(TUNNEL_SESSION_TTL_DEFAULT_MS), label: '8h' },
{ value: String(12 * 60 * 60 * 1000), label: '12h' },
{ value: String(24 * 60 * 60 * 1000), label: '24h' },
{ value: String(7 * 24 * 60 * 60 * 1000), label: '1w' },
{ value: String(30 * 24 * 60 * 60 * 1000), label: '30d' },
{ value: '__custom__', label: 'Custom' },
];
function parseHumanDurationToMs(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.round(value);
}
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10);
}
const normalized = trimmed.replace(/\s+/g, '');
const pattern = /(\d+)(ms|s|m|h|d)/g;
let cursor = 0;
let total = 0;
let match;
while ((match = pattern.exec(normalized)) !== null) {
if (match.index !== cursor) {
return null;
}
cursor = pattern.lastIndex;
const amount = Number.parseInt(match[1], 10);
const unit = match[2];
const unitMs = unit === 'ms'
? 1
: unit === 's'
? 1000
: unit === 'm'
? 60 * 1000
: unit === 'h'
? 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
total += amount * unitMs;
}
if (cursor !== normalized.length) {
return null;
}
return total;
}
function parseTtlMsOrThrow(rawValue, {
flagName,
minMs,
maxMs,
} = {}) {
const parsed = parseHumanDurationToMs(rawValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new TunnelCliError(
`Invalid value for ${flagName}. Use a positive duration like 30m, 24h, 1d, or milliseconds.`,
EXIT_CODE.USAGE_ERROR,
);
}
if (parsed < minMs || parsed > maxMs) {
throw new TunnelCliError(
`${flagName} must be between ${minMs}ms and ${maxMs}ms.`,
EXIT_CODE.USAGE_ERROR,
);
}
return parsed;
}
function formatDurationForCli(ms) {
if (!Number.isFinite(ms) || ms <= 0) {
return null;
}
const value = Math.round(ms);
if (value % (24 * 60 * 60 * 1000) === 0) return `${value / (24 * 60 * 60 * 1000)}d`;
if (value % (60 * 60 * 1000) === 0) return `${value / (60 * 60 * 1000)}h`;
if (value % (60 * 1000) === 0) return `${value / (60 * 1000)}m`;
if (value % 1000 === 0) return `${value / 1000}s`;
return `${value}ms`;
}
function shellQuote(value) {
const text = String(value);
if (/^[A-Za-z0-9._\-/:=]+$/.test(text)) {
return text;
}
return `'${text.replace(/'/g, `'"'"'`)}'`;
}
function buildTunnelStartReplayCommand({
port,
provider,
mode,
profileName,
configPath,
hostname,
connectTtlMs,
sessionTtlMs,
qr,
noQr,
includeTokenPlaceholder,
tokenViaStdin,
tokenFileProvided,
}) {
const parts = ['openchamber', 'tunnel', 'start'];
if (Number.isFinite(port) && port > 0) {
parts.push('--port', String(port));
}
if (profileName) {
parts.push('--profile', shellQuote(profileName));
}
if (provider) {
parts.push('--provider', shellQuote(provider));
}
if (mode) {
parts.push('--mode', shellQuote(mode));
}
if (typeof configPath === 'string' && configPath.trim().length > 0) {
parts.push('--config', shellQuote(configPath));
}
if (typeof hostname === 'string' && hostname.trim().length > 0) {
parts.push('--hostname', shellQuote(hostname));
}
const connectTtl = formatDurationForCli(connectTtlMs);
if (connectTtl) {
parts.push('--connect-ttl', connectTtl);
}
const sessionTtl = formatDurationForCli(sessionTtlMs);
if (sessionTtl) {
parts.push('--session-ttl', sessionTtl);
}
if (qr) parts.push('--qr');
if (noQr) parts.push('--no-qr');
if (includeTokenPlaceholder) {
if (tokenViaStdin) {
parts.push('--token-stdin');
} else if (tokenFileProvided) {
parts.push('--token-file', '<redacted>');
} else {
parts.push('--token', '<redacted>');
}
}
return parts.join(' ');
}
function buildTunnelProfileAddCommand({ provider, hostname }) {
const parts = [
'openchamber',
'tunnel',
'profile',
'add',
'--provider',
shellQuote(provider || 'cloudflare'),
'--mode',
'managed-remote',
'--name',
'<name>',
'--hostname',
shellQuote(hostname || '<hostname>'),
'--token',
'<token>',
];
return parts.join(' ');
}
async function resolveTunnelTtlOverrides(options) {
let connectTtlRaw = typeof options.connectTtl === 'string' ? options.connectTtl : undefined;
let sessionTtlRaw = typeof options.sessionTtl === 'string' ? options.sessionTtl : undefined;
const shouldPrompt = !connectTtlRaw
&& !sessionTtlRaw
&& canPrompt(options);
if (shouldPrompt) {
const connectChoice = await clackSelect({
message: 'Select connect-link TTL',
options: CONNECT_TTL_PICKER_OPTIONS,
});
if (clackIsCancel(connectChoice)) {
clackCancel('Tunnel start cancelled.');
return null;
}
if (connectChoice === '__custom__') {
const enteredConnect = await clackText({
message: 'Enter connect-link TTL (e.g. 30m, 2h, 1d)',
placeholder: '30m',
validate(value) {
try {
parseTtlMsOrThrow(value, {
flagName: '--connect-ttl',
minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
});
return undefined;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid TTL value';
}
},
});
if (clackIsCancel(enteredConnect)) {
clackCancel('Tunnel start cancelled.');
return null;
}
connectTtlRaw = enteredConnect.trim();
} else {
connectTtlRaw = connectChoice;
}
const sessionChoice = await clackSelect({
message: 'Select session TTL',
options: SESSION_TTL_PICKER_OPTIONS,
});
if (clackIsCancel(sessionChoice)) {
clackCancel('Tunnel start cancelled.');
return null;
}
if (sessionChoice === '__custom__') {
const enteredSession = await clackText({
message: 'Enter session TTL (e.g. 8h, 24h, 1d)',
placeholder: '8h',
validate(value) {
try {
parseTtlMsOrThrow(value, {
flagName: '--session-ttl',
minMs: TUNNEL_SESSION_TTL_MIN_MS,
maxMs: TUNNEL_SESSION_TTL_MAX_MS,
});
return undefined;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid TTL value';
}
},
});
if (clackIsCancel(enteredSession)) {
clackCancel('Tunnel start cancelled.');
return null;
}
sessionTtlRaw = enteredSession.trim();
} else {
sessionTtlRaw = sessionChoice;
}
}
const connectTtlMs = connectTtlRaw !== undefined
? parseTtlMsOrThrow(connectTtlRaw, {
flagName: '--connect-ttl',
minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
})
: undefined;
const sessionTtlMs = sessionTtlRaw !== undefined
? parseTtlMsOrThrow(sessionTtlRaw, {
flagName: '--session-ttl',
minMs: TUNNEL_SESSION_TTL_MIN_MS,
maxMs: TUNNEL_SESSION_TTL_MAX_MS,
})
: undefined;
return {
connectTtlMs,
sessionTtlMs,
};
}
export {
buildTunnelStartReplayCommand,
buildTunnelProfileAddCommand,
resolveTunnelTtlOverrides,
};
@@ -0,0 +1,183 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import {
assertSafeBrowserPort,
resolveConfiguredBindHost,
buildLocalUrl,
detectLanIPv4Address,
formatHostForUrl,
} from './cli-network.js';
import { discoverRunningInstances } from './cli-lifecycle.js';
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
import { createRemoteClientAuthRuntime } from '../../server/lib/client-auth/remote-clients.js';
import {
intro as clackIntro,
outro as clackOutro,
log as clackLog,
isJsonMode,
isQuietMode,
printJson,
logStatus,
} from '../cli-output.js';
const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json';
async function resolveConnectUrlServerUrl(options) {
let hostOverride = options.host;
if (typeof hostOverride !== 'string' && !process.env.OPENCHAMBER_HOST) {
const storedOptions = readInstanceOptions(await getInstanceFilePath(options.port));
if (typeof storedOptions?.host === 'string' && storedOptions.host.trim()) {
hostOverride = storedOptions.host.trim();
}
}
const bindHost = resolveConfiguredBindHost(hostOverride);
// A host that's already a full http(s) URL is a public/server URL, not a bind
// address (e.g. `--host https://devchamber.example.com` for a remote deploy
// behind a reverse proxy). Use it directly instead of feeding it to
// buildLocalUrl, which would produce `http://https://...:port`.
const hostAsServerUrl = normalizeServerUrlForConnection(bindHost);
if (hostAsServerUrl) {
return { serverUrl: hostAsServerUrl, source: 'configured-host' };
}
if (!isWildcardBindHost(bindHost)) {
return {
serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''),
source: 'configured-host',
};
}
const lanAddress = await detectLanIPv4Address();
if (!lanAddress) {
return {
serverUrl: buildLocalUrl(options.port, '/').replace(/\/+$/, ''),
source: 'loopback-fallback',
};
}
return {
serverUrl: `http://${formatHostForUrl(lanAddress)}:${options.port}`,
source: 'lan-detected',
};
}
function isWildcardBindHost(host) {
return host === '0.0.0.0' || host === '::' || host === '[::]';
}
function normalizeServerUrlForConnection(value) {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
parsed.hash = '';
return parsed.toString().replace(/\/+$/, '');
} catch {
return null;
}
}
function getOpenChamberDataDir() {
return process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
}
function buildClientConnectionPayload({ serverUrl, token, label }) {
const params = new URLSearchParams();
params.set('v', '1');
params.set('server', serverUrl.trim().replace(/\/+$/, ''));
params.set('token', token.trim());
if (label?.trim()) params.set('label', label.trim());
return `openchamber://connect?${params.toString()}`;
}
async function displayTunnelQrCode(url) {
try {
const qrcode = await import('qrcode-terminal');
console.log('\n📱 Scan this QR code to access the tunnel:\n');
qrcode.default.generate(url, { small: true });
console.log('');
} catch (error) {
console.warn(`Warning: Could not generate QR code: ${error.message}`);
}
}
function createConnectUrlCommand({ serveCommand }) {
return async function connectUrlCommand(options = {}) {
assertSafeBrowserPort(options.port, { context: 'OpenChamber connect-url' });
const explicitServerUrl = options.server ? normalizeServerUrlForConnection(options.server) : null;
if (options.server && !explicitServerUrl) {
throw new TunnelCliError('Invalid --server URL. Use an http:// or https:// URL.', EXIT_CODE.USAGE_ERROR);
}
const running = await discoverRunningInstances();
const serverState = running.some((entry) => entry.port === options.port)
? { port: options.port, autoStarted: false }
: await (async () => {
await serveCommand({
port: options.port,
explicitPort: true,
host: options.host,
uiPassword: options.uiPassword,
apiOnly: options.apiOnly,
suppressUnsafePortWarning: true,
suppressUiPasswordWarning: true,
suppressStartupSummary: true,
suppressQuietOutput: true,
});
return { port: options.port, autoStarted: true };
})();
const resolvedServerUrl = explicitServerUrl
? { serverUrl: explicitServerUrl, source: 'explicit' }
: await resolveConnectUrlServerUrl(options);
const serverUrl = resolvedServerUrl.serverUrl;
const label = options.name || `OpenChamber ${serverUrl}`;
const runtime = createRemoteClientAuthRuntime({
fsPromises: fs.promises,
path,
crypto,
storePath: path.join(getOpenChamberDataDir(), REMOTE_CLIENTS_FILE_NAME),
});
const result = await runtime.createClient({ label });
const connectUrl = buildClientConnectionPayload({ serverUrl, token: result.token, label });
if (isJsonMode(options)) {
printJson({ serverUrl, connectUrl, token: result.token, client: result.client, autoStarted: serverState.autoStarted });
return;
}
if (isQuietMode(options)) {
process.stdout.write(`${connectUrl}\n`);
return;
}
clackIntro('OpenChamber connect URL');
if (serverState.autoStarted) {
logStatus('success', `started OpenChamber on port ${options.port}`);
}
logStatus('success', connectUrl);
clackLog.info(`Server URL: ${serverUrl}`);
if (resolvedServerUrl.source === 'lan-detected') {
clackLog.info('Detected a LAN address because OpenChamber is bound to all interfaces. Use --server to override it.');
} else if (resolvedServerUrl.source === 'loopback-fallback') {
clackLog.warn('OpenChamber is bound to all interfaces, but no LAN address was detected. Use --server to provide a reachable URL.');
}
clackLog.info('Copy this connection link into another OpenChamber client. The token is shown only once.');
if (options.qr === true) {
await displayTunnelQrCode(connectUrl);
}
clackOutro('connect URL generated');
};
}
export { createConnectUrlCommand };
+396
View File
@@ -0,0 +1,396 @@
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { requestServerShutdown } from './cli-http.js';
import { isPortAvailable } from './cli-ports.js';
import {
discoverLifecycleInstances,
discoverUnconfirmedRegistryInstanceOnPort,
} from './cli-lifecycle.js';
import {
readInstanceOptions,
removePidFile,
removeInstanceFile,
isProcessRunning,
stopInstanceProcess,
} from './cli-process.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
createSpinner,
printJson,
logStatus,
} from '../cli-output.js';
async function stopCommand(options) {
const showOutput = shouldRenderHumanOutput(options);
const suppressQuietOutput = options?.suppressQuietOutput === true;
const jsonResults = [];
const printQuietStopResults = () => {
if (suppressQuietOutput) return;
if (!isQuietMode(options) || isJsonMode(options)) return;
if (jsonResults.length === 0) {
process.stdout.write('none\n');
return;
}
for (const result of jsonResults) {
if (result.stopped) {
process.stdout.write(`stopped ${result.port}\n`);
} else {
const reason = result.reason || 'failed';
process.stderr.write(`failed ${result.port} ${reason}\n`);
}
}
};
const finish = (text) => {
if (!showOutput) return;
clackOutro(text);
};
if (showOutput) {
clackIntro('OpenChamber Stop');
}
let runningInstances = await discoverLifecycleInstances(options);
if (options.explicitPort) {
if (runningInstances.length === 0) {
const unconfirmedInstance = await discoverUnconfirmedRegistryInstanceOnPort(options.port, options);
if (unconfirmedInstance) {
runningInstances = [unconfirmedInstance];
}
}
if (runningInstances.length === 0) {
jsonResults.push({ port: options.port, stopped: false, reason: 'not-found' });
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults });
}
if (showOutput) {
logStatus('info', `no OpenChamber instance found on port ${options.port}`);
finish('nothing to stop');
}
printQuietStopResults();
return;
}
const explicitInstance = runningInstances[0];
if (explicitInstance.runtime === 'desktop') {
jsonResults.push({ port: options.port, runtime: 'desktop', stopped: false, reason: 'desktop-managed' });
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults, messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message: `Port ${options.port} is managed by OpenChamber Desktop and cannot be stopped with this command.` }] });
}
if (showOutput) {
logStatus('warning', `port ${options.port} is managed by OpenChamber Desktop`, 'cannot be stopped with this command');
finish('no changes applied');
}
printQuietStopResults();
return;
}
if (explicitInstance.source === 'probe') {
const unmanagedStopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !unmanagedStopSpin) {
logStatus('info', `found unmanaged OpenChamber instance on port ${options.port}`, 'attempting shutdown');
}
unmanagedStopSpin?.start(`Stopping unmanaged OpenChamber on port ${options.port}...`);
const requested = await requestServerShutdown(options.port, options.host);
if (Number.isFinite(explicitInstance.pid) && isProcessRunning(explicitInstance.pid)) {
await stopInstanceProcess(explicitInstance.pid, {
shutdownWaitMs: requested ? 5000 : 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
}).catch(() => false);
}
const stopped = await isPortAvailable(options.port, options.host);
if (stopped) {
unmanagedStopSpin?.stop(`Stopped unmanaged OpenChamber on port ${options.port}`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: true });
if (isJsonMode(options)) {
printJson({ stoppedCount: 1, results: jsonResults });
}
if (showOutput && !unmanagedStopSpin) {
logStatus('success', `stopped OpenChamber on port ${options.port}`);
finish('stop complete');
}
printQuietStopResults();
} else if (requested) {
unmanagedStopSpin?.stop(`Shutdown requested on port ${options.port} (still occupied)`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'shutdown-requested-port-busy' });
if (isJsonMode(options)) {
printJson({
status: 'warning',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'warning', code: 'SHUTDOWN_PARTIAL', message: `Shutdown was requested for port ${options.port}, but the port is still occupied.` }],
});
}
if (showOutput && !unmanagedStopSpin) {
logStatus('warning', `shutdown requested on port ${options.port}`, 'port is still occupied');
finish('partial stop');
}
printQuietStopResults();
} else {
unmanagedStopSpin?.error(`Could not stop OpenChamber on port ${options.port}`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'stop-failed' });
if (isJsonMode(options)) {
printJson({
status: 'error',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber on port ${options.port}.` }],
});
}
if (showOutput && !unmanagedStopSpin) {
logStatus('error', `could not stop OpenChamber on port ${options.port}`);
finish('failed');
}
printQuietStopResults();
}
return;
}
if (explicitInstance.source === 'registry-unconfirmed') {
const unconfirmedStopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !unconfirmedStopSpin) {
logStatus('info', `found unconfirmed OpenChamber pid ${explicitInstance.pid} on port ${options.port}`, 'HTTP shutdown endpoint is unreachable; stopping by PID');
}
unconfirmedStopSpin?.start(`Stopping unconfirmed OpenChamber on port ${options.port}...`);
const stopped = await stopInstanceProcess(explicitInstance.pid, {
shutdownWaitMs: 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
}).catch(() => false);
if (stopped || !isProcessRunning(explicitInstance.pid)) {
removePidFile(explicitInstance.pidFilePath);
removeInstanceFile(explicitInstance.instanceFilePath);
unconfirmedStopSpin?.stop(`Stopped OpenChamber PID ${explicitInstance.pid}`);
jsonResults.push({ port: options.port, pid: explicitInstance.pid, runtime: 'unconfirmed', stopped: true });
if (isJsonMode(options)) {
printJson({ stoppedCount: 1, results: jsonResults });
}
if (showOutput && !unconfirmedStopSpin) {
logStatus('success', `stopped pid ${explicitInstance.pid}`);
finish('stop complete');
}
printQuietStopResults();
return;
}
unconfirmedStopSpin?.error(`Could not stop OpenChamber PID ${explicitInstance.pid}`);
jsonResults.push({ port: options.port, pid: explicitInstance.pid, runtime: 'unconfirmed', stopped: false, reason: 'stop-failed' });
if (isJsonMode(options)) {
printJson({
status: 'error',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber PID ${explicitInstance.pid}.` }],
});
}
if (showOutput && !unconfirmedStopSpin) {
logStatus('error', `could not stop pid ${explicitInstance.pid}`);
finish('failed');
}
printQuietStopResults();
return;
}
} else if (runningInstances.length === 0) {
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults });
}
if (showOutput) {
logStatus('info', 'No running OpenChamber instances found');
finish('nothing to stop');
}
printQuietStopResults();
return;
}
for (const instance of runningInstances) {
const stopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !stopSpin) {
logStatus('info', `stopping port ${instance.port} (PID: ${instance.pid})`);
}
stopSpin?.start(`Stopping OpenChamber on port ${instance.port}...`);
try {
const requested = await requestServerShutdown(instance.port, instance.host || options.host);
const stopped = await stopInstanceProcess(instance.pid, {
shutdownWaitMs: requested ? 5000 : 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
});
if (!stopped && isProcessRunning(instance.pid)) {
throw new Error(`Timed out stopping pid ${instance.pid}`);
}
removePidFile(instance.pidFilePath);
removeInstanceFile(instance.instanceFilePath);
stopSpin?.stop(`Stopped OpenChamber on port ${instance.port}`);
jsonResults.push({ port: instance.port, pid: instance.pid, stopped: true });
if (showOutput && !stopSpin) {
logStatus('success', `stopped port ${instance.port}`);
}
} catch (error) {
stopSpin?.error(`Failed to stop OpenChamber on port ${instance.port}`);
jsonResults.push({ port: instance.port, pid: instance.pid, stopped: false, reason: error instanceof Error ? error.message : String(error) });
if (showOutput) {
logStatus('error', `error stopping port ${instance.port}`, error.message);
} else if (!isJsonMode(options) && !isQuietMode(options)) {
console.error(`Error stopping port ${instance.port}: ${error.message}`);
}
}
}
if (isJsonMode(options)) {
const stoppedCount = jsonResults.filter((entry) => entry.stopped).length;
const hasFailure = jsonResults.some((entry) => !entry.stopped);
printJson({
status: hasFailure ? 'warning' : 'ok',
stoppedCount,
results: jsonResults,
});
return;
}
finish(`${runningInstances.length} instance(s)`);
printQuietStopResults();
}
async function restartCommand(options, serveCommand) {
const commandContext = this && typeof this === 'object' ? this : {};
const runStop = typeof commandContext.stop === 'function'
? commandContext.stop.bind(commandContext)
: stopCommand;
const runServe = typeof commandContext.serve === 'function'
? commandContext.serve.bind(commandContext)
: serveCommand;
const showOutput = shouldRenderHumanOutput(options);
const restarted = [];
if (showOutput) {
clackIntro('OpenChamber Restart');
}
let runningInstances = await discoverLifecycleInstances(options);
if (runningInstances.length === 0) {
if (isJsonMode(options)) {
printJson({ restartedCount: 0, results: restarted });
}
if (showOutput) {
logStatus('info', 'No running OpenChamber instances to restart');
clackOutro('nothing to restart');
} else if (isQuietMode(options)) {
process.stdout.write('restarted 0\n');
}
return;
}
for (const instance of runningInstances) {
if (instance.runtime === 'desktop') {
const message = `Port ${instance.port} is managed by OpenChamber Desktop and cannot be restarted with this command.`;
if (isJsonMode(options)) {
printJson({
status: 'warning',
restartedCount: 0,
results: [{ fromPort: instance.port, runtime: 'desktop', ok: false, reason: 'desktop-managed' }],
messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message }],
});
return;
}
if (showOutput) {
logStatus('warning', `port ${instance.port} is managed by OpenChamber Desktop`, 'cannot be restarted with this command');
clackOutro('no changes applied');
} else if (isQuietMode(options)) {
process.stdout.write('restarted 0\n');
}
return;
}
const storedOptions = instance.instanceFilePath
? (readInstanceOptions(instance.instanceFilePath) || { port: instance.port })
: { port: instance.port };
const instanceHost = storedOptions.host || instance.host || options.host;
const launchMode = instance.launchMode || 'daemon';
const isForeground = launchMode === 'foreground';
const restartPort = options.explicitPort ? options.port : instance.port;
const restartSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !restartSpin) {
logStatus('info', `restarting port ${instance.port}`, `mode: ${launchMode}`);
}
restartSpin?.start(`Restarting OpenChamber on port ${instance.port}...`);
try {
await runStop({
explicitPort: true,
port: instance.port,
host: instanceHost,
quiet: true,
suppressQuietOutput: true,
});
// Foreground instances are managed by a process manager (systemd,
// Docker, etc.) that will restart them automatically after stop.
// Do not call serve() here — just record the stop as a successful
// restart and let the process manager handle the actual restart.
if (isForeground) {
restarted.push({ fromPort: instance.port, toPort: restartPort, launchMode, ok: true });
restartSpin?.stop(`Stopped foreground instance on port ${instance.port} (process manager will restart)`);
if (showOutput && !restartSpin) {
logStatus('success', `port ${instance.port} stopped`, 'process manager will restart');
}
continue;
}
await new Promise((resolve) => setTimeout(resolve, 500));
const restartedPort = await runServe({
port: restartPort,
host: instanceHost,
explicitPort: true,
uiPassword: options.explicitUiPassword ? options.uiPassword : (storedOptions.uiPassword || options.uiPassword),
apiOnly: storedOptions.apiOnly === true || options.apiOnly === true,
suppressStartupSummary: true,
quiet: true,
suppressUiPasswordWarning: true,
suppressQuietOutput: true,
});
restarted.push({ fromPort: instance.port, toPort: restartedPort, launchMode, ok: true });
restartSpin?.stop(`Restarted OpenChamber on port ${restartedPort}`);
if (showOutput && !restartSpin) {
logStatus('success', `port ${restartedPort} restarted`, `mode: ${launchMode}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
restartSpin?.error(`Failed to restart OpenChamber on port ${instance.port}`);
if (showOutput && !restartSpin) {
logStatus('error', `failed to restart port ${instance.port}`, message);
}
throw error;
}
}
if (isJsonMode(options)) {
printJson({ restartedCount: restarted.length, results: restarted.map((r) => ({ ...r, launchMode: r.launchMode })) });
return;
}
if (showOutput) {
clackOutro(`${runningInstances.length} instance(s) restarted`);
} else if (isQuietMode(options)) {
process.stdout.write(`restarted ${restarted.length}\n`);
}
}
function createLifecycleCommands({ serveCommand }) {
return {
stop: stopCommand,
restart(options) {
return restartCommand.call(this, options, serveCommand);
},
};
}
export { createLifecycleCommands };
+110
View File
@@ -0,0 +1,110 @@
import { getLogFilePath } from './cli-paths.js';
import { readTailLines, followFile } from './cli-log-files.js';
import { discoverRunningInstances, getLatestInstance } from './cli-lifecycle.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
shouldRenderHumanOutput,
printJson,
logStatus,
} from '../cli-output.js';
async function logsCommand(options) {
const showFrames = shouldRenderHumanOutput(options);
const shouldPrefixLines = options.all || !showFrames;
let targets = [];
const running = await discoverRunningInstances();
if (options.all) {
targets = running;
if (targets.length === 0) {
throw new Error('No running OpenChamber instance found.');
}
} else if (options.explicitPort) {
const found = running.find((entry) => entry.port === options.port);
if (!found) {
throw new Error(`No running OpenChamber instance found on port ${options.port}.`);
}
targets = [found];
} else {
const latest = getLatestInstance(running);
if (!latest) {
throw new Error('No running OpenChamber instance found.');
}
targets = [latest];
if (shouldRenderHumanOutput(options)) {
logStatus('info', `no port specified; using latest started instance on port ${latest.port}`);
}
}
if (isJsonMode(options)) {
if (options.follow) {
throw new Error('`openchamber logs --json` requires `--no-follow` for deterministic JSON output.');
}
const entries = targets.map((target) => {
const logPath = getLogFilePath(target.port);
return {
port: target.port,
logPath,
lines: readTailLines(logPath, options.lines),
};
});
printJson({ entries });
return;
}
if (showFrames) {
clackIntro('OpenChamber Logs');
}
for (const target of targets) {
const logPath = getLogFilePath(target.port);
const lines = readTailLines(logPath, options.lines);
if (showFrames) {
logStatus('info', `port ${target.port}`, logPath);
}
for (const line of lines) {
if (shouldPrefixLines) {
console.log(`[${target.port}] ${line}`);
} else {
console.log(line);
}
}
}
if (showFrames) {
clackOutro(options.follow ? 'following (Ctrl+C to stop)' : 'tail complete');
}
if (!options.follow) {
return;
}
const unsubs = targets.map((target) => {
const logPath = getLogFilePath(target.port);
return followFile(logPath, (line) => {
if (shouldPrefixLines) {
console.log(`[${target.port}] ${line}`);
} else {
console.log(line);
}
});
});
await new Promise((resolve) => {
const onSignal = () => {
for (const unsub of unsubs) {
unsub();
}
process.off('SIGINT', onSignal);
process.off('SIGTERM', onSignal);
resolve();
};
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
});
}
export { logsCommand };
+396
View File
@@ -0,0 +1,396 @@
import fs from 'fs';
import { pathToFileURL } from 'url';
import { spawn } from 'child_process';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { buildLocalUrl, resolveServeHost, assertSafeBrowserPort, hasUiPasswordConfigured, assertAuthenticatedNetworkExposure } from './cli-network.js';
import { fetchSystemInfoFromPort } from './cli-http.js';
import { isPortAvailable, resolveAvailablePort } from './cli-ports.js';
import { ensureLogsDir, getLogFilePath } from './cli-paths.js';
import { rotateLogFile } from './cli-log-files.js';
import { discoverOpenChamberInstanceOnPort, isDesktopRuntimeForPort } from './cli-lifecycle.js';
import { getPidFilePath, getInstanceFilePath, writePidFile, writeInstanceOptions, removePidFile, removeInstanceFile, isProcessRunning, terminateProcessTree } from './cli-process.js';
import { isNetworkExposedBindHost } from '../../server/lib/security/bind-host.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
createSpinner,
printJson,
logStatus,
} from '../cli-output.js';
const DAEMON_READY_TIMEOUT_MS = 30000;
function createServeCommand({
serverPath,
bunBin,
checkOpenCodeCLI,
getPreferredServerRuntime,
setForegroundServerActive,
setForegroundShutdown,
}) {
async function serveCommand(options) {
const showOutput = shouldRenderHumanOutput(options);
const jsonMessages = [];
const emitNotice = (notice) => {
if (!notice || typeof notice !== 'object' || typeof notice.message !== 'string') return;
const level = notice.level === 'error' ? 'error' : (notice.level === 'warning' ? 'warning' : 'info');
if (isJsonMode(options)) {
jsonMessages.push({
level,
code: notice.code,
message: notice.message,
});
return;
}
if (showOutput) {
logStatus(level, notice.message);
return;
}
if (!isQuietMode(options)) {
const prefix = level === 'warning' ? 'Warning' : level === 'error' ? 'Error' : 'Info';
const line = `${prefix}: ${notice.message}`;
if (level === 'error') {
console.error(line);
} else {
console.warn(line);
}
}
};
const explicitPort = options.explicitPort === true;
const effectiveHost = resolveServeHost(options.host);
const targetPort = await resolveAvailablePort(options.port, explicitPort, emitNotice);
if (targetPort !== 0 && !options.suppressUnsafePortWarning) {
assertSafeBrowserPort(targetPort, { context: 'OpenChamber serve' });
}
if (targetPort !== 0) {
const existingInstance = await discoverOpenChamberInstanceOnPort(targetPort, { host: effectiveHost });
if (existingInstance?.runtime === 'desktop') {
throw new Error(
`Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.`
);
}
if (existingInstance) {
const pidSuffix = Number.isFinite(existingInstance.pid) ? ` (PID: ${existingInstance.pid})` : '';
if (existingInstance.source === 'probe') {
throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`);
}
throw new Error(`OpenChamber is already running on port ${targetPort}${pidSuffix}`);
}
if (explicitPort && !(await isPortAvailable(targetPort, effectiveHost))) {
const systemInfo = await fetchSystemInfoFromPort(targetPort, globalThis.fetch, effectiveHost);
if (isDesktopRuntimeForPort(systemInfo, targetPort)) {
throw new Error(
`Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.`
);
}
const systemInfoRuntimeMatchesPort = systemInfo?.runtime !== 'desktop' || isDesktopRuntimeForPort(systemInfo, targetPort);
if (systemInfo?.runtime && systemInfoRuntimeMatchesPort) {
throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`);
}
throw new Error(`Port ${targetPort} is already in use by another process.`);
}
}
const opencodeBinary = await checkOpenCodeCLI(emitNotice);
const preferredRuntime = getPreferredServerRuntime();
const runtimeBin = preferredRuntime === 'bun' ? bunBin : process.execPath;
ensureLogsDir();
const initialLogPort = targetPort === 0 ? 'auto' : String(targetPort);
const initialLogPath = getLogFilePath(initialLogPort);
rotateLogFile(initialLogPath);
const logFd = fs.openSync(initialLogPath, 'a');
const effectiveUiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
assertAuthenticatedNetworkExposure({
host: effectiveHost,
uiPassword: effectiveUiPassword,
});
if (!effectiveUiPassword && !options.suppressUiPasswordWarning) {
const bindHost = effectiveHost;
const networkExposed = isNetworkExposedBindHost(bindHost);
const warningLine = 'OPENCHAMBER_UI_PASSWORD is not set';
const warningDetail = networkExposed
? `server is bound to ${bindHost} and reachable on your network with no UI auth. `
+ 'Set --ui-password or OPENCHAMBER_UI_PASSWORD before exposing it over LAN.'
: 'browser UI is unsecured. Use --ui-password or OPENCHAMBER_UI_PASSWORD.';
if (showOutput) {
logStatus('warning', warningLine, warningDetail);
} else if (isJsonMode(options)) {
emitNotice({
level: 'warning',
code: 'UI_PASSWORD_MISSING',
message: `${warningLine}; ${warningDetail}`,
});
} else if (!isQuietMode(options)) {
console.warn(`Warning: ${warningLine}; ${warningDetail}`);
}
}
// Foreground mode: run server inline so the CLI process is the server process.
// Required for process managers like systemd (Type=simple) that track the
// direct child rather than a detached grandchild.
// IMPORTANT: foreground MUST remain inline (in-process). Do not convert to
// child-process orchestration — that causes shell job-control suspension.
if (options.foreground) {
if (isJsonMode(options)) {
throw new TunnelCliError(
'--json is not supported with --foreground. Use --json with background (daemon) mode instead.',
EXIT_CODE.USAGE_ERROR
);
}
// Propagate resolved values into env before importing the server module.
if (opencodeBinary) {
process.env.OPENCODE_BINARY = opencodeBinary;
}
if (effectiveUiPassword) {
process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword;
}
process.env.OPENCHAMBER_HOST = effectiveHost;
process.env.OPENCHAMBER_RUNTIME = 'web';
// In --quiet mode, redirect stdout/stderr to the log file so that
// server runtime output (console.log calls) does not pollute the
// deterministic CLI output contract. In plain human mode, close the
// log fd and let output go to the inherited terminal as before.
const suppressServerOutput = isQuietMode(options);
// Keep a reference to the real stdout.write so CLI output (port, JSON)
// can bypass the log-file redirect.
const realStdoutWrite = process.stdout.write.bind(process.stdout);
if (suppressServerOutput) {
const logStream = fs.createWriteStream(null, { fd: logFd });
process.stdout.write = (chunk, encoding, callback) => {
return logStream.write(chunk, encoding, callback);
};
process.stderr.write = (chunk, encoding, callback) => {
return logStream.write(chunk, encoding, callback);
};
} else {
// Close the log fd in foreground human mode stdout/stderr are
// inherited from the parent (e.g. journald/terminal).
try {
fs.closeSync(logFd);
} catch {
}
}
if (!isQuietMode(options)) {
console.log(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort} (foreground)`);
}
const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
const controller = await startWebUiServer({
port: targetPort,
host: effectiveHost,
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
attachSignals: false,
exitOnShutdown: false,
});
const resolvedPort = controller.getPort();
// Write PID / instance files so status, stop, and restart can discover
// this foreground instance the same way they discover daemon instances.
const fgPidFilePath = await getPidFilePath(resolvedPort);
const fgInstanceFilePath = await getInstanceFilePath(resolvedPort);
writePidFile(fgPidFilePath, process.pid, emitNotice);
writeInstanceOptions(fgInstanceFilePath, {
port: resolvedPort,
host: effectiveHost,
launchMode: 'foreground',
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
}, emitNotice);
if (isQuietMode(options)) {
if (!options.suppressQuietOutput) {
realStdoutWrite(`${resolvedPort}\n`);
}
}
// Clean up PID / instance files.
const cleanupFiles = () => {
removePidFile(fgPidFilePath);
removeInstanceFile(fgInstanceFilePath);
};
process.on('exit', cleanupFiles);
// Idempotent graceful shutdown with deterministic exit codes.
let shutdownInProgress = false;
const shutdownForegroundServer = async (signal = 'SIGTERM') => {
if (shutdownInProgress) return;
shutdownInProgress = true;
try {
await controller.stop({ exitProcess: false });
} catch {
}
cleanupFiles();
setForegroundServerActive(false);
setForegroundShutdown(null);
const exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGQUIT' ? 131 : 143;
process.exit(exitCode);
};
// Expose shutdown to the global SIGINT handler.
setForegroundShutdown(shutdownForegroundServer);
setForegroundServerActive(true);
// Register signal handlers (additive, no removeAllListeners).
process.on('SIGINT', () => { void shutdownForegroundServer('SIGINT'); });
process.on('SIGTERM', () => { void shutdownForegroundServer('SIGTERM'); });
process.on('SIGQUIT', () => { void shutdownForegroundServer('SIGQUIT'); });
// Block forever the process stays alive until signalled.
await new Promise(() => {});
}
const serverArgs = [serverPath, '--port', String(targetPort)];
serverArgs.push('--host', effectiveHost);
if (options.apiOnly === true) {
serverArgs.push('--api-only');
}
const serveSpin = showOutput ? createSpinner(options) : null;
const child = spawn(runtimeBin, serverArgs, {
detached: true,
windowsHide: true,
stdio: ['ignore', logFd, logFd, 'ipc'],
env: {
...process.env,
OPENCHAMBER_PORT: String(targetPort),
OPENCHAMBER_RUNTIME: 'web',
OPENCODE_BINARY: opencodeBinary,
OPENCHAMBER_HOST: effectiveHost,
...(effectiveUiPassword ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
...(options.apiOnly === true ? { OPENCHAMBER_API_ONLY: 'true' } : {}),
...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}),
},
});
child.unref();
serveSpin?.start(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort}...`);
let resolvedPort;
try {
resolvedPort = await new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error(`OpenChamber daemon did not report ready within ${DAEMON_READY_TIMEOUT_MS / 1000}s`));
}, DAEMON_READY_TIMEOUT_MS);
child.on('message', (msg) => {
if (settled) return;
if (msg && msg.type === 'openchamber:ready' && typeof msg.port === 'number') {
settled = true;
clearTimeout(timeout);
resolve(msg.port);
}
});
child.on('error', (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(error);
});
child.on('exit', (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error(`OpenChamber daemon exited before reporting ready${signal ? ` (${signal})` : ` (code ${code ?? 'unknown'})`}`));
});
});
} catch (error) {
await terminateProcessTree(child.pid, { gracefulTimeoutMs: 1500, forceTimeoutMs: 1500 });
throw error;
}
try {
if (typeof child.disconnect === 'function' && child.connected) {
child.disconnect();
}
} catch {
}
try {
fs.closeSync(logFd);
} catch {
}
const resolvedLogPath = getLogFilePath(resolvedPort);
if (initialLogPath !== resolvedLogPath && !fs.existsSync(resolvedLogPath)) {
try {
fs.renameSync(initialLogPath, resolvedLogPath);
} catch {
}
}
if (!isProcessRunning(child.pid)) {
serveSpin?.error('Failed to start OpenChamber');
throw new Error('Failed to start server in daemon mode');
}
const pidFilePath = await getPidFilePath(resolvedPort);
const instanceFilePath = await getInstanceFilePath(resolvedPort);
writePidFile(pidFilePath, child.pid, emitNotice);
writeInstanceOptions(instanceFilePath, {
port: resolvedPort,
host: effectiveHost,
launchMode: 'daemon',
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
}, emitNotice);
const serveResult = {
port: resolvedPort,
pid: child.pid,
url: buildLocalUrl(resolvedPort, '/'),
logs: `openchamber logs -p ${resolvedPort}`,
launchMode: 'daemon',
};
if (isJsonMode(options)) {
printJson({ ...serveResult, messages: jsonMessages });
return resolvedPort;
}
if (isQuietMode(options)) {
if (options.suppressQuietOutput) {
return resolvedPort;
}
process.stdout.write(`${resolvedPort}\n`);
return resolvedPort;
}
serveSpin?.clear();
if (!options.suppressStartupSummary && showOutput) {
clackIntro('OpenChamber Started');
logStatus('success', `port ${serveResult.port} (PID: ${serveResult.pid})`);
logStatus('info', `visit: ${serveResult.url}`);
logStatus('info', `logs: ${serveResult.logs}`);
clackOutro('daemon running');
}
return resolvedPort;
}
return serveCommand;
}
export { createServeCommand };
+64
View File
@@ -0,0 +1,64 @@
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { getStartupStatus, enableStartupService, disableStartupService } from './cli-startup.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
printJson,
logStatus,
} from '../cli-output.js';
async function startupCommand(options, action = 'status') {
const normalized = typeof action === 'string' ? action.trim().toLowerCase() : 'status';
if (!['status', 'enable', 'disable'].includes(normalized)) {
throw new TunnelCliError(
`Unknown startup subcommand '${action}'. Use 'openchamber startup --help'.`,
EXIT_CODE.USAGE_ERROR
);
}
let status;
if (normalized === 'enable') {
status = enableStartupService(options);
} else if (normalized === 'disable') {
status = disableStartupService();
} else {
status = getStartupStatus();
}
const result = { action: normalized, ...status };
if (!result.supported) {
throw new TunnelCliError(
`Startup integration is not supported on ${result.platform}.`,
EXIT_CODE.USAGE_ERROR
);
}
if (normalized === 'enable' && result.activeState === 'failed') {
throw new TunnelCliError(
'Startup service was installed but failed to start. Run `journalctl --user -u openchamber.service -n 80 --no-pager` for details.',
EXIT_CODE.GENERAL_ERROR
);
}
if (isJsonMode(options)) {
printJson(result);
return;
}
if (isQuietMode(options)) {
process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}\n`);
return;
}
clackIntro('OpenChamber Startup');
logStatus(result.enabled ? 'success' : 'info', `startup ${result.enabled ? 'enabled' : 'disabled'}`, result.servicePath || undefined);
if (typeof result.activeState === 'string') {
logStatus(result.active ? 'success' : result.activeState === 'failed' ? 'error' : 'warning', `service ${result.activeState}`);
}
if (normalized === 'enable') {
logStatus('info', 'service command', 'openchamber serve --foreground');
}
clackOutro(normalized === 'status' ? 'status complete' : `${normalized} complete`);
}
export { startupCommand };
+114
View File
@@ -0,0 +1,114 @@
import { readInstanceOptions } from './cli-process.js';
import { discoverLifecycleInstances, discoverDesktopInstance } from './cli-lifecycle.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
printJson,
logStatus,
} from '../cli-output.js';
async function statusCommand(options = {}) {
const [runningInstances, desktopInstance] = options.explicitPort
? [await discoverLifecycleInstances(options), null]
: await Promise.all([
discoverLifecycleInstances(options),
discoverDesktopInstance(),
]);
const toPasswordProtectionLabel = (value) => {
if (value === true) return 'yes';
if (value === false) return 'no';
return 'unknown';
};
const desktopOnly = desktopInstance && !runningInstances.some((entry) => entry.port === desktopInstance.port)
? {
runtime: 'desktop',
port: desktopInstance.port,
pid: Number.isFinite(desktopInstance.pid) ? desktopInstance.pid : null,
launchMode: null,
passwordProtected: null,
}
: null;
const cliInstances = runningInstances
.filter((instance) => instance.runtime !== 'desktop')
.map((instance) => {
const storedOptions = instance.instanceFilePath ? (readInstanceOptions(instance.instanceFilePath) || {}) : {};
const passwordProtected = storedOptions.hasUiPassword === true
|| (typeof storedOptions.uiPassword === 'string' && storedOptions.uiPassword.trim().length > 0);
return {
runtime: instance.source === 'probe' ? 'unmanaged' : 'cli',
port: instance.port,
pid: instance.pid,
launchMode: instance.launchMode || 'daemon',
passwordProtected: instance.source === 'probe' ? null : passwordProtected,
};
});
const explicitDesktop = options.explicitPort
? runningInstances.find((entry) => entry.runtime === 'desktop')
: null;
const instances = desktopOnly ? [...cliInstances, desktopOnly] : cliInstances;
if (explicitDesktop) {
instances.push({
runtime: 'desktop',
port: explicitDesktop.port,
pid: Number.isFinite(explicitDesktop.pid) ? explicitDesktop.pid : null,
launchMode: null,
passwordProtected: null,
});
}
const runningCount = instances.length;
if (isJsonMode(options)) {
printJson({
state: runningCount > 0 ? 'running' : 'stopped',
runningCount,
instances,
});
return;
}
if (isQuietMode(options)) {
if (runningCount === 0) {
process.stdout.write('stopped\n');
return;
}
for (const instance of instances) {
process.stdout.write(
`port ${instance.port} mode:${instance.launchMode || 'n/a'} pass:${toPasswordProtectionLabel(instance.passwordProtected)}\n`
);
}
return;
}
clackIntro('OpenChamber Status');
if (runningCount === 0) {
logStatus('warning', 'stopped');
clackOutro('no running instances');
return;
}
for (const instance of instances) {
const pidSuffix = Number.isFinite(instance.pid) ? ` (PID: ${instance.pid})` : '';
const modeDetail = instance.launchMode ? `mode: ${instance.launchMode}` : '';
const protectionDetail = `password: ${toPasswordProtectionLabel(instance.passwordProtected)}`;
const detail = modeDetail ? `${modeDetail}; ${protectionDetail}` : protectionDetail;
if (instance.runtime === 'desktop') {
logStatus('info', `desktop app on port ${instance.port}${pidSuffix}`, detail);
} else {
logStatus('success', `port ${instance.port}${pidSuffix}`, detail);
}
}
clackOutro(`${runningCount} running runtime(s)`);
}
export { statusCommand };
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
import { requestServerShutdown } from './cli-http.js';
import { discoverRunningInstances } from './cli-lifecycle.js';
import {
readInstanceOptions,
removePidFile,
stopInstanceProcess,
} from './cli-process.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
createSpinner,
printJson,
logStatus,
} from '../cli-output.js';
function createUpdateCommand({ importFromFilePath, packageManagerPath, serveCommand }) {
return async function updateCommand(options = {}) {
const showOutput = shouldRenderHumanOutput(options);
const updateSpin = createSpinner(options);
const {
checkForUpdates,
executeUpdate,
detectPackageManager,
getCurrentVersion,
} = await importFromFilePath(packageManagerPath);
const runningInstances = await discoverRunningInstances();
const currentVersion = getCurrentVersion();
if (showOutput) {
clackIntro('OpenChamber Update');
}
if (showOutput && !updateSpin) {
logStatus('info', `current version: ${currentVersion}`);
}
updateSpin?.start('Checking for updates...');
const updateInfo = await checkForUpdates();
if (updateInfo.error) {
updateSpin?.error('Update check failed');
if (showOutput) {
clackOutro('update failed');
}
throw new Error(updateInfo.error);
}
if (!updateInfo.available) {
if (isJsonMode(options)) {
printJson({
currentVersion,
latestVersion: updateInfo.version || currentVersion,
updated: false,
});
return;
}
if (showOutput && !updateSpin) {
logStatus('success', 'you are running the latest version');
}
updateSpin?.stop('Already up to date');
if (showOutput) {
clackOutro('no update needed');
} else if (isQuietMode(options)) {
process.stdout.write(`up-to-date ${currentVersion}\n`);
}
return;
}
if (showOutput && !updateSpin) {
logStatus('info', `updating ${updateInfo.currentVersion || currentVersion} -> ${updateInfo.version || 'latest'}`);
}
updateSpin?.message(`Updating to ${updateInfo.version || 'latest'}...`);
if (runningInstances.length > 0) {
updateSpin?.message(`Stopping ${runningInstances.length} running instance(s)...`);
for (const instance of runningInstances) {
try {
const requested = await requestServerShutdown(instance.port, instance.host);
await stopInstanceProcess(instance.pid, {
shutdownWaitMs: requested ? 5000 : 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
});
removePidFile(instance.pidFilePath);
} catch {
}
}
}
const pm = detectPackageManager();
const result = executeUpdate(pm, { silent: isJsonMode(options) || isQuietMode(options) });
if (!result.success) {
updateSpin?.error('Update failed');
if (showOutput) {
clackOutro('update failed');
}
throw new Error(`Update failed with exit code ${result.exitCode}`);
}
if (runningInstances.length > 0) {
updateSpin?.message(`Restarting ${runningInstances.length} instance(s)...`);
for (const instance of runningInstances) {
const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
await serveCommand({
port: storedOptions.port || instance.port,
host: storedOptions.host,
explicitPort: true,
uiPassword: storedOptions.uiPassword,
suppressStartupSummary: true,
suppressUiPasswordWarning: true,
quiet: true,
});
}
}
if (showOutput && !updateSpin) {
logStatus('success', `updated to ${updateInfo.version || 'latest'}`);
}
updateSpin?.stop(`Updated to ${updateInfo.version || 'latest'}`);
if (isJsonMode(options)) {
printJson({
currentVersion,
latestVersion: updateInfo.version || 'latest',
updated: true,
restartedCount: runningInstances.length,
});
return;
}
if (showOutput) {
clackOutro('update complete');
} else if (isQuietMode(options)) {
process.stdout.write(`updated ${updateInfo.version || 'latest'}\n`);
}
};
}
export { createUpdateCommand };
@@ -0,0 +1,50 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it, vi } from 'vitest';
import { createUpdateCommand } from './commands-update.js';
async function withTempOpenChamberDataDir(fn) {
const previous = process.env.OPENCHAMBER_DATA_DIR;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-update-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 });
}
}
describe('update command', () => {
it('uses the package-manager helpers on the update-available path', async () => {
await withTempOpenChamberDataDir(async () => {
const originalWrite = process.stdout.write;
process.stdout.write = vi.fn(() => true);
const executeUpdate = vi.fn(() => ({ success: true, exitCode: 0 }));
const updateCommand = createUpdateCommand({
packageManagerPath: '/fake/package-manager.js',
serveCommand: vi.fn(),
importFromFilePath: vi.fn(async () => ({
checkForUpdates: vi.fn(async () => ({ available: true, version: '9.9.9' })),
detectPackageManager: vi.fn(() => 'npm'),
executeUpdate,
getCurrentVersion: vi.fn(() => '1.0.0'),
})),
});
try {
await updateCommand({ json: true });
expect(executeUpdate).toHaveBeenCalledWith('npm', { silent: true });
} finally {
process.stdout.write = originalWrite;
}
});
});
});
+59
View File
@@ -4,6 +4,65 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>OpenChamber Mobile</title>
<script>
// Blocking script: resolve the theme and paint the correct background BEFORE first
// paint. Without this the WebView shows its default (light) canvas until React mounts
// and the theme CSS variables are applied a frame later, which flashed
// white -> default-theme -> resolved-theme on every cold launch. Mirrors the same
// pre-paint logic in index.html, but the mobile shell has no `#initial-loading`
// overlay, so we paint the document background directly. Bundled-only: reads the
// theme the app itself persisted to localStorage; no server involved.
(function () {
try {
var themeMode = localStorage.getItem('themeMode');
var useSystem = localStorage.getItem('useSystemTheme');
var variant = localStorage.getItem('selectedThemeVariant');
var isDark;
if (themeMode === 'dark') {
isDark = true;
} else if (themeMode === 'light') {
isDark = false;
} else if (themeMode === 'system' || useSystem === null || useSystem === 'true') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else if (variant === 'light' || variant === 'dark') {
isDark = variant === 'dark';
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
var root = document.documentElement;
root.classList.add(isDark ? 'dark' : 'light');
// color-scheme also drives the WebView's default canvas color, so the gap before
// CSS/React loads matches instead of defaulting to light.
root.style.setProperty('color-scheme', isDark ? 'dark' : 'light');
// splashBg* are the resolved theme surface.background colors persisted by the app
// theme system (see ThemeSystemContext). When absent (e.g. a fresh install before
// the theme has been persisted once), fall back to the default theme's
// surface.background — flexoki-dark/light (see lib/theme/themes), which is what the
// app renders on first launch — so the pre-paint matches instead of flashing a
// different colour.
var bgDark = localStorage.getItem('splashBgDark') || '#171515';
var bgLight = localStorage.getItem('splashBgLight') || '#fffdf4';
var bg = isDark ? bgDark : bgLight;
// Set the actual --background CSS variable, not just the element background.
// `<body class="bg-background">` paints `var(--background)`, and design-system.css
// ships a baked default (.dark { --background: ... /* #151313 */ }) that otherwise
// overpaints our html background the moment the stylesheet loads — before React's
// theme system injects the real (flexoki/custom) vars with !important. Setting the
// var inline on the root wins over that non-!important default, so body paints the
// correct colour immediately; React's later !important vars still take over.
root.style.setProperty('--background', bg);
root.style.backgroundColor = bg;
} catch (error) {
/* no-op: a missing/unavailable localStorage just defers to React's theme init */
}
})();
</script>
<script type="module" src="/src/mobile-main.tsx"></script>
</head>
<body class="h-full bg-background text-foreground">
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@openchamber/web",
"version": "1.13.2",
"version": "1.13.8",
"private": false,
"type": "module",
"main": "./server/index.js",
@@ -25,8 +25,8 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.7",
"@simplewebauthn/server": "13.3.0",
"@opencode-ai/sdk": "^1.17.12",
"@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.10.0",
"bun-pty": "^0.4.5",
+75 -10
View File
@@ -9,6 +9,7 @@ import net from 'net';
import { fileURLToPath } from 'url';
import os from 'os';
import crypto from 'crypto';
import http2 from 'node:http2';
import { createUiAuth } from './lib/ui-auth/ui-auth.js';
import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js';
@@ -79,11 +80,13 @@ import { registerNotificationRoutes } from './lib/notifications/routes.js';
import { createNotificationEmitterRuntime } from './lib/notifications/emitter-runtime.js';
import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js';
import { createPushRuntime } from './lib/notifications/push-runtime.js';
import { createApnsRuntime } from './lib/notifications/apns-runtime.js';
import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js';
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
import webPush from 'web-push';
@@ -135,9 +138,14 @@ const SSE_PATH_PREFIXES = [
'/api/global/event',
'/api/notifications/stream',
'/api/openchamber/events',
'/api/openchamber/realtime-proxy/sse',
];
function shouldSkipCompression(req, res) {
if (process.env.OPENCHAMBER_RUNTIME === 'desktop') {
return true;
}
if (headerIncludesEventStream(req.headers.accept)) {
return true;
}
@@ -269,6 +277,7 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
: path.join(os.homedir(), '.config', 'openchamber');
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json');
const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json');
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json');
const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json');
@@ -371,12 +380,34 @@ const getOrCreateVapidKeys = (...args) => pushRuntime.getOrCreateVapidKeys(...ar
const addOrUpdatePushSubscription = (...args) => pushRuntime.addOrUpdatePushSubscription(...args);
const removePushSubscription = (...args) => pushRuntime.removePushSubscription(...args);
const sendPushToAllUiSessions = (...args) => pushRuntime.sendPushToAllUiSessions(...args);
const updateUiVisibility = (...args) => pushRuntime.updateUiVisibility(...args);
// Set once the notification trigger runtime exists (declared later). When a UI
// client reports it became visible, reset the native push badge set — the same
// moment the device zeroes its icon badge on becomeActive, keeping them in sync.
let clearPendingPushBadge = () => {};
const updateUiVisibility = (token, visible, platform) => {
if (visible === true) clearPendingPushBadge();
return pushRuntime.updateUiVisibility(token, visible, platform);
};
const isAnyUiVisible = (...args) => pushRuntime.isAnyUiVisible(...args);
const isAnyInteractiveClientVisible = (...args) => pushRuntime.isAnyInteractiveClientVisible(...args);
const isUiVisible = (...args) => pushRuntime.isUiVisible(...args);
const ensurePushInitialized = (...args) => pushRuntime.ensurePushInitialized(...args);
const setPushInitialized = (...args) => pushRuntime.setPushInitialized(...args);
const apnsRuntime = createApnsRuntime({
fsPromises,
path,
crypto,
http2,
APNS_TOKENS_FILE_PATH,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
});
const addOrUpdateApnsToken = (...args) => apnsRuntime.addOrUpdateApnsToken(...args);
const removeApnsToken = (...args) => apnsRuntime.removeApnsToken(...args);
const sendApnsToAllUiSessions = (...args) => apnsRuntime.sendApnsToAllUiSessions(...args);
const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128;
const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000;
const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000;
@@ -670,12 +701,15 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({
emitDesktopNotification,
broadcastUiNotification,
sendPushToAllUiSessions,
sendApnsToAllUiSessions,
isAnyInteractiveClientVisible,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
});
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge();
const globalMessageStreamHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
@@ -1087,6 +1121,9 @@ async function main(options = {}) {
if (typeof options.getIsWindowFocused === 'function') {
notificationTriggerRuntime.setGetIsWindowFocused(options.getIsWindowFocused);
}
const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function'
? options.getDesktopRuntimeConfig
: null;
console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`);
@@ -1094,7 +1131,13 @@ async function main(options = {}) {
const app = express();
const serverStartedAt = new Date().toISOString();
const packagedClientOrigins = new Set(['openchamber-ui://app']);
const packagedClientOrigins = new Set([
'openchamber-ui://app',
'capacitor://localhost',
'http://localhost',
'https://localhost',
]);
const isLocalDevClientOrigin = (origin) => /^https?:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin);
app.set('trust proxy', true);
// Keep self-hosted instances out of search engines. The app shell is served
// publicly (it loads before prompting for the UI password), so without this
@@ -1109,7 +1152,7 @@ async function main(options = {}) {
});
app.use((req, res, next) => {
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
if (packagedClientOrigins.has(origin)) {
if (packagedClientOrigins.has(origin) || isLocalDevClientOrigin(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
@@ -1132,6 +1175,7 @@ async function main(options = {}) {
}));
expressApp = app;
server = http.createServer(app);
let realtimeProxyRuntime = { stop: () => {} };
const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, {
process,
@@ -1183,7 +1227,10 @@ async function main(options = {}) {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
addOrUpdateApnsToken,
removeApnsToken,
updateUiVisibility,
clearPendingPushBadge: () => clearPendingPushBadge(),
isUiVisible,
getUiNotificationClients: () => uiNotificationClients,
writeSseEvent,
@@ -1202,6 +1249,13 @@ async function main(options = {}) {
setAutoAcceptSession,
});
uiAuthController = bootstrapResult.uiAuthController;
realtimeProxyRuntime = attachRealtimeProxy({
app,
server,
getDesktopRuntimeConfig,
getUiAuthController: () => uiAuthController,
isRequestOriginAllowed,
});
const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port);
const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext;
@@ -1327,13 +1381,24 @@ async function main(options = {}) {
}),
isReady: () => isOpenCodeReady,
restartOpenCode: () => restartOpenCode(),
getOpenCodeProcessInfo: () => ({
managed: Boolean((openCodeProcess || openCodePort) && !ENV_SKIP_OPENCODE_START && !isExternalOpenCode),
pid: typeof openCodeProcess?.pid === 'number' ? openCodeProcess.pid : null,
port: openCodePort,
}),
stop: (shutdownOptions = {}) =>
gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false })
getOpenCodeProcessInfo: () => {
const managed = Boolean((openCodeProcess || openCodePort) && !ENV_SKIP_OPENCODE_START && !isExternalOpenCode);
// Only ever expose pid/port for a server WE manage. The Electron-side
// killer kills by port (lsof + kill -KILL), so returning a port we don't
// own — e.g. an external/desktop OpenCode on 4096 we attached to — would
// let a single miscomputed `managed` flag take down the user's separate
// server. Structurally withhold what isn't ours so the killer has no
// target, instead of relying on the flag check alone.
return {
managed,
pid: managed && typeof openCodeProcess?.pid === 'number' ? openCodeProcess.pid : null,
port: managed ? openCodePort : null,
};
},
stop: (shutdownOptions = {}) => {
realtimeProxyRuntime.stop();
return gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false });
}
};
}
+2 -2
View File
@@ -41,7 +41,7 @@ export async function checkCloudflaredAvailable() {
return { available: false, path: null, version: null };
}
export function printCloudflareTunnelInstallHelp() {
function printCloudflareTunnelInstallHelp() {
const platform = process.platform;
let installCmd = '';
@@ -600,7 +600,7 @@ export async function startCloudflareManagedLocalTunnel({ configPath, hostname }
};
}
export async function startCloudflareTunnel({ originUrl, port }) {
async function startCloudflareTunnel({ originUrl, port }) {
void port;
return startCloudflareQuickTunnel({ originUrl });
}
@@ -42,6 +42,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
- The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`.
- Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
- If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
- When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and can run scoped state repair without requiring the browser WS to close.
- Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream.
- Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
- Global UI broadcasts are fan-out capable across both SSE and WS clients.
@@ -2,7 +2,7 @@ import { createUpstreamSseReader } from './upstream-reader.js';
// Raised from 512 → 2048 to improve recovery after brief disconnects during
// long-running agent sessions where many events accumulate quickly.
export const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
export function createGlobalMessageStreamHub({
buildOpenCodeUrl,
@@ -120,6 +120,17 @@ export function createGlobalMessageStreamWsBridge({
for (const socket of Array.from(clients)) {
if (!readyClients.has(socket)) {
markReady(socket, clientLastEventIds.get(socket) ?? '');
continue;
}
if (status.wasReady) {
const sent = sendMessageStreamWsFrame(socket, {
type: 'ready',
scope: 'global',
});
if (!sent) {
removeClient(socket);
}
}
}
return;
@@ -1,25 +1,13 @@
export {
MESSAGE_STREAM_GLOBAL_WS_PATH,
MESSAGE_STREAM_DIRECTORY_WS_PATH,
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
parseSseEventEnvelope,
sendMessageStreamWsFrame,
sendMessageStreamWsEvent,
} from './protocol.js';
export {
createGlobalUiEventBroadcaster,
createMessageStreamWsRuntime,
} from './runtime.js';
export {
MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
createGlobalMessageStreamHub,
} from './global-hub.js';
export {
DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
UPSTREAM_STALL_TIMEOUT_CONCURRENT_MS,
createUpstreamSseReader,
} from './upstream-reader.js';
@@ -1,6 +1,6 @@
import { WebSocketServer } from 'ws';
import { parseRequestPathname } from '../terminal/index.js';
import { parseRequestPathname } from '../terminal/terminal-ws-protocol.js';
import {
MESSAGE_STREAM_DIRECTORY_WS_PATH,
MESSAGE_STREAM_GLOBAL_WS_PATH,
@@ -435,7 +435,7 @@ describe('message stream websocket runtime', () => {
return createSseResponse({
signal: options.signal,
holdOpen: false,
holdOpen: true,
blocks: [
'id: evt-2\ndata: {"type":"server.connected","properties":{}}\n\n',
],
@@ -451,7 +451,7 @@ describe('message stream websocket runtime', () => {
const readyFrames = socket.sent.filter((frame) => frame.type === 'ready');
const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected');
expect(readyFrames).toHaveLength(1);
expect(readyFrames.length).toBeGreaterThanOrEqual(2);
expect(eventFrames.length).toBeGreaterThanOrEqual(2);
expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
expect(triggerHealthCheckCalls).toBe(0);
+7 -1
View File
@@ -883,7 +883,13 @@ export const registerFsRoutes = (app, dependencies) => {
const download = req.query.download === 'true';
if (download) {
const fileName = path.basename(canonicalPath);
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
// RFC 5987: use filename*= for non-ASCII filenames, with ASCII-only
// filename= as fallback for older clients.
const asciiOnly = fileName.replace(/[^\u0000-\u007F]/g, '');
const fallback = asciiOnly || 'file';
// Percent-encode the raw UTF-8 bytes for filename*=
const encoded = encodeURIComponent(fileName);
res.setHeader('Content-Disposition', `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`);
}
const content = await fsPromises.readFile(canonicalPath);
+39
View File
@@ -596,3 +596,42 @@ describe('fs exec git-read cache', () => {
expect(calls.length).toBe(afterFill + 2);
});
});
describe('fs raw download Content-Disposition', () => {
it('uses RFC 5987 filename*= encoding for non-ASCII filenames on download', async () => {
const fsPromises = {
realpath: vi.fn(async (targetPath) => targetPath),
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
readFile: vi.fn(async () => Buffer.from('content')),
};
const handler = registerRaw(fsPromises);
const res = await callRaw(handler, {
path: '/repo/文件.txt',
download: 'true',
});
expect(res.statusCode).toBe(200);
const cd = res.getHeader('content-disposition');
expect(cd).toContain("filename*=UTF-8''");
expect(cd).toContain(encodeURIComponent('文件.txt'));
// ASCII fallback strips non-ASCII chars, leaving extension
expect(cd).toContain('filename=".txt"');
});
it('uses plain filename for ASCII-only filenames on download', async () => {
const fsPromises = {
realpath: vi.fn(async (targetPath) => targetPath),
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
readFile: vi.fn(async () => Buffer.from('content')),
};
const handler = registerRaw(fsPromises);
const res = await callRaw(handler, { path: '/repo/readme.txt', download: 'true' });
expect(res.statusCode).toBe(200);
const cd = res.getHeader('content-disposition');
expect(cd).toContain('filename="readme.txt"');
expect(cd).toContain("filename*=UTF-8''readme.txt");
});
});
@@ -68,6 +68,8 @@ export function createProfile(profileData) {
userEmail: profileData.userEmail,
authType: profileData.authType || 'ssh',
sshKey: profileData.sshKey || null,
signCommits: profileData.signCommits,
signingKey: profileData.signingKey || null,
host: profileData.host || null,
color: profileData.color || 'keyword',
icon: profileData.icon || 'branch'
+4 -4
View File
@@ -1,11 +1,11 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const gitLibraries = {
stageFiles: mock(),
unstageFiles: mock(),
stageFiles: vi.fn(),
unstageFiles: vi.fn(),
};
mock.module('./index.js', () => ({
vi.mock('./index.js', () => ({
stageFiles: gitLibraries.stageFiles,
unstageFiles: gitLibraries.unstageFiles,
}));
+38 -1
View File
@@ -824,6 +824,19 @@ const isNotGitRepositoryError = (error) => {
return /not a git repository/i.test(text);
};
// A directory that no longer exists (e.g. a worktree deleted while something
// was still polling its status) is an expected, benign condition — not a fault
// to scream about. simple-git throws "Cannot use simple-git on a directory that
// does not exist"; the underlying fs errors are ENOENT/ENOTDIR.
const isMissingDirectoryError = (error) => {
const code = error?.code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return true;
}
const text = parseGitErrorText(error);
return /directory that does not exist|does not exist|no such file or directory/i.test(text);
};
const runGitCommand = async (cwd, args) => {
try {
const { stdout, stderr } = await execFileAsync(getGitBinary(), args, {
@@ -1913,6 +1926,12 @@ export async function setLocalIdentity(directory, profile) {
await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
}
if (profile.signCommits === true && typeof profile.signingKey === 'string' && profile.signingKey.trim()) {
await git.addConfig('gpg.format', 'ssh', false, 'local');
await git.addConfig('user.signingkey', profile.signingKey.trim(), false, 'local');
await git.addConfig('commit.gpgsign', 'true', false, 'local');
}
return true;
} catch (error) {
console.error('Failed to set Git identity:', error);
@@ -2178,7 +2197,7 @@ export async function getStatus(directory, options = {}) {
rebaseInProgress,
};
} catch (error) {
if (!isNotGitRepositoryError(error)) {
if (!isNotGitRepositoryError(error) && !isMissingDirectoryError(error)) {
console.error('Failed to get Git status:', error);
}
throw error;
@@ -3544,6 +3563,19 @@ export async function validateWorktreeCreate(directory, input = {}) {
}
}
const assertWorktreeCreatePreflight = async (directory, input = {}) => {
const validation = await validateWorktreeCreate(directory, input);
if (validation?.ok) {
return;
}
const message = validation?.errors
?.map((error) => error?.message)
.filter(Boolean)
.join('\n') || 'Failed to validate worktree creation';
throw new Error(message);
};
export async function previewWorktreeCreate(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
@@ -3692,6 +3724,11 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
export async function createWorktree(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
if (input?.returnAfterDirectoryCreated === true) {
await assertWorktreeCreatePreflight(directory, input);
}
await fsp.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
@@ -8,6 +8,7 @@ import simpleGit from 'simple-git';
import {
checkoutCommit,
cherryPick,
createWorktree,
getStatus,
removeWorktree,
resolvePrimaryWorktreeRoot,
@@ -315,6 +316,53 @@ describe('worktree root resolution', () => {
});
});
// ---------------------------------------------------------------------------
// createWorktree
// ---------------------------------------------------------------------------
describe('createWorktree', () => {
it('preflights fast create branch-in-use failures before creating the candidate directory', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const repo = createTempDir();
const worktree = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
const projectID = runGit(repo, ['rev-list', '--max-parents=0', '--all']).trim();
fs.rmSync(worktree, { recursive: true, force: true });
runGit(repo, ['worktree', 'add', '-b', 'feature/in-use', worktree, 'HEAD']);
const canonicalWorktree = fs.realpathSync(worktree);
await expect(createWorktree(repo, {
mode: 'existing',
existingBranch: 'feature/in-use',
branchName: 'feature/in-use',
worktreeName: 'feature-in-use',
returnAfterDirectoryCreated: true,
})).rejects.toThrow(`Branch is already checked out in ${canonicalWorktree}`);
const candidateDirectory = path.join(dataHome, 'opencode', 'worktree', projectID, 'feature-in-use');
expect(fs.existsSync(candidateDirectory)).toBe(false);
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
});
// ---------------------------------------------------------------------------
// removeWorktree
// ---------------------------------------------------------------------------
+1
View File
@@ -21,6 +21,7 @@ export {
export {
getOctokitOrNull,
createOctokit,
} from './octokit.js';
export {
+21 -1
View File
@@ -2,6 +2,26 @@ import { Octokit } from '@octokit/rest';
import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js';
import { getGhCliToken } from './gh-cli-credential.js';
// Per-request timeout for every GitHub call. Octokit v22 uses native fetch,
// which has no built-in timeout — without this, a stuck connection hangs until
// some outer bound (the PR-status route's 12s overall budget) fires, and a
// single slow request can eat the whole budget. Bounding each request lets the
// caller fail fast and fall back to cached state instead.
const OCTOKIT_REQUEST_TIMEOUT_MS = 8000;
const timeoutFetch = (url, options = {}) => {
// Respect a caller-provided signal if present; otherwise attach our timeout.
if (options.signal) {
return fetch(url, options);
}
return fetch(url, { ...options, signal: AbortSignal.timeout(OCTOKIT_REQUEST_TIMEOUT_MS) });
};
/** Create an Octokit instance with a per-request timeout applied. */
export function createOctokit(token) {
return new Octokit({ auth: token, request: { fetch: timeoutFetch } });
}
export function getOctokitOrNull() {
const auth = getGitHubAuth();
const ghToken = !isGhCliDisabled() ? getGhCliToken() : null;
@@ -9,5 +29,5 @@ export function getOctokitOrNull() {
if (!token) {
return null;
}
return new Octokit({ auth: token });
return createOctokit(token);
}
+59 -11
View File
@@ -1,5 +1,17 @@
import { stat } from 'node:fs/promises';
import { getRemotes, getStatus } from '../git/index.js';
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
import { noteIfGitHubRateLimit } from './rate-limit.js';
const directoryExists = async (dir) => {
if (!dir) return false;
try {
await stat(dir);
return true;
} catch {
return false;
}
};
const REPO_DEFAULT_BRANCH_TTL_MS = 5 * 60_000;
const defaultBranchCache = new Map();
@@ -160,6 +172,17 @@ const getRepoDefaultBranch = async (octokit, repo) => {
return cached.defaultBranch;
}
// Reuse the full repo metadata if it was already fetched (expandRepoNetwork
// calls getRepoMetadata for every candidate before the default-branch loop).
// This avoids a redundant repos.get per repo — fewer serial GitHub calls means
// less exposure to secondary-rate-limiting that makes PR status slow.
const metaCached = repoMetadataCache.get(repoKey);
if (metaCached && Date.now() - metaCached.fetchedAt < REPO_DEFAULT_BRANCH_TTL_MS) {
const defaultBranch = normalizeText(metaCached.data?.default_branch) || null;
defaultBranchCache.set(repoKey, { defaultBranch, fetchedAt: Date.now() });
return defaultBranch;
}
try {
const response = await octokit.rest.repos.get({
owner: repo.owner,
@@ -171,7 +194,8 @@ const getRepoDefaultBranch = async (octokit, repo) => {
fetchedAt: Date.now(),
});
return defaultBranch;
} catch {
} catch (error) {
noteIfGitHubRateLimit(error);
return null;
}
};
@@ -199,6 +223,7 @@ const getRepoMetadata = async (octokit, repo) => {
});
return data;
} catch (error) {
noteIfGitHubRateLimit(error);
if (error?.status === 403 || error?.status === 404) {
repoMetadataCache.set(repoKey, {
data: null,
@@ -211,21 +236,26 @@ const getRepoMetadata = async (octokit, repo) => {
};
const resolveRemoteCandidates = async (directory, rankedRemoteNames) => {
// Resolve every ranked remote concurrently — they're independent git lookups.
// Dedup afterwards in rank order so the result is identical to the previous
// sequential pass, just without paying each lookup's latency back-to-back.
const resolvedRemotes = await Promise.all(
rankedRemoteNames.map((remoteName) =>
resolveGitHubRepoFromDirectory(directory, remoteName)
.then((resolved) => ({ remoteName, repo: resolved?.repo || null }))
.catch(() => ({ remoteName, repo: null })),
),
);
const results = [];
const seenRepoKeys = new Set();
for (const remoteName of rankedRemoteNames) {
const resolved = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null }));
const repo = resolved?.repo || null;
for (const { remoteName, repo } of resolvedRemotes) {
const repoKey = normalizeRepoKey(repo?.owner, repo?.repo);
if (!repo || !repoKey || seenRepoKeys.has(repoKey)) {
continue;
}
seenRepoKeys.add(repoKey);
results.push({
remoteName,
repo,
});
results.push({ remoteName, repo });
}
return results;
@@ -244,8 +274,16 @@ const expandRepoNetwork = async (octokit, candidates) => {
expanded.push({ repo, remoteName, priority });
};
for (const candidate of candidates) {
const metadata = await getRepoMetadata(octokit, candidate.repo);
// Fetch repo metadata for all candidates concurrently (independent GET
// /repos calls), then fold them in candidate order so dedup/priority is
// unchanged from the sequential version.
const metadatas = await Promise.all(
candidates.map((candidate) =>
getRepoMetadata(octokit, candidate.repo).then((metadata) => ({ candidate, metadata })),
),
);
for (const { candidate, metadata } of metadatas) {
if (!metadata) {
continue;
}
@@ -279,6 +317,7 @@ const safeListPulls = async (octokit, options) => {
const response = await octokit.rest.pulls.list(options);
return Array.isArray(response?.data) ? response.data : [];
} catch (error) {
noteIfGitHubRateLimit(error);
if (error?.status === 404 || error?.status === 403) {
return [];
}
@@ -334,6 +373,7 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
// If we get here, search API works for this repo — clear the disabled flag
_searchApiDisabledRepos.delete(repoKey);
} catch (error) {
noteIfGitHubRateLimit(error);
if (error?.status === 403) {
_searchApiDisabledRepos.set(repoKey, Date.now());
return null;
@@ -424,6 +464,14 @@ const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates }
};
export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName }) {
// A deleted worktree can still have a session in the sidebar that keeps
// requesting its PR status. Bail before touching git or GitHub for a
// directory that no longer exists — otherwise every poll spends a git call
// (and the remote/repo resolution that follows) on a path that's gone.
if (!(await directoryExists(directory))) {
return { repo: null, pr: null, defaultBranch: null, resolvedRemoteName: null };
}
const normalizedBranch = normalizeText(branch);
const normalizedRemoteName = normalizeText(remoteName) || 'origin';
@@ -0,0 +1,66 @@
// Lightweight, process-global GitHub rate-limit gate.
//
// Octokit is configured without the throttling plugin, so a primary or
// secondary rate limit surfaces as a thrown 403/429. Resolving PR status for
// many worktrees fans out dozens of calls; once GitHub starts limiting, every
// further call wastes a round-trip and the cache masks the failure. When we
// detect a rate-limit response we record a cooldown and skip GitHub work until
// it passes, so the burst stops and the reason is visible in the logs.
const MAX_COOLDOWN_MS = 15 * 60 * 1000;
const DEFAULT_COOLDOWN_MS = 60 * 1000;
let rateLimitedUntil = 0;
const headerValue = (headers, name) => {
if (!headers) return undefined;
// Octokit/fetch headers can be a plain object or a Headers instance.
if (typeof headers.get === 'function') return headers.get(name);
return headers[name];
};
const parseRetryAfterMs = (error) => {
const headers = error?.response?.headers;
const retryAfter = headerValue(headers, 'retry-after');
if (retryAfter !== undefined && retryAfter !== null) {
const secs = Number(retryAfter);
if (Number.isFinite(secs) && secs > 0) return secs * 1000;
}
const reset = headerValue(headers, 'x-ratelimit-reset');
if (reset !== undefined && reset !== null) {
const delta = Number(reset) * 1000 - Date.now();
if (Number.isFinite(delta) && delta > 0) return delta;
}
return null;
};
/** True when an Octokit error represents a primary or secondary rate limit. */
export const isGitHubRateLimitError = (error) => {
const status = error?.status ?? error?.response?.status;
if (status === 429) return true;
if (status !== 403) return false;
const remaining = headerValue(error?.response?.headers, 'x-ratelimit-remaining');
if (remaining === '0' || remaining === 0) return true;
if (headerValue(error?.response?.headers, 'retry-after') != null) return true;
const message = String(error?.message ?? '').toLowerCase();
return message.includes('rate limit');
};
/** Record a cooldown after a detected rate-limit response. */
export const noteGitHubRateLimit = (error) => {
const retryMs = Math.min(parseRetryAfterMs(error) ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
const until = Date.now() + retryMs;
if (until > rateLimitedUntil) {
rateLimitedUntil = until;
console.warn(`[github] rate limited — pausing GitHub PR status calls for ~${Math.round(retryMs / 1000)}s`);
}
};
/** Convenience: note the error if it is a rate-limit error. Returns whether it was. */
export const noteIfGitHubRateLimit = (error) => {
if (!isGitHubRateLimitError(error)) return false;
noteGitHubRateLimit(error);
return true;
};
export const isGitHubRateLimited = () => Date.now() < rateLimitedUntil;
+67 -14
View File
@@ -1,7 +1,26 @@
const PR_STATUS_CACHE_TTL_MS = 90_000;
const PR_STATUS_CACHE_MAX_ENTRIES = 200;
// Upper bound for resolving a single PR status. resolveGitHubPrStatus makes many
// serial GitHub API calls; under GitHub secondary-rate-limiting a single request
// can otherwise hang 20s+. We bound it so the route fails fast instead of holding
// the response (and a client socket) open — the client keeps its last-known
// status on error, and a later poll fills it in.
const PR_STATUS_RESOLVE_TIMEOUT_MS = 12_000;
const prStatusCache = new Map();
function withTimeout(promise, timeoutMs, label) {
let timer;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = 'ETIMEDOUT';
reject(error);
}, timeoutMs);
if (typeof timer.unref === 'function') timer.unref();
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function getRequestedRepo(req) {
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
@@ -89,8 +108,8 @@ export function registerGitHubRoutes(app) {
if (ghToken !== null && !ghCliDisabled) {
try {
const { Octokit } = await import('@octokit/rest');
ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken }));
const { createOctokit } = await import('./octokit.js');
ghCliUser = await getGitHubUserSummary(createOctokit(ghToken));
} catch {
ghCliUser = null;
}
@@ -227,8 +246,8 @@ export function registerGitHubRoutes(app) {
return res.status(500).json({ error: 'Missing access_token from GitHub' });
}
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: accessToken });
const { createOctokit } = await import('./octokit.js');
const octokit = createOctokit(accessToken);
const user = await getGitHubUserSummary(octokit);
setGitHubAuth({
@@ -264,8 +283,8 @@ export function registerGitHubRoutes(app) {
return res.status(404).json({ error: 'GitHub CLI account not found' });
}
const { Octokit } = await import('@octokit/rest');
const user = await getGitHubUserSummary(new Octokit({ auth: ghToken }));
const { createOctokit } = await import('./octokit.js');
const user = await getGitHubUserSummary(createOctokit(ghToken));
setGhCliActive(true);
const accounts = getGitHubAuthAccounts()
.map((account) => ({ ...account, current: false }))
@@ -300,8 +319,8 @@ export function registerGitHubRoutes(app) {
let ghCliUser = null;
if (ghToken) {
try {
const { Octokit } = await import('@octokit/rest');
ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken }));
const { createOctokit } = await import('./octokit.js');
ghCliUser = await getGitHubUserSummary(createOctokit(ghToken));
accounts = accounts.concat({
id: GH_CLI_ACCOUNT_ID,
user: ghCliUser,
@@ -400,6 +419,17 @@ export function registerGitHubRoutes(app) {
return res.json(cached.data);
}
// If GitHub recently rate-limited us, don't pile on more calls that will
// also fail. Serve whatever we last cached (even if stale); otherwise
// report a transient failure so the client keeps its last-known status.
const { isGitHubRateLimited } = await import('./rate-limit.js');
if (isGitHubRateLimited()) {
if (cached) {
return res.json(cached.data);
}
return res.status(503).json({ error: 'GitHub rate limited' });
}
// Intercept res.json to cache successful responses before sending
// Only caches responses with connected:true — error/edge-case responses are not cached
const originalJson = res.json.bind(res);
@@ -417,12 +447,16 @@ export function registerGitHubRoutes(app) {
}
const { resolveGitHubPrStatus } = await import('./pr-status.js');
const resolvedStatus = await resolveGitHubPrStatus({
octokit,
directory,
branch,
remoteName: remote,
});
const resolvedStatus = await withTimeout(
resolveGitHubPrStatus({
octokit,
directory,
branch,
remoteName: remote,
}),
PR_STATUS_RESOLVE_TIMEOUT_MS,
'resolveGitHubPrStatus',
);
const searchRepo = resolvedStatus.repo;
const first = resolvedStatus.pr;
if (!searchRepo) {
@@ -554,6 +588,24 @@ export function registerGitHubRoutes(app) {
clearGitHubAuth();
return res.json({ connected: false });
}
// Transient failures — a rate limit, or the overall resolve timeout
// firing — are expected under heavy load and should not be logged as hard
// errors. Record a rate-limit cooldown when applicable, then serve the
// last cached status (even if stale) or a 503 so the client keeps its
// last-known value instead of clearing the badge.
const { noteIfGitHubRateLimit } = await import('./rate-limit.js');
const wasRateLimited = noteIfGitHubRateLimit(error);
const wasTimeout = error?.code === 'ETIMEDOUT';
if (wasRateLimited || wasTimeout) {
const dir = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const br = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
const rem = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
const cached = prStatusCache.get(`${dir}::${br}::${rem}`);
if (cached) {
return res.json(cached.data);
}
return res.status(503).json({ error: wasRateLimited ? 'GitHub rate limited' : 'GitHub request timed out' });
}
if (isGitHubResourceUnavailable(error)) {
return res.json({
connected: true,
@@ -982,6 +1034,7 @@ export function registerGitHubRoutes(app) {
if (upstream) {
try {
const { getRemotes } = await import('../git/index.js');
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
const remotes = await getRemotes(directory);
for (const r of remotes) {
if (r?.name) {
@@ -0,0 +1,131 @@
# APNs remote push — signed relay mode
Native iOS background push (notifications even when the app is **suspended or killed**) is
delivered via APNs through a **central relay**, so no user configures an Apple key. Each server
signs its relay requests with an auto-generated keypair, and tokens are bound to the server that
registered them — so a leaked device token alone can't be used to push.
## How it works
1. The app registers its APNs device token with **its own server** (`POST /api/push/apns-token`,
`useNativePushRegistration`). PWA/desktop never register — only the native Capacitor app.
2. The server **binds the token on the relay**: it POSTs `{ token, publicKeyJwk, ts, sig }` to
`POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key
(`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records
`token → serverId` where `serverId = SHA-256(publicKey)`.
3. On a trigger (ready/error/question/permission), the server composes **generic, content-free**
text — a fixed scenario title ("Agent response is ready" / "Agent needs your input" / "Agent
needs permission" / "Agent hit an error") + the **session name** as the body, no model/project/
message content — plus a **`badge`** count (see below) — and POSTs `{ tokens, title, body,
badge, env, data:{sessionId}, publicKeyJwk, ts, sig }` to `POST /v1/push/send`
(`apns-runtime.js``sendViaRelay`). It does **not** gate on UI visibility (see below).
4. The **relay** (`openchamber-website/apps/api`, Cloudflare Worker) verifies the signature +
`ts` freshness, derives `serverId`, and only delivers to tokens bound to that server. It holds
the single project APNs `.p8` key, signs an ES256 JWT with `crypto.subtle`, and sends each
token to APNs over HTTP/2, returning per-token results; the server drops tokens flagged `drop`
(410 / BadDeviceToken). The relay stores no secret — only `token → serverId` hashes.
5. Tapping a push deep-links to its session via the forwarded `sessionId`.
## Foreground suppression
APNs is **not** gated on UI visibility. A backgrounded WKWebView can't reliably report "hidden"
before iOS suspends it, so a server-side visibility gate dropped background push for short
responses. Instead the server always sends, and **iOS** suppresses the foreground banner
(`PushNotifications.presentationOptions: []` in `capacitor.config`) — so there is no notification
while the app is active, with no race. APNs is the native app's **only** channel; local
notifications were removed (a WKWebView can't tell foreground from background — `document.hasFocus()`
is unreliable — so they leaked while the app was open). Cloudflare is touched only when a native
app with notifications on has a registered token and a trigger fires.
## App-icon badge
Each push carries an **absolute** `aps.badge` = the number of **distinct collapse-ids (`tag`)
pushed since the app was last foregrounded**. It mirrors the lock-screen banner stack.
The count is a `Set<tag>` (`pendingPushTags`) in the trigger runtime (`runtime.js`):
`toApnsGenericPayload` adds the push `tag` and returns the set size as the badge. We key by **`tag`,
not sessionId**, because the tag *is* the banner identity — iOS uses it as `apns-collapse-id`, so
same-tag pushes replace one banner while different tags are distinct banners. One session can raise
several banners (`ready-<id>`, `question-<id>`, `permission-<requestKey>` are different tags), so
counting sessionIds both over- and under-counts the stack; counting tags matches it.
It is deliberately **not** derived from the live attention snapshot (`needsAttention`/`isViewed`):
that machinery drives in-app indicators on *connected* clients, where a backgrounded client stays
"viewing" and `needsAttention` is set by a separate `session.status` event that races the push
trigger. The set self-clears via `clearPendingPushBadge` on any signal that the user is engaging
with the app: the visibility beacon (`updateUiVisibility` wrapper, `visible:true`), **plus** opening
a session (`POST /api/sessions/:id/view`) and sending a message (`POST /api/sessions/:id/
message-sent`). The latter two need no auth and fire reliably on the native app when it foregrounds,
so they are the dependable reset — the visibility beacon alone proved unreliable in WKWebView. This
mirrors the device zeroing its icon badge on `sceneDidBecomeActive` (`AppDelegate.swift`), keeping
server and device in sync.
The value flows `runtime.js` (`toApnsGenericPayload`) → `apns-runtime.js` (`sendViaRelay` body /
direct-mode `aps.badge`) → relay (`pushSendSchema.badge``aps.badge`). It is **not** signed (like
`body`/`data`); the relay still only delivers to bound tokens. The set is server-global, so every
device token of a server sees the same badge.
## Modes
- **Relay (default):** server has no Apple key; `OPENCHAMBER_PUSH_RELAY_URL` defaults to
`https://api.openchamber.dev/v1/push/send` (register URL is derived as `…/register-token`).
- **Direct (fallback):** set `OPENCHAMBER_PUSH_RELAY_DISABLED=true` + `OPENCHAMBER_APNS_KEY_ID/
TEAM_ID/P8` to sign+send from the server itself (HTTP/2 + ES256 JWT); no relay binding needed.
## Config
Server (`apns-runtime.js`):
- `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT`
(`sandbox` default / `production`). The signing keypair is auto-generated — nothing to set.
- Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8`
(or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`.
Relay (Cloudflare Worker secrets via `wrangler secret put` / GitHub Actions): `APNS_P8`,
`APNS_KEY_ID`, `APNS_TEAM_ID`, optional `APNS_BUNDLE_ID` / `APNS_DEFAULT_ENV`. The `push_tokens`
binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy).
## Apple setup (one-time)
1. Apple **Keys** (not Certificates) → create an **APNs Auth Key** (`.p8`) → Key ID + Team ID;
enable **Push Notifications** on App ID `com.openchamber.app`.
2. In the **openchamber-website** repo → Actions secrets: `APNS_P8` (PEM), `APNS_KEY_ID`,
`APNS_TEAM_ID`. Push to `main` → relay deploys, secrets sync, D1 migrations apply.
3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device.
## Security posture
- The device token is a per-install secret, but no longer the *only* defence: every relay request
is signed by the server's private key, and the relay only delivers to a token from its bound
`serverId`. A leaked token alone is useless — an attacker has neither the private key nor a
matching binding.
- `serverId` self-certifies (`SHA-256(publicKey)`), so the relay holds no secret; a D1 leak
exposes only `token → serverId` hashes. The signed `ts` (±5 min window) blocks replay.
- Residual: trust-on-first-bind (whoever registers a token first owns it) — acceptable, since
registering already requires possessing the token. Cloudflare rate limiting is defence-in-depth.
## Data confidentiality (what the relay / Apple can see)
The push payload is **not** application-encrypted, so there is no decryption step. The text is
sent in plaintext, protected only by **TLS in transit** (HTTPS to the relay, TLS from the relay
to APNs). The request **signature is authentication, not encryption** — the relay *verifies* it
(valid / invalid), it does not hide anything.
Who can read the alert text:
- **Network hops:** nothing (TLS).
- **The relay (Cloudflare):** the generic title + body (session name), the device token, and
`sessionId`. It stores only `token → serverId` hashes (no text, no payload).
- **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push.
- **The device:** displays it.
This is acceptable **because the text is deliberately content-free**: a fixed scenario title +
the session name only — no model, project, or message content (`runtime.js`
`toApnsGenericPayload`). The session name is the single semi-personal field that crosses the
relay/Apple. To hide even that from Apple would require an end-to-end **encrypted payload**
(`mutable-content` + a Notification Service Extension that decrypts on-device with a key never
sent to the relay) — not implemented, and unnecessary for generic text.
## Android (FCM) note
The Android equivalent is **FCM** (not implemented): the same relay would forward to FCM with a
server key, and the client would register an FCM token (same store/routes + signing).
@@ -7,6 +7,7 @@ This module provides notification message preparation utilities for the web serv
- `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`.
- `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints.
- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the central Cloudflare relay `https://api.openchamber.dev/v1/push/send`, which holds the single project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`.
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
@@ -24,6 +25,8 @@ This module provides notification message preparation utilities for the web serv
- `GET /api/push/vapid-public-key`
- `POST /api/push/subscribe`
- `DELETE /api/push/subscribe`
- `POST /api/push/apns-token` (native iOS APNs device-token registration)
- `DELETE /api/push/apns-token`
- `POST /api/push/visibility`
- `GET /api/push/visibility`
- `GET /api/notifications/stream`
@@ -61,6 +64,16 @@ This module provides notification message preparation utilities for the web serv
- `isAnyUiVisible()`
- `isUiVisible(token)`
### APNs runtime API (apns-runtime.js)
- `createApnsRuntime(dependencies)`: creates runtime for native iOS APNs push and device-token state. Dependencies: `fsPromises`, `path`, `crypto`, `http2`, `APNS_TOKENS_FILE_PATH`, `readSettingsFromDiskMigrated`, `writeSettingsToDisk` (persists the auto-generated relay signing keypair).
- Returned API:
- `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`).
- `removeApnsToken(uiSessionToken, deviceToken)`
- `removeApnsTokenFromAllSessions(deviceToken)`
- `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`.
- `resolveApnsConfig()`
- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production`).
### Emitter runtime API (emitter-runtime.js)
- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
- Returned API:
@@ -0,0 +1,512 @@
// APNs (Apple Push Notification service) runtime for the native iOS mobile app.
//
// Device tokens are persisted per UI session (mirrors push-runtime.js). Delivery has two
// modes, chosen at send time:
// - Relay (default): POST tokens + generic text to the central Cloudflare relay, which
// holds the single project APNs key and signs+sends — so users configure nothing.
// - Direct (fallback): sign an ES256 JWT with Node crypto and send over HTTP/2 ourselves,
// for self-hosters who set OPENCHAMBER_APNS_* and OPENCHAMBER_PUSH_RELAY_DISABLED=true.
// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
// generic, model-based text (no session content) — see APNS.md.
const APNS_TOKENS_VERSION = 1;
const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
// APNs rejects auth tokens older than 1h; refresh well inside that window.
const JWT_TTL_MS = 50 * 60 * 1000;
const DEFAULT_BUNDLE_ID = 'com.openchamber.app';
const DEFAULT_RELAY_URL = 'https://api.openchamber.dev/v1/push/send';
const MAX_TOKENS_PER_SESSION = 10;
// APNs reasons that mean the token is permanently invalid → drop it.
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
const trimmedEnv = (name) => {
const value = process.env[name];
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
};
// Env vars commonly store the .p8 with literal "\n" sequences; restore real newlines.
const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
export const createApnsRuntime = (deps) => {
const {
fsPromises,
path,
crypto,
http2,
APNS_TOKENS_FILE_PATH,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
} = deps;
let persistLock = Promise.resolve();
let cachedJwt = null; // { token, issuedAtMs, keyId }
let cachedRelayKey = null; // { privateKey, publicJwk }
let warnedUnconfigured = false;
// ---------------------------------------------------------------------------
// Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
// (mirrors getOrCreateVapidKeys). The relay derives serverId = SHA-256(publicKey), verifies
// each request's signature, and only delivers to tokens this server registered — so a leaked
// device token alone can't be used to push. Zero-config: the keypair generates on first use.
// ---------------------------------------------------------------------------
const getOrCreateRelayKeypair = async () => {
if (cachedRelayKey) return cachedRelayKey;
const settings = await readSettingsFromDiskMigrated();
const existing = settings?.relaySigningKey;
if (existing && existing.privateJwk && existing.publicJwk) {
cachedRelayKey = {
privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }),
publicJwk: existing.publicJwk,
};
return cachedRelayKey;
}
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
const privateJwk = privateKey.export({ format: 'jwk' });
const publicJwk = publicKey.export({ format: 'jwk' });
await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } });
cachedRelayKey = { privateKey, publicJwk };
return cachedRelayKey;
};
const signRelayMessage = (privateKey, message) =>
crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
// Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash).
const relayPublicJwk = (publicJwk) => ({
kty: publicJwk.kty,
crv: publicJwk.crv,
x: publicJwk.x,
y: publicJwk.y,
});
const registerTokenWithRelay = async (token, platform = 'ios') => {
const relay = resolveRelayConfig();
if (!relay) return; // direct mode — no relay binding needed
try {
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
const ts = Date.now();
// platform is part of the signed message so it can't be tampered en route.
const sig = signRelayMessage(privateKey, `${ts}.${token}.${platform}`);
const res = await fetch(relay.registerUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }),
});
if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`);
} catch (error) {
console.warn('[Push relay] register-token request failed:', error?.message ?? error);
}
};
// ---------------------------------------------------------------------------
// Token persistence (same shape + write-lock pattern as push-runtime.js)
// ---------------------------------------------------------------------------
const emptyStore = () => ({ version: APNS_TOKENS_VERSION, tokensBySession: {} });
const readTokensFromDisk = async () => {
try {
const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || parsed.version !== APNS_TOKENS_VERSION) {
return emptyStore();
}
const tokensBySession =
parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {};
return { version: APNS_TOKENS_VERSION, tokensBySession };
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return emptyStore();
}
console.warn('Failed to read APNs tokens file:', error);
return emptyStore();
}
};
const writeTokensToDisk = async (data) => {
await fsPromises.mkdir(path.dirname(APNS_TOKENS_FILE_PATH), { recursive: true });
await fsPromises.writeFile(APNS_TOKENS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
};
const persistTokenUpdate = async (mutate) => {
persistLock = persistLock.then(async () => {
const current = await readTokensFromDisk();
const next = mutate({ version: APNS_TOKENS_VERSION, tokensBySession: current.tokensBySession || {} });
await writeTokensToDisk(next);
return next;
});
return persistLock;
};
const normalizeTokens = (record) => {
if (!Array.isArray(record)) return [];
return record
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const deviceToken = entry.deviceToken;
if (typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return null;
return {
deviceToken: deviceToken.trim(),
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
lastSeenAt: typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : null,
userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
// 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
platform: entry.platform === 'android' ? 'android' : 'ios',
};
})
.filter(Boolean);
};
// Normalize an incoming platform hint to the two we support; default to APNs/iOS since that
// was the only registrant before Android/FCM existed.
const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => {
if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
const token = deviceToken.trim();
const tokenPlatform = normalizePlatform(platform);
const now = Date.now();
await persistTokenUpdate((current) => {
const tokensBySession = { ...(current.tokensBySession || {}) };
const existing = normalizeTokens(tokensBySession[uiSessionToken]);
const filtered = existing.filter((entry) => entry.deviceToken !== token);
filtered.unshift({
deviceToken: token,
createdAt: now,
lastSeenAt: now,
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
platform: tokenPlatform,
});
tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
return { version: APNS_TOKENS_VERSION, tokensBySession };
});
// (Re)bind this token to our server on the relay so only we can push to it. The device
// re-sends its token on each launch; this is an idempotent upsert relay-side, and binding
// every time (not just for new tokens) keeps existing tokens bound after a relay/server
// upgrade rather than silently going unbound. Platform is bound too so the relay routes
// it to APNs vs FCM.
await registerTokenWithRelay(token, tokenPlatform);
};
const removeApnsToken = async (uiSessionToken, deviceToken) => {
if (!uiSessionToken || !deviceToken) return;
await persistTokenUpdate((current) => {
const tokensBySession = { ...(current.tokensBySession || {}) };
const filtered = normalizeTokens(tokensBySession[uiSessionToken]).filter(
(entry) => entry.deviceToken !== deviceToken,
);
if (filtered.length === 0) delete tokensBySession[uiSessionToken];
else tokensBySession[uiSessionToken] = filtered;
return { version: APNS_TOKENS_VERSION, tokensBySession };
});
};
const removeApnsTokenFromAllSessions = async (deviceToken) => {
if (!deviceToken) return;
await persistTokenUpdate((current) => {
const tokensBySession = { ...(current.tokensBySession || {}) };
for (const [session, entries] of Object.entries(tokensBySession)) {
const filtered = normalizeTokens(entries).filter((entry) => entry.deviceToken !== deviceToken);
if (filtered.length === 0) delete tokensBySession[session];
else tokensBySession[session] = filtered;
}
return { version: APNS_TOKENS_VERSION, tokensBySession };
});
};
// ---------------------------------------------------------------------------
// Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject
// ---------------------------------------------------------------------------
const resolveApnsConfig = async () => {
let keyId = trimmedEnv('OPENCHAMBER_APNS_KEY_ID');
let teamId = trimmedEnv('OPENCHAMBER_APNS_TEAM_ID');
let bundleId = trimmedEnv('OPENCHAMBER_APNS_BUNDLE_ID');
let environment = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase();
let p8 = normalizePem(process.env.OPENCHAMBER_APNS_P8 || '');
const p8Path = trimmedEnv('OPENCHAMBER_APNS_P8_PATH');
if (!p8 && p8Path) {
try {
p8 = (await fsPromises.readFile(p8Path, 'utf8')).trim();
} catch (error) {
console.warn('[APNs] Failed to read OPENCHAMBER_APNS_P8_PATH:', error?.message ?? error);
}
}
if (!keyId || !teamId || !p8) {
try {
const settings = await readSettingsFromDiskMigrated();
const stored = settings?.apnsConfig;
if (stored && typeof stored === 'object') {
keyId = keyId || (typeof stored.keyId === 'string' ? stored.keyId.trim() : null);
teamId = teamId || (typeof stored.teamId === 'string' ? stored.teamId.trim() : null);
bundleId = bundleId || (typeof stored.bundleId === 'string' ? stored.bundleId.trim() : null);
environment = environment || (typeof stored.environment === 'string' ? stored.environment.toLowerCase() : '');
if (!p8 && typeof stored.p8 === 'string') p8 = normalizePem(stored.p8);
}
} catch {
// settings unavailable — fall through to the unconfigured result
}
}
if (!keyId || !teamId || !p8) return null;
return {
keyId,
teamId,
p8,
bundleId: bundleId || DEFAULT_BUNDLE_ID,
environment: environment === 'production' ? 'production' : 'sandbox',
};
};
// ---------------------------------------------------------------------------
// JWT (ES256, JOSE/raw signature) + HTTP/2 send
// ---------------------------------------------------------------------------
const signApnsJwt = (config) => {
const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: config.keyId })).toString('base64url');
const claims = Buffer.from(
JSON.stringify({ iss: config.teamId, iat: Math.floor(Date.now() / 1000) }),
).toString('base64url');
const signingInput = `${header}.${claims}`;
const signature = crypto
.sign('sha256', Buffer.from(signingInput), { key: config.p8, dsaEncoding: 'ieee-p1363' })
.toString('base64url');
return `${signingInput}.${signature}`;
};
const getJwt = (config) => {
const now = Date.now();
if (cachedJwt && cachedJwt.keyId === config.keyId && now - cachedJwt.issuedAtMs < JWT_TTL_MS) {
return cachedJwt.token;
}
const token = signApnsJwt(config);
cachedJwt = { token, issuedAtMs: now, keyId: config.keyId };
return token;
};
const buildBody = (payload) => {
const data = payload && typeof payload.data === 'object' && payload.data ? payload.data : {};
return JSON.stringify({
aps: {
alert: {
title: typeof payload?.title === 'string' ? payload.title : undefined,
body: typeof payload?.body === 'string' ? payload.body : undefined,
},
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
sound: 'default',
'thread-id': typeof payload?.tag === 'string' ? payload.tag : undefined,
// Wakes the Notification Service Extension so it can refresh the home/lock-screen
// widgets (attention count + unread dot) from the push, even when the app is closed.
// No extra network call — just an extra key on the push we already send.
'mutable-content': 1,
},
...data,
});
};
const sendOne = (client, deviceToken, body, jwt, config) =>
new Promise((resolve) => {
const headers = {
':method': 'POST',
':path': `/3/device/${deviceToken}`,
authorization: `bearer ${jwt}`,
'apns-topic': config.bundleId,
'apns-push-type': 'alert',
'apns-priority': '10',
};
// collapse-id dedups like web-push tags; APNs caps it at 64 bytes.
const collapseId = typeof config.tag === 'string' ? config.tag.slice(0, 64) : undefined;
if (collapseId) headers['apns-collapse-id'] = collapseId;
let req;
try {
req = client.request(headers);
} catch (error) {
console.warn('[APNs] request open failed:', error?.message ?? error);
resolve();
return;
}
let status = 0;
let responseBody = '';
req.on('response', (resHeaders) => {
status = Number(resHeaders[':status']) || 0;
});
req.setEncoding('utf8');
req.on('data', (chunk) => {
responseBody += chunk;
});
req.on('end', async () => {
if (status === 200) {
resolve();
return;
}
let reason = '';
try {
reason = JSON.parse(responseBody)?.reason || '';
} catch {
// non-JSON error body
}
if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
await removeApnsTokenFromAllSessions(deviceToken);
} else {
console.warn(`[APNs] push failed status=${status} reason=${reason || 'unknown'}`);
}
resolve();
});
req.on('error', (error) => {
console.warn('[APNs] request error:', error?.message ?? error);
resolve();
});
req.end(body);
});
// Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on
// each user's server — so users configure nothing. The server just POSTs device tokens +
// generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below)
// is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay.
const resolveRelayConfig = () => {
if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
return {
url,
registerUrl: url.replace(/\/send$/, '/register-token'),
environment:
(trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
? 'production'
: 'sandbox',
};
};
const sendViaRelay = async (deviceTokens, payload, relay) => {
const tokens = deviceTokens.slice(0, 100);
const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
const ts = Date.now();
// Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
const requestBody = JSON.stringify({
tokens,
title,
body: typeof payload?.body === 'string' ? payload.body : '',
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined,
env: relay.environment,
data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
publicKeyJwk: relayPublicJwk(publicJwk),
ts,
sig,
});
try {
const res = await fetch(relay.url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: requestBody,
});
if (!res.ok) {
console.warn(`[APNs relay] send failed status=${res.status}`);
return;
}
const data = await res.json().catch(() => null);
const results = Array.isArray(data?.results) ? data.results : [];
for (const result of results) {
if (result && result.drop === true && typeof result.token === 'string') {
await removeApnsTokenFromAllSessions(result.token);
}
}
} catch (error) {
console.warn('[APNs relay] request failed:', error?.message ?? error);
}
};
const sendViaDirectApns = async (deviceTokens, payload) => {
const config = await resolveApnsConfig();
if (!config) {
if (!warnedUnconfigured) {
warnedUnconfigured = true;
console.warn(
'[APNs] Relay disabled and no direct config; set OPENCHAMBER_APNS_KEY_ID / OPENCHAMBER_APNS_TEAM_ID / OPENCHAMBER_APNS_P8 for direct send.',
);
}
return;
}
const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX;
const jwt = getJwt(config);
const body = buildBody(payload);
const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined };
let client;
try {
client = http2.connect(host);
} catch (error) {
console.warn('[APNs] connect failed:', error?.message ?? error);
return;
}
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
try {
client.close();
} catch {
// ignore close errors
}
resolve();
};
client.on('error', (error) => {
console.warn('[APNs] session error:', error?.message ?? error);
finish();
});
Promise.all(
deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)),
).finally(finish);
});
};
// NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
// report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed
// background push for short responses. Instead we always send, and rely on iOS to NOT
// display the alert while the app is foreground (presentationOptions: [] in
// capacitor.config) — so there is no notification when the app is active, with no race.
const sendApnsToAllUiSessions = async (payload, _options = {}) => {
const store = await readTokensFromDisk();
const deviceTokens = [];
const seen = new Set();
for (const record of Object.values(store.tokensBySession || {})) {
for (const entry of normalizeTokens(record)) {
if (!seen.has(entry.deviceToken)) {
seen.add(entry.deviceToken);
deviceTokens.push(entry.deviceToken);
}
}
}
if (deviceTokens.length === 0) return;
const relay = resolveRelayConfig();
if (relay) {
await sendViaRelay(deviceTokens, payload, relay);
return;
}
await sendViaDirectApns(deviceTokens, payload);
};
return {
addOrUpdateApnsToken,
removeApnsToken,
removeApnsTokenFromAllSessions,
sendApnsToAllUiSessions,
resolveApnsConfig,
// exposed for tests
signApnsJwt,
};
};
@@ -0,0 +1,196 @@
import crypto from 'node:crypto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApnsRuntime } from './apns-runtime.js';
// A real P-256 key so the ES256 signing path (direct mode) runs for real.
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
const P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
const APNS_CONFIG = { keyId: 'KEY123', teamId: 'TEAM123', p8: P8, bundleId: 'com.openchamber.app', environment: 'sandbox' };
// In-memory fs so add-then-read reflects within a test.
const createMemoryFs = () => {
let content = null;
return {
mkdir: vi.fn(async () => {}),
readFile: vi.fn(async () => {
if (content == null) {
const err = new Error('ENOENT');
err.code = 'ENOENT';
throw err;
}
return content;
}),
writeFile: vi.fn(async (_path, data) => {
content = data;
}),
};
};
const makeDeps = (overrides = {}) => {
// Stateful settings so the auto-generated relay signing keypair persists + reads back.
let settings = {};
return {
fsPromises: createMemoryFs(),
path: { dirname: () => '/tmp' },
crypto,
http2: { connect: vi.fn(() => { throw new Error('http2 must not be used in relay mode'); }) },
APNS_TOKENS_FILE_PATH: '/tmp/apns-tokens.json',
readSettingsFromDiskMigrated: vi.fn(async () => settings),
writeSettingsToDisk: vi.fn(async (next) => { settings = next; }),
...overrides,
};
};
const jsonResponse = (data, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
// Mirror of the relay's verifier (crypto.subtle), to prove the server's signatures are valid.
const verifyRelaySignature = async (publicKeyJwk, message, sigB64Url) => {
const key = await crypto.subtle.importKey(
'jwk',
{ kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y },
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify'],
);
return crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
new Uint8Array(Buffer.from(sigB64Url, 'base64url')),
new TextEncoder().encode(message),
);
};
const isRegister = ([url]) => String(url).endsWith('/register-token');
const isSend = ([url]) => String(url) === 'https://relay.test/v1/push/send';
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.OPENCHAMBER_PUSH_RELAY_URL;
delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
});
describe('apns runtime relay mode (default)', () => {
it('registers tokens (signed) and posts signed generic text, dropping dead tokens', async () => {
const fetchMock = vi.fn(async (url) =>
isRegister([url])
? jsonResponse({ ok: true })
: jsonResponse({
results: [
{ token: 'tokenA', ok: true, drop: false },
{ token: 'tokenDead', ok: false, drop: true },
],
}),
);
vi.stubGlobal('fetch', fetchMock);
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
const runtime = createApnsRuntime(makeDeps());
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
await runtime.addOrUpdateApnsToken('s2', 'tokenDead');
// Each new token is bound on the relay with a signed register-token call.
const registerCalls = fetchMock.mock.calls.filter(isRegister);
expect(registerCalls).toHaveLength(2);
for (const [url, init] of registerCalls) {
expect(url).toBe('https://relay.test/v1/push/register-token');
const body = JSON.parse(init.body);
expect(body.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
expect(typeof body.ts).toBe('number');
expect(body.platform).toBe('ios');
expect(await verifyRelaySignature(body.publicKeyJwk, `${body.ts}.${body.token}.${body.platform}`, body.sig)).toBe(true);
}
fetchMock.mockClear();
await runtime.sendApnsToAllUiSessions(
{ title: 'Agent response is ready', body: 'My session', badge: 3, tag: 'ready-x', data: { sessionId: 'sess1' } },
{},
);
const sendCall = fetchMock.mock.calls.find(isSend);
expect(sendCall).toBeTruthy();
const sent = JSON.parse(sendCall[1].body);
expect(sendCall[1].headers.authorization).toBeUndefined();
expect(new Set(sent.tokens)).toEqual(new Set(['tokenA', 'tokenDead']));
expect(sent.title).toBe('Agent response is ready');
expect(sent.body).toBe('My session');
expect(sent.badge).toBe(3);
expect(sent.data).toEqual({ sessionId: 'sess1' });
expect(sent.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
const sendMessage = `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.title}`;
expect(await verifyRelaySignature(sent.publicKeyJwk, sendMessage, sent.sig)).toBe(true);
// tokenDead should have been dropped → next send targets only tokenA.
fetchMock.mockClear();
await runtime.sendApnsToAllUiSessions({ title: 'x', body: 'y', tag: 't' }, {});
expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['tokenA']);
});
it('reuses one persisted keypair (same serverId) across register + send', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
vi.stubGlobal('fetch', fetchMock);
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
const deps = makeDeps();
const runtime = createApnsRuntime(deps);
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'x' }, {});
const keys = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).publicKeyJwk);
expect(keys.length).toBeGreaterThanOrEqual(2);
expect(keys.every((k) => k.x === keys[0].x && k.y === keys[0].y)).toBe(true);
// Keypair was generated + persisted exactly once.
expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1);
});
it('no-ops (no relay call) when no tokens are registered', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const runtime = createApnsRuntime(makeDeps());
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('apns runtime direct fallback (relay disabled)', () => {
it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => {
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
const targeted = [];
const http2 = {
connect: () => ({
on: () => {},
close: () => {},
request: (headers) => {
targeted.push(String(headers[':path']).replace('/3/device/', ''));
const listeners = {};
const req = {
on: (event, cb) => { listeners[event] = cb; return req; },
setEncoding: () => req,
end: () => {
queueMicrotask(() => {
listeners.response?.({ ':status': '200' });
listeners.end?.();
});
},
};
return req;
},
}),
};
const runtime = createApnsRuntime(
makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
);
await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' });
expect(targeted).toEqual(['tokenDirect']);
});
it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => {
const runtime = createApnsRuntime(makeDeps());
const parts = runtime.signApnsJwt(APNS_CONFIG).split('.');
expect(parts).toHaveLength(3);
expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' });
expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123');
});
});
@@ -1,4 +1 @@
export { truncateNotificationText, prepareNotificationLastMessage } from './message.js';
export { createNotificationTriggerRuntime } from './runtime.js';
export { createPushRuntime } from './push-runtime.js';
export { createNotificationTemplateRuntime } from './template-runtime.js';
export { prepareNotificationLastMessage } from './message.js';
@@ -115,12 +115,13 @@ export const createPushRuntime = (deps) => {
p256dh,
auth,
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
platform: typeof entry.platform === 'string' ? entry.platform : undefined,
};
})
.filter(Boolean);
};
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent, platform) => {
if (!uiSessionToken) {
return;
}
@@ -135,6 +136,7 @@ export const createPushRuntime = (deps) => {
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint);
const previous = existing.find((entry) => entry && entry.endpoint === subscription.endpoint);
filtered.unshift({
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
@@ -142,6 +144,13 @@ export const createPushRuntime = (deps) => {
createdAt: now,
lastSeenAt: now,
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
// Platform lets the sender route mobile PWA push through the same presence gate as APNs.
platform:
typeof platform === 'string' && platform
? platform
: typeof previous?.platform === 'string'
? previous.platform
: undefined,
});
subsBySession[uiSessionToken] = filtered.slice(0, 10);
@@ -230,18 +239,32 @@ export const createPushRuntime = (deps) => {
}
await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => {
if (requireNoSse && isAnyUiVisible()) {
return;
if (requireNoSse) {
// Mobile PWA subscriptions follow the same presence model as native push: suppress only
// when an interactive (desktop/web) client is visible. The phone PWA's own foreground is
// handled in the service worker (focused-client check), so it won't double-notify.
// Non-mobile (desktop/web) subscriptions keep the existing any-visible gate.
const suppressed = isMobilePlatform(sub.platform) ? isAnyInteractiveClientVisible() : isAnyUiVisible();
if (suppressed) return;
}
await sendPushToSubscription(sub, payload);
}));
};
const updateUiVisibility = (token, visible) => {
// A client is "mobile" if it reports a native mobile platform. Anything else (web, desktop,
// vscode, or an older client that doesn't report a platform) is treated as interactive — i.e.
// a surface where the user would actually see the in-app notification.
const MOBILE_PLATFORMS = new Set(['ios', 'android']);
const isMobilePlatform = (platform) => typeof platform === 'string' && MOBILE_PLATFORMS.has(platform);
const updateUiVisibility = (token, visible, platform) => {
if (!token) return;
const now = Date.now();
const nextVisible = Boolean(visible);
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
const existing = uiVisibilityByToken.get(token);
// Keep the last known platform if this beacon didn't carry one (e.g. a heartbeat).
const nextPlatform = typeof platform === 'string' && platform ? platform : existing?.platform;
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now, platform: nextPlatform });
};
const isAnyUiVisible = () => {
@@ -255,6 +278,25 @@ export const createPushRuntime = (deps) => {
return false;
};
// True when at least one NON-mobile client (desktop/web/vscode) is currently visible. Used to
// suppress native push to the phone: an active desktop already shows the notification, so the
// phone doesn't need it. Deliberately based on the desktop's visibility (reliable), never the
// phone's own (a backgrounded WKWebView can't report "hidden" before iOS suspends it).
const isAnyInteractiveClientVisible = () => {
const now = Date.now();
pruneUiVisibility(now);
for (const state of uiVisibilityByToken.values()) {
if (
state.visible === true &&
now - state.updatedAt <= UI_VISIBILITY_TTL_MS &&
!isMobilePlatform(state.platform)
) {
return true;
}
}
return false;
};
const isUiVisible = (token) => {
const now = Date.now();
pruneUiVisibility(now);
@@ -317,6 +359,7 @@ export const createPushRuntime = (deps) => {
sendPushToAllUiSessions,
updateUiVisibility,
isAnyUiVisible,
isAnyInteractiveClientVisible,
isUiVisible,
ensurePushInitialized,
setPushInitialized,
@@ -42,4 +42,38 @@ describe('push runtime visibility tracking', () => {
expect(runtime.isAnyUiVisible()).toBe(false);
expect(runtime.isUiVisible('visible-client')).toBe(false);
});
it('treats only mobile platforms as non-interactive for isAnyInteractiveClientVisible', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const runtime = createRuntime();
// Only the phone (foreground) is connected → no interactive client to absorb the notification.
runtime.updateUiVisibility('phone', true, 'ios');
expect(runtime.isAnyUiVisible()).toBe(true);
expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
// A visible desktop counts as interactive → suppress mobile push.
runtime.updateUiVisibility('desktop', true, 'desktop');
expect(runtime.isAnyInteractiveClientVisible()).toBe(true);
// Desktop hidden again → back to mobile-only, push should flow to the phone.
runtime.updateUiVisibility('desktop', false, 'desktop');
expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
// A client that never reported a platform is treated as interactive (conservative).
runtime.updateUiVisibility('legacy', true);
expect(runtime.isAnyInteractiveClientVisible()).toBe(true);
});
it('remembers the last platform when a heartbeat omits it', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const runtime = createRuntime();
runtime.updateUiVisibility('phone', true, 'android');
runtime.updateUiVisibility('phone', true); // heartbeat without platform
expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
});
});
@@ -35,7 +35,10 @@ export const registerNotificationRoutes = (app, dependencies) => {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
addOrUpdateApnsToken,
removeApnsToken,
updateUiVisibility,
clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
@@ -106,6 +109,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
}
}
const platform = typeof req.body?.platform === 'string' ? req.body.platform : undefined;
await addOrUpdatePushSubscription(
uiToken,
{
@@ -113,7 +117,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
p256dh: keys.p256dh,
auth: keys.auth,
},
req.headers['user-agent']
req.headers['user-agent'],
platform
);
return res.json({ ok: true });
@@ -138,6 +143,50 @@ export const registerNotificationRoutes = (app, dependencies) => {
return res.json({ ok: true });
});
// Native iOS APNs device token registration (mirrors /api/push/subscribe). The token
// is a hex APNs device token from @capacitor/push-notifications, scoped to the UI
// session like web-push subscriptions.
app.post('/api/push/apns-token', async (req, res) => {
await ensureSessionWatcher();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
if (!deviceToken) {
return res.status(400).json({ error: 'Invalid body' });
}
const platform = req.body?.platform === 'android' ? 'android' : 'ios';
if (typeof addOrUpdateApnsToken === 'function') {
await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform);
}
return res.json({ ok: true });
});
app.delete('/api/push/apns-token', async (req, res) => {
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
if (!deviceToken) {
return res.status(400).json({ error: 'Invalid body' });
}
if (typeof removeApnsToken === 'function') {
await removeApnsToken(uiToken, deviceToken);
}
return res.json({ ok: true });
});
app.post('/api/push/visibility', async (req, res) => {
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
@@ -146,8 +195,9 @@ export const registerNotificationRoutes = (app, dependencies) => {
return res.status(401).json({ error: 'UI session missing' });
}
const visible = req.body && typeof req.body === 'object' ? req.body.visible : null;
updateUiVisibility(uiToken, visible === true);
const body = req.body && typeof req.body === 'object' ? req.body : {};
const platform = typeof body.platform === 'string' ? body.platform : undefined;
updateUiVisibility(uiToken, body.visible === true, platform);
return res.json({ ok: true });
});
@@ -301,6 +351,10 @@ export const registerNotificationRoutes = (app, dependencies) => {
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
markSessionViewed(sessionId, clientId);
// The user is engaging with the app, so the native push badge no longer
// applies — reset it here too (not only on the visibility beacon), since
// opening the app reliably marks the opened session viewed.
if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge();
return res.json({
success: true,
@@ -326,6 +380,9 @@ export const registerNotificationRoutes = (app, dependencies) => {
const sessionId = req.params.id;
markUserMessageSent(sessionId);
// Sending a message means the user is active in the app; reset the native
// push badge so it counts only notifications since this engagement.
if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge();
return res.json({
success: true,
@@ -10,10 +10,84 @@ export const createNotificationTriggerRuntime = (deps) => {
emitDesktopNotification,
broadcastUiNotification,
sendPushToAllUiSessions,
sendApnsToAllUiSessions,
isAnyInteractiveClientVisible,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
} = deps;
// App-icon badge for native push: the set of DISTINCT collapse-ids (the push
// `tag`, e.g. `ready-<sessionId>` / `permission-<requestKey>`) we've sent since
// the app was last foregrounded. The badge is the absolute APNs `aps.badge`.
//
// We key by `tag`, not sessionId, because the tag IS the banner identity: iOS
// uses it as `apns-collapse-id`, so same-tag pushes REPLACE one banner while
// different tags are distinct banners. One session can raise several banners
// (ready + question + permission are different tags), so counting sessionIds
// both over- and under-counts the lock-screen stack; counting tags mirrors it.
//
// We deliberately do NOT derive this from the live attention snapshot
// (needsAttention/isViewed): that machinery is for in-app indicators on
// connected clients — a backgrounded client stays "viewing", and needsAttention
// is set by a separate session.status event that races the push trigger. The
// set is cleared when a UI client reports visible (`clearPendingPushBadge`),
// the same moment the device zeroes its icon badge on becomeActive.
const pendingPushTags = new Set();
const clearPendingPushBadge = () => {
pendingPushTags.clear();
};
const trackPushAndCountBadge = (tag) => {
if (typeof tag === 'string' && tag.length > 0) {
pendingPushTags.add(tag);
}
return pendingPushTags.size;
};
// Generic notification for native push (per the mobile design): a fixed, scenario-based
// title + the session name as the body. No model/project/message content crosses the relay.
const APNS_TITLE_BY_TYPE = {
ready: 'Agent response is ready',
error: 'Agent hit an error',
question: 'Agent needs your input',
permission: 'Agent needs permission',
};
const toApnsGenericPayload = (payload) => {
const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0
? data.sessionName.trim()
: 'Session';
return {
title: APNS_TITLE_BY_TYPE[data.type] || 'Agent update',
body: sessionName,
badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined),
tag: payload?.tag,
// sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content.
data: typeof data.sessionId === 'string' ? { sessionId: data.sessionId } : undefined,
};
};
// Fan a notification out to every delivery channel: browser web-push (full templated
// payload) and native iOS APNs (generic model-based text). Both share the dedup tag and
// `requireNoSse` focus gate; a failure in one channel must not block the other.
const fanoutPush = (payload, options) => {
// Presence-aware routing: if any interactive (non-mobile) client — desktop/web/vscode — is
// currently visible, it already shows the in-app notification, so skip the native push to the
// phone. Gated on the desktop's visibility (reliable), never the phone's own. When we skip we
// also skip toApnsGenericPayload, so the badge isn't incremented for an undelivered push.
const interactiveVisible = isAnyInteractiveClientVisible?.() === true;
return Promise.all([
Promise.resolve(sendPushToAllUiSessions?.(payload, options)).catch((error) => {
console.warn('[Push] web-push fanout failed:', error?.message ?? error);
}),
interactiveVisible
? Promise.resolve()
: Promise.resolve(sendApnsToAllUiSessions?.(toApnsGenericPayload(payload), options)).catch((error) => {
console.warn('[APNs] fanout failed:', error?.message ?? error);
}),
]);
};
let getIsWindowFocused = typeof deps.getIsWindowFocused === 'function'
? deps.getIsWindowFocused
: null;
@@ -240,6 +314,7 @@ export const createNotificationTriggerRuntime = (deps) => {
let title = `${formatMode(info?.mode)} agent is ready`;
let body = `${formatModelId(info?.modelID)} completed the task`;
let sessionName = '';
try {
const templates = settings.notificationTemplates || {};
@@ -249,6 +324,7 @@ export const createNotificationTriggerRuntime = (deps) => {
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
const variables = await buildTemplateVariables(payload, sessionId);
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
const messageId = info?.id;
let lastMessage = extractLastMessageText(payload);
@@ -283,7 +359,7 @@ export const createNotificationTriggerRuntime = (deps) => {
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
await sendPushToAllUiSessions(
await fanoutPush(
{
title,
body,
@@ -291,6 +367,7 @@ export const createNotificationTriggerRuntime = (deps) => {
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
sessionName,
type: 'ready',
},
},
@@ -308,9 +385,11 @@ export const createNotificationTriggerRuntime = (deps) => {
let title = 'Tool error';
let body = 'An error occurred';
let sessionName = '';
try {
const variables = await buildTemplateVariables(payload, sessionId);
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
const errorMessageId = info?.id;
let lastMessage = extractLastMessageText(payload);
if (!lastMessage) {
@@ -345,7 +424,7 @@ export const createNotificationTriggerRuntime = (deps) => {
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
await sendPushToAllUiSessions(
await fanoutPush(
{
title,
body,
@@ -353,6 +432,7 @@ export const createNotificationTriggerRuntime = (deps) => {
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
sessionName,
type: 'error',
},
},
@@ -391,9 +471,11 @@ export const createNotificationTriggerRuntime = (deps) => {
? 'Switch to build mode'
: header || 'Input needed';
let body = questionText || 'Agent is waiting for your response';
let sessionName = '';
try {
const variables = await buildTemplateVariables(payload, sessionId);
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
variables.last_message = questionText || header || '';
const templates = settings.notificationTemplates || {};
@@ -421,7 +503,7 @@ export const createNotificationTriggerRuntime = (deps) => {
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
void sendPushToAllUiSessions(
void fanoutPush(
{
title,
body,
@@ -429,6 +511,7 @@ export const createNotificationTriggerRuntime = (deps) => {
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
sessionName,
type: 'question',
},
},
@@ -505,9 +588,11 @@ export const createNotificationTriggerRuntime = (deps) => {
let title = 'Permission required';
let body = fallbackMessage;
let sessionName = '';
try {
const variables = await buildTemplateVariables(payload, sessionId);
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
variables.last_message = fallbackMessage;
const templates = settings.notificationTemplates || {};
@@ -539,7 +624,7 @@ export const createNotificationTriggerRuntime = (deps) => {
notifiedPermissionRequests.add(requestKey);
}
void sendPushToAllUiSessions(
void fanoutPush(
{
title,
body,
@@ -547,6 +632,7 @@ export const createNotificationTriggerRuntime = (deps) => {
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
sessionName,
type: 'permission',
},
},
@@ -562,5 +648,6 @@ export const createNotificationTriggerRuntime = (deps) => {
maybeSendPushForTrigger,
setAutoAcceptSession,
setGetIsWindowFocused,
clearPendingPushBadge,
};
};
+4 -7
View File
@@ -409,7 +409,10 @@ function createAgent(agentName, config, workingDirectory, scope) {
targetScope = AGENT_SCOPE.USER;
}
const { prompt, scope: _scopeFromConfig, ...frontmatter } = config;
const { prompt, scope: _scopeFromConfig, ...rawFrontmatter } = config;
const frontmatter = Object.fromEntries(
Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined)
);
writeMdFile(targetPath, frontmatter, prompt || '');
console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`);
@@ -685,12 +688,6 @@ function deleteAgent(agentName, workingDirectory, scope) {
}
export {
ensureProjectAgentDir,
getProjectAgentPath,
getUserAgentPath,
getAgentScope,
getAgentWritePath,
getAgentPermissionSource,
getAgentSources,
getAgentConfig,
createAgent,
@@ -51,7 +51,8 @@ export const createOpenCodeAuthStateRuntime = (dependencies) => {
return {};
}
const credentials = Buffer.from(`opencode:${password}`).toString('base64');
const username = process.env.OPENCODE_SERVER_USERNAME?.trim() || 'opencode';
const credentials = Buffer.from(`${username}:${password}`).toString('base64');
return { Authorization: `Basic ${credentials}` };
};
+6
View File
@@ -32,7 +32,10 @@ export const createBootstrapRuntime = (dependencies) => {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
addOrUpdateApnsToken,
removeApnsToken,
updateUiVisibility,
clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
@@ -95,7 +98,10 @@ export const createBootstrapRuntime = (dependencies) => {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
addOrUpdateApnsToken,
removeApnsToken,
updateUiVisibility,
clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
@@ -327,11 +327,6 @@ function deleteCommand(commandName, workingDirectory) {
}
export {
ensureProjectCommandDir,
getProjectCommandPath,
getUserCommandPath,
getCommandScope,
getCommandWritePath,
getCommandSources,
createCommand,
updateCommand,
@@ -26,6 +26,30 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
expandSnippets,
} = dependencies;
// Build the response for a config mutation based on whether OpenCode actually
// reloaded the change. When connected to an external OpenCode server that
// OpenChamber cannot restart, the change is persisted to disk but the running
// server will not serve it until the user restarts that server. We must not
// report a clean "reloading" success in that case, otherwise the UI silently
// reverts the edit to the stale value on the next refresh.
const buildConfigMutationResponse = (refreshResult, { liveMessage, manualRestartMessage }) => {
if (refreshResult && refreshResult.external) {
return {
success: true,
requiresReload: false,
requiresManualRestart: true,
message: manualRestartMessage,
};
}
return {
success: true,
requiresReload: true,
message: liveMessage,
reloadDelayMs: clientReloadDelayMs,
};
};
const completeMcpMutation = async (res, action, name, applyChange) => {
applyChange();
@@ -104,16 +128,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createAgent(agentName, config, directory, scope);
await refreshOpenCodeAfterConfigChange('agent creation', {
const refreshResult = await refreshOpenCodeAfterConfigChange('agent creation', {
agentName
});
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} created successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
res.json(buildConfigMutationResponse(refreshResult, {
liveMessage: `Agent ${agentName} created successfully. Reloading interface…`,
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
}));
} catch (error) {
console.error('Failed to create agent:', error);
res.status(500).json({ error: error.message || 'Failed to create agent' });
@@ -134,16 +156,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
console.log('[Server] Working directory:', directory);
updateAgent(agentName, updates, directory);
await refreshOpenCodeAfterConfigChange('agent update');
const refreshResult = await refreshOpenCodeAfterConfigChange('agent update');
console.log(`[Server] Agent ${agentName} updated successfully`);
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} updated successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
res.json(buildConfigMutationResponse(refreshResult, {
liveMessage: `Agent ${agentName} updated successfully. Reloading interface…`,
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
}));
} catch (error) {
console.error('[Server] Failed to update agent:', error);
console.error('[Server] Error stack:', error.stack);
@@ -161,14 +181,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
const scope = req.body?.scope;
deleteAgent(agentName, directory, scope);
await refreshOpenCodeAfterConfigChange('agent deletion');
const refreshResult = await refreshOpenCodeAfterConfigChange('agent deletion');
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
res.json(buildConfigMutationResponse(refreshResult, {
liveMessage: `Agent ${agentName} deleted successfully. Reloading interface…`,
manualRestartMessage: `Agent ${agentName} deleted. Restart your connected OpenCode server to apply the change.`,
}));
} catch (error) {
console.error('Failed to delete agent:', error);
res.status(500).json({ error: error.message || 'Failed to delete agent' });
@@ -396,6 +396,35 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
}
};
const runWithClientCreateAuth = async (req, res, next, handler) => {
try {
if (typeof uiAuthController.resolveAuthContext === 'function') {
const context = await uiAuthController.resolveAuthContext(req, res, {
allowClientAuth: true,
allowUrlToken: false,
});
if (context?.type === 'session') {
await handler(context);
return;
}
if (context?.type === 'client') {
const client = await clientRecordFromAuthContext(context);
if (client?.clientKind === 'desktop-local') {
await handler({ ...context, client });
return;
}
return res.status(403).json({ error: 'Client tokens cannot create remote clients' });
}
}
await runWithUiAuth(req, res, next, async () => {
await handler({ type: 'session' });
}, { sessionOnly: true });
} catch (error) {
next(error);
}
};
const clientIdFromAuthContext = (context) => {
const raw = context?.client?.id || context?.clientId;
return typeof raw === 'string' && raw.length > 0 ? raw : null;
@@ -567,7 +596,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
});
app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => {
await runWithUiAuth(req, res, next, async () => {
await runWithClientCreateAuth(req, res, next, async () => {
const result = await remoteClientAuthRuntime.createClient({
label: req.body?.label,
clientKind: req.body?.clientKind,
@@ -575,7 +604,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
});
res.setHeader('Cache-Control', 'no-store');
res.status(201).json(result);
}, { sessionOnly: true });
});
});
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
@@ -399,6 +399,36 @@ describe('client auth routes', () => {
expect(revoked.body.client.id).toBe(current.body.client.id);
});
it('allows only the local desktop client token to create remote client tokens', async () => {
const app = express();
let authContext = { type: 'session' };
const dependencies = createDependencies({
resolveAuthContext: async () => authContext,
});
registerAuthAndAccessRoutes(app, dependencies);
const desktop = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
const remote = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Phone' });
authContext = { type: 'client', clientId: remote.body.client.id, client: remote.body.client };
const denied = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Another phone' });
expect(denied.status).toBe(403);
expect(denied.body.error).toBe('Client tokens cannot create remote clients');
authContext = { type: 'client', clientId: desktop.body.client.id, client: desktop.body.client };
const created = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Mobile' });
expect(created.status).toBe(201);
expect(created.body.client.label).toBe('Mobile');
});
it('requires UI-session auth for passkey registration management routes', async () => {
const app = express();
const dependencies = createDependencies();
@@ -13,6 +13,33 @@ import { registerPluginRoutes } from './plugin-routes.js';
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
import { registerOpenCodeRoutes } from './routes.js';
import { getProviderSources, removeProviderConfig } from './providers.js';
import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js';
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
import { listSnippets, getSnippet, createSnippet, updateSnippet, deleteSnippet, expandSnippets } from './snippets.js';
import {
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
} from './plugins.js';
import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js';
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.js';
import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js';
import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js';
import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js';
import { scanSkillsRepository } from '../skills-catalog/scan.js';
import { installSkillsFromRepository } from '../skills-catalog/install.js';
import { scanClawdHubPage } from '../skills-catalog/clawdhub/scan.js';
import { installSkillsFromClawdHub } from '../skills-catalog/clawdhub/install.js';
export const createFeatureRoutesRuntime = (dependencies) => {
const {
@@ -63,8 +90,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
writeSseEvent,
} = routeDependencies;
const { getProviderSources, removeProviderConfig } = await import('./index.js');
registerSettingsUtilityRoutes(app, {
readCustomThemesFromDisk,
refreshOpenCodeAfterConfigChange,
@@ -111,40 +136,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
writeSseEvent,
});
const {
getAgentSources,
getAgentConfig,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
createCommand,
updateCommand,
deleteCommand,
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
listSnippets,
getSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
expandSnippets,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
} = await import('./index.js');
registerConfigEntityRoutes(app, {
resolveProjectDirectory,
resolveOptionalProjectDirectory,
@@ -193,32 +184,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
isExactSemver,
});
const {
getSkillSources,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
} = await import('./index.js');
const {
getCuratedSkillsSources,
getCacheKey,
getCachedScan,
setCachedScan,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
scanClawdHubPage,
installSkillsFromClawdHub,
isClawdHubSource,
} = await import('../skills-catalog/index.js');
const { getProfiles, getProfile } = await import('../git/index.js');
registerSkillRoutes(app, {
-95
View File
@@ -1,95 +0,0 @@
export {
AGENT_DIR,
COMMAND_DIR,
SKILL_DIR,
CONFIG_FILE,
AGENT_SCOPE,
COMMAND_SCOPE,
SKILL_SCOPE,
readConfig,
writeConfig,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
} from './shared.js';
export {
getAgentScope,
getAgentPermissionSource,
getAgentSources,
getAgentConfig,
createAgent,
updateAgent,
deleteAgent,
} from './agents.js';
export {
getCommandScope,
getCommandSources,
createCommand,
updateCommand,
deleteCommand,
} from './commands.js';
export {
getSkillSources,
getSkillScope,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
updateSkill,
deleteSkill,
} from './skills.js';
export {
getProviderSources,
removeProviderConfig,
} from './providers.js';
export {
readAuthFile,
writeAuthFile,
removeProviderAuth,
getProviderAuth,
listProviderAuths,
AUTH_FILE,
OPENCODE_DATA_DIR,
} from './auth.js';
export { createUiAuth } from '../ui-auth/ui-auth.js';
export {
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
} from './mcp.js';
export {
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
parsePluginRaw,
serializePluginEntry,
} from './plugins.js';
export {
listSnippets,
getSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
expandSnippets,
} from './snippets.js';
export { getNpmInfo, lookupNpmPackage, clearCache as clearNpmCache } from './npm-registry.js';
export { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
+59 -10
View File
@@ -1,5 +1,6 @@
import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
@@ -140,7 +141,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
});
};
const closeManagedOpenCodeChild = async (child) => {
const terminateChildProcess = async (child) => {
if (!child) {
return;
}
@@ -212,6 +213,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
await waitForChildProcessClose(child, 1000);
};
const closeManagedOpenCodeChild = async (child) => {
const pid = child?.pid;
try {
await terminateChildProcess(child);
} finally {
// Drop it from the registry only once it has actually exited, so a child
// that survived teardown stays eligible for the next run's reaper.
if (Number.isInteger(pid) && hasChildProcessExited(child)) {
unregisterManagedProcess(pid);
}
}
};
const formatCapturedOutput = ({ stdout, stderr }) => {
const parts = [];
if (stdout.trim()) {
@@ -324,6 +338,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
child.on('error', onError);
});
// Record this child so a future run can reap it if we crash before teardown.
// The web-server lifecycle runs in-process inside multiple hosts, so tag the
// actual host (Electron sets OPENCHAMBER_RUNTIME='desktop'; the standalone
// web CLI leaves it unset → 'web'; SSH remote → 'ssh-remote') rather than a
// hardcoded label, matching the server's existing runtimeName convention.
registerManagedProcess({
pid: child.pid,
ownerPid: process.pid,
port,
binary,
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
});
return {
url,
pid: child.pid || null,
@@ -726,12 +753,22 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
await restartOpenCode();
// A managed OpenCode process is restarted (and thus re-reads config from
// disk) by restartOpenCode(). An external OpenCode server is NOT owned by
// OpenChamber: restartOpenCode() only re-probes its health, so the freshly
// written config is on disk but the running server keeps serving its old,
// startup-cached config until the user restarts it themselves. Report this
// honestly so callers don't claim the change is live.
const external = state.isExternalOpenCode === true;
try {
await waitForOpenCodeReady();
state.isOpenCodeReady = true;
state.openCodeNotReadySince = 0;
if (agentName) {
// Waiting for the agent to appear only makes sense when we actually
// reloaded config. An external server will never surface it here.
if (agentName && !external) {
await waitForAgentPresence(agentName);
}
@@ -743,10 +780,22 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
throw error;
}
return { reloaded: !external, external };
};
const bootstrapOpenCodeAtStartup = async () => {
try {
// Before doing anything, reap any OpenCode process WE spawned in a prior
// run that was orphaned by a crash/hard-exit. Verified + scoped to our own
// pids, so it never touches a live instance's or the user's own server.
try {
const { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) });
if (reaped > 0) console.log(`[lifecycle] startup reaped ${reaped} orphaned OpenCode process(es)`);
} catch (error) {
console.warn('[lifecycle] orphan reap failed:', error?.message ?? error);
}
syncFromHmrState();
if (await isOpenCodeProcessHealthy()) {
console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`);
@@ -770,15 +819,15 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) {
console.log('Auto-detected existing OpenCode server on default port 4096');
setOpenCodePort(4096);
state.isOpenCodeReady = true;
state.isExternalOpenCode = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else {
// We never auto-attach to an arbitrary pre-existing OpenCode instance.
// Attaching to an external server requires explicit opt-in via env
// (OPENCODE_HOST / OPENCODE_PORT / OPENCODE_SKIP_START), handled by the
// branches above. Without that opt-in we always start our OWN managed
// instance on a freshly-allocated port. A blind probe of the default
// port 4096 used to hijack a user's separately-running OpenCode (e.g.
// the OpenCode desktop app), coupling our lifecycle to theirs and
// breaking init against an unexpected server version/config.
if (env.ENV_EFFECTIVE_PORT) {
console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`);
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
@@ -0,0 +1,251 @@
// Managed OpenCode process registry + orphan reaper.
//
// OpenChamber spawns the OpenCode server as an EXTERNAL child binary (on Unix
// with `detached: true`, so it leads its own process group). That binary can
// therefore outlive its parent if the parent is hard-killed/crashes/`Ctrl+C`ed
// before graceful teardown runs — leaving an orphaned `opencode serve` that
// then contends on the shared SQLite DB and slows everything down.
//
// We cannot tie an arbitrary external binary to the parent's death portably
// (Electron's `utilityProcess` would, but it only runs JS entrypoints, not a
// standalone binary). So we use the same pattern OpenCode's own CLI daemon uses
// for its detached server: an on-disk record of the pids WE spawned, plus a
// startup reaper that kills ONLY our own, verified, genuinely-orphaned
// processes — never a process a live instance (another desktop window, a VS
// Code host, the user's standalone `opencode`) is actively using.
//
// Storage: ONE FILE PER SPAWNED PROCESS in a registry directory, named
// `<childPid>.json`. Multiple runtimes (web/desktop/VS Code) and multiple
// windows all run concurrently; a single shared JSON file would be corrupted by
// the read-modify-write race (last writer wins, clobbering another instance's
// entry). Per-process files mean every instance only ever writes/deletes its
// OWN file, so there is no write contention at all.
//
// Safety model (why this never kills the wrong thing):
// 1. The reaper only ever considers pids THIS product recorded. The user's
// standalone CLI server, the official desktop app, and the TUI are never
// recorded, so they are never even candidates.
// 2. Before killing, it re-verifies the live pid is still an `opencode serve`
// matching the recorded port (guards against the OS recycling a dead pid
// onto an unrelated process).
// 3. It kills only when the spawning owner is provably gone — the child has
// been reparented to init/pid 1, or the recorded owner pid is dead. A
// child still owned by a live instance is left untouched.
//
// The VS Code extension cannot import this module (it does not bundle the web
// package); it carries a parity implementation that reads/writes the SAME dir.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const resolveRegistryDir = () => {
const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY;
if (override && override.trim()) return override.trim();
return path.join(os.homedir(), '.config', 'openchamber', 'managed-opencode');
};
const entryFilePath = (pid) => path.join(resolveRegistryDir(), `${pid}.json`);
const writeEntryFile = (entry) => {
const dir = resolveRegistryDir();
try {
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${entry.pid}.json`);
const tmp = `${filePath}.tmp-${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
fs.renameSync(tmp, filePath);
} catch {
// Best-effort: a failed registry write must never break spawn/shutdown.
}
};
const readAllEntries = () => {
const dir = resolveRegistryDir();
let names = [];
try {
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
} catch {
return [];
}
const out = [];
for (const name of names) {
const filePath = path.join(dir, name);
try {
const entry = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (entry && Number.isInteger(entry.pid)) {
out.push({ entry, filePath });
} else {
fs.rmSync(filePath, { force: true });
}
} catch {
// Corrupt/partial file — drop it.
try { fs.rmSync(filePath, { force: true }); } catch {}
}
}
return out;
};
/** Record an OpenCode process WE spawned so a future run can reap it if orphaned. */
export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } = {}) => {
if (!Number.isInteger(pid)) return;
writeEntryFile({
pid,
ownerPid: Number.isInteger(ownerPid) ? ownerPid : process.pid,
port: Number.isInteger(port) ? port : null,
binary: typeof binary === 'string' ? binary : null,
runtime: typeof runtime === 'string' ? runtime : 'web',
startedAt: new Date().toISOString(),
});
};
/** Drop a pid from the registry (after we have killed/closed it ourselves). */
export const unregisterManagedProcess = (pid) => {
if (!Number.isInteger(pid)) return;
try {
fs.rmSync(entryFilePath(pid), { force: true });
} catch {
}
};
const isPidAlive = (pid) => {
if (!Number.isInteger(pid)) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM = process exists but we lack permission to signal it → still alive.
return error?.code === 'EPERM';
}
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Returns { ppid, command } for a live pid on Unix, or null if it can't be read.
const readUnixProcInfo = (pid) => {
try {
const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
const line = (result.stdout || '').trim();
if (!line) return null;
const match = line.match(/^\s*(\d+)\s+(.*)$/);
if (!match) return null;
return { ppid: Number.parseInt(match[1], 10), command: match[2] };
} catch {
return null;
}
};
// Windows image name for a pid (e.g. "opencode.exe"), or null.
const readWindowsImageName = (pid) => {
try {
const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
return (result.stdout || '').trim() || null;
} catch {
return null;
}
};
const commandIdentifiesOurServer = (command, entry) => {
if (typeof command !== 'string') return false;
const lower = command.toLowerCase();
if (!lower.includes('opencode') || !lower.includes('serve')) return false;
// Tie to the exact server we registered when we know its port, so a recycled
// pid running a *different* opencode server is never mistaken for ours.
if (Number.isInteger(entry.port) && !command.includes(String(entry.port))) return false;
return true;
};
const killOrphan = async (pid) => {
if (process.platform === 'win32') {
try {
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true });
} catch {
}
return;
}
const signalTree = (signal) => {
try { process.kill(-pid, signal); } catch {}
try { process.kill(pid, signal); } catch {}
};
signalTree('SIGTERM');
for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) {
await sleep(150);
}
if (isPidAlive(pid)) {
signalTree('SIGKILL');
await sleep(300);
}
};
// Decide+act on a single registry entry. Returns true if it was reaped.
const processEntry = async (entry, { log }) => {
// Dead pid → nothing to do (caller drops the file).
if (!isPidAlive(entry.pid)) return false;
const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid);
if (process.platform === 'win32') {
const image = readWindowsImageName(entry.pid);
const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode');
// Windows lacks reliable reparent-to-1 semantics (job objects usually kill
// children with the parent), so we reap only when the owner is provably dead
// AND the image still looks like opencode.
if (looksLikeOpencode && ownerGone) {
await killOrphan(entry.pid);
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (owner ${entry.ownerPid} gone)`);
return true;
}
return false;
}
const info = readUnixProcInfo(entry.pid);
// Can't verify identity (or it's not our server) → leave it alone.
if (!info || !commandIdentifiesOurServer(info.command, entry)) return false;
const orphaned = info.ppid === 1 || ownerGone;
if (!orphaned) return false; // still owned by a live instance
await killOrphan(entry.pid);
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (reparented/owner gone)`);
return true;
};
/**
* Kill any genuinely-orphaned OpenCode processes WE previously spawned, and
* prune their registry files. Safe to call at startup before spawning a new
* server. Returns { inspected, reaped }.
*/
export const reapOrphanedProcesses = async ({ log } = {}) => {
const records = readAllEntries();
if (records.length === 0) return { inspected: 0, reaped: 0 };
let reaped = 0;
for (const { entry, filePath } of records) {
let drop = false;
try {
const wasReaped = await processEntry(entry, { log });
if (wasReaped) reaped += 1;
// Drop the file when the process is gone (reaped now, or already dead);
// keep it only while the process is still alive and owned by a live owner.
drop = wasReaped || !isPidAlive(entry.pid);
} catch (error) {
log?.(`[lifecycle] reap check failed for pid ${entry.pid}: ${error?.message ?? error}`);
}
if (drop) {
try { fs.rmSync(filePath, { force: true }); } catch {}
}
}
return { inspected: records.length, reaped };
};
@@ -2,9 +2,9 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
export const NPM_CACHE_TTL_MS = 3_600_000;
export const NPM_FETCH_TIMEOUT_MS = 5_000;
export const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
const NPM_CACHE_TTL_MS = 3_600_000;
const NPM_FETCH_TIMEOUT_MS = 5_000;
const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
/**
* @typedef {Object} NpmPackagePayload
@@ -1,5 +1,13 @@
import { createRealpathCache } from '../path-realpath-cache.js';
// Browser transport percent-encodes directory hints and marks them explicitly.
// Only marked values are decoded so literal percent sequences from direct API
// clients are preserved.
const safeDecodeMarkedURIComponent = (value, encoding) => {
if (encoding !== 'uri') return value;
try { return decodeURIComponent(value); } catch { return value; }
};
export const createProjectDirectoryRuntime = (dependencies) => {
const {
fsPromises,
@@ -50,18 +58,24 @@ export const createProjectDirectoryRuntime = (dependencies) => {
};
const resolveProjectDirectory = async (req) => {
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null;
const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
const requested = [headerDirectory, queryDirectory].filter(Boolean);
if (requested) {
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
if (requested.length > 0) {
let lastError = null;
for (const candidate of requested) {
const validated = await validateDirectoryPath(candidate);
if (validated.ok) {
return { directory: validated.directory, error: null };
}
lastError = validated.error;
}
return { directory: validated.directory, error: null };
return { directory: null, error: lastError };
}
const readSettings = typeof getReadSettingsFromDiskMigrated === 'function'
@@ -103,22 +117,27 @@ export const createProjectDirectoryRuntime = (dependencies) => {
};
const resolveOptionalProjectDirectory = async (req) => {
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null;
const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
const requested = [headerDirectory, queryDirectory].filter(Boolean);
if (!requested) {
if (requested.length === 0) {
return { directory: null, error: null };
}
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
let lastError = null;
for (const candidate of requested) {
const validated = await validateDirectoryPath(candidate);
if (validated.ok) {
return { directory: validated.directory, error: null };
}
lastError = validated.error;
}
return { directory: validated.directory, error: null };
return { directory: null, error: lastError };
};
return {
@@ -128,6 +128,80 @@ describe('project directory runtime', () => {
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('decodes marked x-opencode-directory header values', async () => {
const pathWithUnicode = '/home/user/测试项目';
let validatedPath = null;
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
validatedPath = p;
return { isDirectory: () => true };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => {
if (header === 'x-opencode-directory') return encodeURIComponent(pathWithUnicode);
if (header === 'x-opencode-directory-encoding') return 'uri';
return null;
},
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(validatedPath).toBe(pathWithUnicode);
expect(result).toEqual({ directory: pathWithUnicode, error: null });
});
it('preserves raw percent sequences without directory encoding marker', async () => {
const rawPath = '/home/user/foo%20bar';
let validatedPath = null;
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
validatedPath = p;
return { isDirectory: () => true };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? rawPath : null,
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(validatedPath).toBe(rawPath);
expect(result).toEqual({ directory: rawPath, error: null });
});
it('falls back to query directory when an unmarked encoded header is invalid', async () => {
const validPath = '/home/user/workspace/project';
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
if (p === validPath) return { isDirectory: () => true };
throw { code: 'ENOENT' };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? encodeURIComponent(validPath) : null,
query: { directory: validPath },
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: validPath, error: null });
});
it('resolves symlinks in query directory parameter', async () => {
const runtime = createTestRuntime({
fsPromises: {
@@ -222,5 +296,29 @@ describe('project directory runtime', () => {
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('preserves raw percent sequences without directory encoding marker', async () => {
const rawPath = '/optional/foo%25bar';
let validatedPath = null;
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
validatedPath = p;
return { isDirectory: () => true };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? rawPath : null,
query: {},
};
const result = await runtime.resolveOptionalProjectDirectory(req);
expect(validatedPath).toBe(rawPath);
expect(result).toEqual({ directory: rawPath, error: null });
});
});
});
+38 -5
View File
@@ -31,7 +31,26 @@ export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions }
};
};
export const waitForSseDrain = (res, signal) => new Promise((resolve) => {
export const normalizeForwardedDirectoryHeaders = (headers) => {
const rawDirectory = headers?.['x-opencode-directory'];
if (typeof rawDirectory !== 'string') {
return headers;
}
if (headers['x-opencode-directory-encoding'] !== 'uri') {
return headers;
}
try {
headers['x-opencode-directory'] = decodeURIComponent(rawDirectory);
} catch {
// Leave malformed values untouched; upstream will reject invalid paths.
}
delete headers['x-opencode-directory-encoding'];
return headers;
};
const waitForSseDrain = (res, signal) => new Promise((resolve) => {
if (signal?.aborted || res.writableEnded || res.destroyed) {
resolve();
return;
@@ -113,7 +132,7 @@ const SESSION_LIST_ALLOWED_FIELDS = [
'project',
];
export const sanitizeSessionListItem = (session) => {
const sanitizeSessionListItem = (session) => {
if (!session || typeof session !== 'object' || Array.isArray(session)) {
return session;
}
@@ -149,7 +168,7 @@ export const sanitizeSessionListItem = (session) => {
return sanitized;
};
export const sanitizeSessionListPayload = (payload) => {
const sanitizeSessionListPayload = (payload) => {
if (!Array.isArray(payload)) {
return payload;
}
@@ -295,7 +314,9 @@ export const registerOpenCodeProxy = (app, deps) => {
? req.originalUrl
: (typeof req.url === 'string' ? req.url : '');
const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
const headers = collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders());
const headers = normalizeForwardedDirectoryHeaders(
collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())
);
headers.accept ??= 'text/event-stream';
headers['cache-control'] ??= 'no-cache';
@@ -414,7 +435,7 @@ export const registerOpenCodeProxy = (app, deps) => {
const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
const headers = req
? {
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
...normalizeForwardedDirectoryHeaders(collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())),
accept: 'application/json',
'accept-encoding': 'identity',
}
@@ -654,6 +675,18 @@ export const registerOpenCodeProxy = (app, deps) => {
proxyReq.setHeader('Authorization', authHeaders.Authorization);
}
if (req.headers?.['x-opencode-directory-encoding'] === 'uri') {
const rawDirectory = req.headers['x-opencode-directory'];
if (typeof rawDirectory === 'string') {
try {
proxyReq.setHeader('x-opencode-directory', decodeURIComponent(rawDirectory));
} catch {
proxyReq.setHeader('x-opencode-directory', rawDirectory);
}
}
proxyReq.removeHeader?.('x-opencode-directory-encoding');
}
// Defensive: request identity encoding from upstream OpenCode.
// This avoids compressed-body/header mismatches in multi-proxy setups.
proxyReq.setHeader('accept-encoding', 'identity');
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createDirectoryQueryCanonicalizer } from './proxy.js';
import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js';
describe('createDirectoryQueryCanonicalizer', () => {
it('canonicalizes directory query params and preserves other params', async () => {
@@ -70,3 +70,26 @@ describe('createDirectoryQueryCanonicalizer', () => {
await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1');
});
});
describe('normalizeForwardedDirectoryHeaders', () => {
it('decodes marked directory headers before forwarding to OpenCode', () => {
const headers = normalizeForwardedDirectoryHeaders({
'x-opencode-directory': encodeURIComponent('/Users/example/project'),
'x-opencode-directory-encoding': 'uri',
});
expect(headers).toEqual({
'x-opencode-directory': '/Users/example/project',
});
});
it('preserves unmarked percent sequences from direct clients', () => {
const headers = normalizeForwardedDirectoryHeaders({
'x-opencode-directory': '/Users/example/project%20literal',
});
expect(headers).toEqual({
'x-opencode-directory': '/Users/example/project%20literal',
});
});
});
@@ -131,9 +131,15 @@ export const createServerStartupRuntime = (dependencies) => {
const handleSignal = async () => {
await gracefulShutdown();
};
// Cover every signal a shell or dev harness may use to stop/restart us, so
// the managed OpenCode child is always torn down gracefully instead of
// orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP
// (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`).
process.on('SIGTERM', handleSignal);
process.on('SIGINT', handleSignal);
process.on('SIGQUIT', handleSignal);
process.on('SIGHUP', handleSignal);
process.on('SIGUSR2', handleSignal);
setSignalsAttached(true);
syncToHmrState();
}
@@ -26,6 +26,9 @@ export const createSettingsHelpers = (dependencies) => {
const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128;
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
const HIDDEN_MODELS_MAX = 1024;
const RECENT_EFFORTS_MAX_KEYS = 128;
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
const sanitizeShortcutOverrides = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -41,6 +44,35 @@ export const createSettingsHelpers = (dependencies) => {
return result;
};
const sanitizeRecentEfforts = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const result = {};
const seenKeys = new Set();
let count = 0;
for (const [rawKey, rawVariants] of Object.entries(value)) {
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
if (!key || seenKeys.has(key)) continue;
if (!Array.isArray(rawVariants)) continue;
const variants = [];
const seenVariants = new Set();
for (const rawVariant of rawVariants) {
const variant = typeof rawVariant === 'string' ? rawVariant.trim() : '';
if (!variant || seenVariants.has(variant)) continue;
seenVariants.add(variant);
variants.push(variant);
if (variants.length >= RECENT_EFFORTS_MAX_VARIANTS_PER_KEY) break;
}
if (variants.length === 0) continue;
seenKeys.add(key);
result[key] = variants;
count += 1;
if (count >= RECENT_EFFORTS_MAX_KEYS) break;
}
return count > 0 ? result : null;
};
const normalizePwaAppName = (value, fallback = '') => {
if (typeof value !== 'string') {
return fallback;
@@ -74,6 +106,20 @@ export const createSettingsHelpers = (dependencies) => {
return fallback;
};
const normalizeFollowUpBehavior = (value, legacyQueueModeEnabled = null) => {
// "immediate" was removed (it was wire-identical to "steer"); collapse it.
if (value === 'immediate') {
return 'steer';
}
if (value === 'steer' || value === 'queue') {
return value;
}
if (legacyQueueModeEnabled === false) {
return 'steer';
}
return 'queue';
};
const sanitizeSettingsUpdate = (payload) => {
if (!payload || typeof payload !== 'object') {
return {};
@@ -132,6 +178,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
}
if (typeof candidate.desktopKeepAwakeEnabled === 'boolean') {
result.desktopKeepAwakeEnabled = candidate.desktopKeepAwakeEnabled;
}
if (typeof candidate.desktopUiPassword === 'string') {
result.desktopUiPassword = candidate.desktopUiPassword.trim();
}
@@ -329,8 +378,10 @@ export const createSettingsHelpers = (dependencies) => {
const trimmed = candidate.defaultGitIdentityId.trim();
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
if (typeof candidate.followUpBehavior === 'string') {
result.followUpBehavior = normalizeFollowUpBehavior(candidate.followUpBehavior);
} else if (typeof candidate.queueModeEnabled === 'boolean') {
result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled);
}
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
@@ -474,6 +525,28 @@ export const createSettingsHelpers = (dependencies) => {
if (recentModels) {
result.recentModels = recentModels;
}
// Cap at 1024: users with several providers (anthropic, openai, google,
// bedrock, azure, etc.) each exposing dozens-to-hundreds of models can
// exceed 256 hidden entries quickly. 1024 covers dense multi-provider
// setups while still bounding persistence/memory.
const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, HIDDEN_MODELS_MAX);
if (hiddenModels) {
result.hiddenModels = hiddenModels;
}
if (Array.isArray(candidate.collapsedModelProviders)) {
result.collapsedModelProviders = normalizeStringArray(candidate.collapsedModelProviders);
}
if (Array.isArray(candidate.recentAgents)) {
result.recentAgents = normalizeStringArray(candidate.recentAgents);
}
const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts);
if (recentEfforts) {
result.recentEfforts = recentEfforts;
}
if (typeof candidate.diffLayoutPreference === 'string') {
const mode = candidate.diffLayoutPreference.trim();
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createSettingsHelpers } from './settings-helpers.js';
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
const createTestHelpers = () => createSettingsHelpers({
normalizePathForPersistence: (value) => value,
@@ -20,6 +21,42 @@ const createTestHelpers = () => createSettingsHelpers({
sanitizeProjects: () => undefined,
});
const createTestHelpersWithRealSanitizers = () => {
const runtime = createSettingsNormalizationRuntime({
os: { homedir: () => '/home/testuser' },
path: {
resolve: (...args) => args[args.length - 1],
sep: '/',
dirname: (p) => p.split('/').slice(0, -1).join('/') || '/',
},
processLike: { platform: 'linux', env: {} },
realpathSync: (p) => p,
tunnelBootstrapTtlDefaultMs: 600000,
tunnelBootstrapTtlMinMs: 60000,
tunnelBootstrapTtlMaxMs: 3600000,
tunnelSessionTtlDefaultMs: 86400000,
tunnelSessionTtlMinMs: 3600000,
tunnelSessionTtlMaxMs: 604800000,
});
return createSettingsHelpers({
normalizePathForPersistence: (value) => value,
normalizeDirectoryPath: (value) => value,
normalizeTunnelBootstrapTtlMs: (value) => value,
normalizeTunnelSessionTtlMs: (value) => value,
normalizeTunnelProvider: (value) => value,
normalizeTunnelMode: (value) => value,
normalizeOptionalPath: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: () => undefined,
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
sanitizeTypographySizesPartial: () => undefined,
normalizeStringArray: runtime.normalizeStringArray,
sanitizeModelRefs: runtime.sanitizeModelRefs,
sanitizeSkillCatalogs: () => undefined,
sanitizeProjects: () => undefined,
});
};
describe('settings helpers', () => {
it('accepts messageStreamTransport as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -52,6 +89,17 @@ describe('settings helpers', () => {
});
});
it('accepts desktopKeepAwakeEnabled as a persisted shared setting', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: true })).toEqual({
desktopKeepAwakeEnabled: true,
});
expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: false })).toEqual({
desktopKeepAwakeEnabled: false,
});
});
it('accepts desktopUiPassword as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -188,4 +236,121 @@ describe('settings helpers', () => {
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
}
});
describe('previously-dropped model selector persistence fields', () => {
it('round-trips hiddenModels through the sanitizer', () => {
const helpers = createTestHelpersWithRealSanitizers();
const input = [
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
{ providerID: 'openai', modelID: 'gpt-5' },
];
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: input })).toEqual({
hiddenModels: input,
});
});
it('handles empty hiddenModels the same way as empty favoriteModels', () => {
const helpers = createTestHelpersWithRealSanitizers();
const hiddenResult = helpers.sanitizeSettingsUpdate({ hiddenModels: [] });
const favoriteResult = helpers.sanitizeSettingsUpdate({ favoriteModels: [] });
expect(hiddenResult.hiddenModels).toEqual([]);
expect(favoriteResult.favoriteModels).toEqual([]);
expect(hiddenResult.hiddenModels).toEqual(favoriteResult.favoriteModels);
});
it('round-trips collapsedModelProviders and recentAgents as string arrays', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: ['anthropic', 'openai'] })).toEqual({
collapsedModelProviders: ['anthropic', 'openai'],
});
expect(helpers.sanitizeSettingsUpdate({ recentAgents: ['build', 'plan'] })).toEqual({
recentAgents: ['build', 'plan'],
});
});
it('round-trips recentEfforts as a Record<string, string[]>', () => {
const helpers = createTestHelpersWithRealSanitizers();
const input = {
'anthropic/claude-opus-4': ['high', 'default'],
'openai/gpt-5': ['low'],
};
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: input })).toEqual({
recentEfforts: input,
});
});
it('rejects garbage hiddenModels input the same way sanitizeModelRefs rejects bad refs', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 'not-an-array' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: null })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 123 })).toEqual({});
expect(
helpers.sanitizeSettingsUpdate({
hiddenModels: [
{ providerID: 'anthropic' },
{ modelID: 'gpt-5' },
'not-an-object',
null,
{ providerID: ' ', modelID: 'x' },
{ providerID: 'openai', modelID: '' },
],
})
).toEqual({ hiddenModels: [] });
});
it('rejects garbage collapsedModelProviders and recentAgents input', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: 'anthropic' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: null })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentAgents: 42 })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentAgents: { build: 1 } })).toEqual({});
});
it('rejects garbage recentEfforts input', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: 'not-an-object' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: [] })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: null })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': 'high' } })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { '': ['high'] } })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [] } })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({});
});
it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => {
const helpers = createTestHelpersWithRealSanitizers();
const payload = {
themeId: 'default',
hiddenModels: [
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
{ providerID: 'openai', modelID: 'gpt-5' },
],
collapsedModelProviders: ['anthropic', 'openai'],
recentAgents: ['build', 'plan'],
recentEfforts: {
'anthropic/claude-opus-4': ['high', 'default'],
'openai/gpt-5': ['low'],
},
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }],
recentModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
};
const sanitized = helpers.sanitizeSettingsUpdate(payload);
expect(sanitized.hiddenModels).toEqual(payload.hiddenModels);
expect(sanitized.collapsedModelProviders).toEqual(payload.collapsedModelProviders);
expect(sanitized.recentAgents).toEqual(payload.recentAgents);
expect(sanitized.recentEfforts).toEqual(payload.recentEfforts);
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
expect(sanitized.recentModels).toEqual(payload.recentModels);
});
});
});
@@ -507,20 +507,14 @@ export {
COMMAND_DIR,
SKILL_DIR,
CONFIG_FILE,
CUSTOM_CONFIG_FILE,
PROMPT_FILE_PATTERN,
AGENT_SCOPE,
COMMAND_SCOPE,
SKILL_SCOPE,
ensureDirs,
parseMdFile,
writeMdFile,
getProjectConfigCandidates,
getProjectConfigPath,
getConfigPaths,
readConfigFile,
isPlainObject,
mergeConfigs,
readConfigLayers,
readConfig,
getConfigForPath,
@@ -594,8 +594,6 @@ function deleteSkill(skillName, workingDirectory) {
export {
getSkillSources,
getSkillScope,
getSkillWritePath,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
@@ -240,5 +240,3 @@ export function expandSnippets(text, workingDirectory) {
const expanded = expandText(text || '', registry, new Map(), collector).trim();
return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n');
}
export { assertValidSnippetName };
+2 -2
View File
@@ -634,7 +634,7 @@ export function getCurrentVersion() {
/**
* Fetch latest version from npm registry
*/
export async function getLatestVersion() {
async function getLatestVersion() {
try {
const response = await fetch(NPM_REGISTRY_URL, {
headers: { Accept: 'application/json' },
@@ -690,7 +690,7 @@ function compareVersions(left, right) {
/**
* Fetch changelog notes between versions
*/
export async function fetchChangelogNotes(fromVersion, toVersion) {
async function fetchChangelogNotes(fromVersion, toVersion) {
try {
const response = await fetch(CHANGELOG_URL, {
signal: AbortSignal.timeout(10000),
@@ -6,7 +6,12 @@ vi.mock('node:child_process', () => ({
spawnSync: vi.fn(() => ({ status: 0, stdout: '/usr/local/bin', stderr: '' })),
}));
const { checkForUpdates } = await import('./package-manager.js');
const {
checkForUpdates,
detectPackageManager,
executeUpdate,
getCurrentVersion,
} = await import('./package-manager.js');
/** Helper: create a fetch mock that routes by URL pattern */
function createFetchMock() {
@@ -244,3 +249,17 @@ describe('checkForUpdates', () => {
expect(result.available).toBe(false);
});
});
describe('getCurrentVersion', () => {
it('is exported for the CLI update command', () => {
expect(typeof getCurrentVersion).toBe('function');
expect(getCurrentVersion()).toMatch(/^\d+\.\d+\.\d+|unknown$/);
});
});
describe('CLI update exports', () => {
it('exports package-manager helpers used by the update command', () => {
expect(typeof detectPackageManager).toBe('function');
expect(typeof executeUpdate).toBe('function');
});
});
@@ -557,11 +557,3 @@ export const createProjectConfigRuntime = (deps) => {
resolveProjectConfigPath,
};
};
export {
MAX_TASK_NAME_LENGTH,
MAX_TASK_PROMPT_LENGTH,
MAX_CRON_LENGTH,
MAX_LAST_ERROR_LENGTH,
normalizeTaskForStorage,
};
+15 -3
View File
@@ -7,7 +7,6 @@ This module fetches quota and usage signals for supported providers in the web s
- `packages/web/server/lib/quota/index.js`: public entrypoint imported by `packages/web/server/index.js`.
- `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints.
- `packages/web/server/lib/quota/providers/index.js`: provider registry, configured-provider list, and provider dispatcher.
- `packages/web/server/lib/quota/providers/interface.js`: JSDoc provider contract used as implementation reference.
- `packages/web/server/lib/quota/providers/google/`: Google-specific auth, API, and transform modules.
- `packages/web/server/lib/quota/utils/`: shared auth, transform, and formatting helpers.
@@ -28,8 +27,8 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
| `openrouter` | OpenRouter | `providers/openrouter.js` | `openrouter` |
| `zai-coding-plan` | z.ai | `providers/zai.js` | `zai-coding-plan`, `zai`, `z.ai` |
| `zhipuai-coding-plan` | Zhipu AI Coding Plan | `providers/zhipuai-coding-plan.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` |
| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` | `minimax-coding-plan` |
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` | `minimax-cn-coding-plan` |
| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` / `providers/minimax-shared.js` | `minimax-coding-plan` |
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` |
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Cookie file at `~/.config/ollama-quota/cookie` (raw session cookie string) |
| `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
@@ -42,6 +41,9 @@ All providers should return results via shared helpers to preserve API shape:
- Optional field: `error`
- Unsupported provider requests should return `ok: false`, `configured: false`, `error: Unsupported provider`
Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`.
`fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data.
## Add a new provider (quick steps)
1. Choose module shape based on complexity:
- Simple providers: create `packages/web/server/lib/quota/providers/<provider>.js`.
@@ -53,6 +55,16 @@ All providers should return results via shared helpers to preserve API shape:
6. Update this file with the new provider ID, module path, and alias/auth details.
7. Validate with `bun run type-check`, `bun run lint`, and `bun run build`.
## MiniMax M3 / Token Plan migration
In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 model release. The API underwent breaking changes:
- **Endpoint fallback**: The provider tries `/v1/token_plan/remains` (M3) first, falling back to legacy `/v1/api/openplatform/coding_plan/remains`.
- **Field semantics**: On the `token_plan/remains` endpoint, `current_interval_usage_count` returns **remaining** quota (not consumed). The provider computes `used = total - remaining` for this endpoint. The legacy `coding_plan/remains` endpoint retains the old semantics (`usage_count = consumed`).
- **Percentage-based plans**: Legacy Coding Plan accounts return `current_interval_total_count: 0` but include `current_interval_remaining_percent`. The provider prefers this field when count fields are absent.
- **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent.
- **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows.
## Notes for contributors
- Keep provider IDs stable; clients use them directly.
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
@@ -10,7 +10,7 @@ import {
export const providerId = 'claude';
export const providerName = 'Claude';
export const aliases = ['anthropic', 'claude'];
const aliases = ['anthropic', 'claude'];
export const isConfigured = () => {
const auth = readAuthFile();
@@ -11,7 +11,7 @@ import {
export const providerId = 'codex';
export const providerName = 'Codex';
export const aliases = ['openai', 'codex', 'chatgpt'];
const aliases = ['openai', 'codex', 'chatgpt'];
export const isConfigured = () => {
const auth = readAuthFile();
@@ -40,7 +40,7 @@ const buildCopilotWindows = (payload) => {
export const providerId = 'github-copilot';
export const providerName = 'GitHub Copilot';
export const aliases = ['github-copilot', 'copilot'];
const aliases = ['github-copilot', 'copilot'];
export const isConfigured = () => {
const auth = readAuthFile();
@@ -21,7 +21,7 @@ const STATE_DB = join(homedir(), 'Library', 'Application Support', 'Cursor', 'Us
export const providerId = 'cursor';
export const providerName = 'Cursor';
export const aliases = ['cursor'];
const aliases = ['cursor'];
const readJwtPayload = (token) => {
try {
@@ -39,7 +39,7 @@ export const resolveGoogleOAuthClient = (sourceId) => {
};
};
export const resolveGeminiCliAuth = (auth) => {
const resolveGeminiCliAuth = (auth) => {
const entry = normalizeAuthEntry(getAuthEntry(auth, ['google', 'google.oauth']));
const entryObject = asObject(entry);
if (!entryObject) {
@@ -64,7 +64,7 @@ export const resolveGeminiCliAuth = (auth) => {
};
};
export const resolveAntigravityAuth = () => {
const resolveAntigravityAuth = () => {
for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) {
const data = readJsonFile(filePath);
const accounts = data?.accounts;
@@ -1,30 +1,3 @@
/**
* Google Provider
*
* Google quota provider implementation.
* @module quota/providers/google
*/
export {
resolveGoogleOAuthClient,
resolveGeminiCliAuth,
resolveAntigravityAuth,
resolveGoogleAuthSources,
DEFAULT_PROJECT_ID
} from './auth.js';
export {
resolveGoogleWindow,
transformQuotaBucket,
transformModelData
} from './transforms.js';
export {
refreshGoogleAccessToken,
fetchGoogleQuotaBuckets,
fetchGoogleModels
} from './api.js';
import { buildResult } from '../../utils/index.js';
import {
resolveGoogleAuthSources,
@@ -38,12 +11,20 @@ import {
fetchGoogleModels
} from './api.js';
export { resolveGoogleAuthSources } from './auth.js';
export const providerId = 'google';
export const providerName = 'Google';
export const aliases = ['google', 'google.oauth'];
export const isConfigured = () => resolveGoogleAuthSources().length > 0;
export const fetchGoogleQuota = async () => {
const authSources = resolveGoogleAuthSources();
if (!authSources.length) {
return buildResult({
providerId: 'google',
providerName: 'Google',
providerId,
providerName,
ok: false,
configured: false,
error: 'Not configured'
@@ -103,8 +84,8 @@ export const fetchGoogleQuota = async () => {
if (!Object.keys(models).length) {
return buildResult({
providerId: 'google',
providerName: 'Google',
providerId,
providerName,
ok: false,
configured: true,
error: sourceErrors[0] ?? 'Failed to fetch models'
@@ -112,8 +93,8 @@ export const fetchGoogleQuota = async () => {
}
return buildResult({
providerId: 'google',
providerName: 'Google',
providerId,
providerName,
ok: true,
configured: true,
usage: {
@@ -29,7 +29,7 @@ export const parseGoogleRefreshToken = (rawRefreshToken) => {
};
};
export const resolveGoogleWindow = (sourceId, resetAt) => {
const resolveGoogleWindow = (sourceId, resetAt) => {
if (sourceId === 'gemini') {
return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS };
}
@@ -43,9 +43,9 @@ const registry = {
fetchQuota: cursor.fetchQuota
},
google: {
providerId: 'google',
providerName: 'Google',
isConfigured: () => google.resolveGoogleAuthSources().length > 0,
providerId: google.providerId,
providerName: google.providerName,
isConfigured: google.isConfigured,
fetchQuota: google.fetchGoogleQuota
},
'zai-coding-plan': {
@@ -168,7 +168,7 @@ export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon;
export const fetchKimiQuota = kimi.fetchQuota;
export const fetchOpenRouterQuota = openrouter.fetchQuota;
export const fetchZaiQuota = zai.fetchQuota;
export const fetchZhipuaiCodingPlanQuota = zhipuaiCodingPlan.fetchQuota;
const fetchZhipuaiCodingPlanQuota = zhipuaiCodingPlan.fetchQuota;
export const fetchNanoGptQuota = nanogpt.fetchQuota;
export const fetchMinimaxCodingPlanQuota = minimaxCodingPlan.fetchQuota;
export const fetchMinimaxCnCodingPlanQuota = minimaxCnCodingPlan.fetchQuota;
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import * as google from './google/index.js';
import { listConfiguredQuotaProviders } from './index.js';
describe('quota provider registry', () => {
it('exposes google provider configuration helpers through the provider module', () => {
expect(google.providerId).toBe('google');
expect(google.providerName).toBe('Google');
expect(typeof google.isConfigured).toBe('function');
expect(typeof google.resolveGoogleAuthSources).toBe('function');
});
it('can list configured providers without missing provider exports', () => {
expect(() => listConfiguredQuotaProviders()).not.toThrow();
});
});
@@ -1,55 +0,0 @@
/**
* Quota Provider Interface
*
* Defines the contract for implementing quota providers.
* @module quota/providers
*/
/**
* @typedef {Object} UsageWindow
* @property {number|null} usedPercent - Percentage of usage (0-100)
* @property {number|null} remainingPercent - Percentage remaining (0-100)
* @property {number|null} windowSeconds - Window duration in seconds
* @property {number|null} resetAfterSeconds - Seconds until reset
* @property {number|null} resetAt - Unix timestamp when quota resets
* @property {string|null} resetAtFormatted - Human-readable reset time
* @property {string|null} resetAfterFormatted - Human-readable time until reset
* @property {string|null} valueLabel - Optional label for display (e.g., "$10.00 remaining")
*/
/**
* @typedef {Object} ProviderUsage
* @property {Object.<string, UsageWindow>} windows - Usage windows by key (e.g., '5h', '7d', 'daily')
* @property {Object.<string, Object>} [models] - Model-specific usage (provider-specific)
*/
/**
* @typedef {Object} QuotaProviderResult
* @property {string} providerId - Unique identifier for the provider
* @property {string} providerName - Display name for the provider
* @property {boolean} ok - Whether the fetch was successful
* @property {boolean} configured - Whether the provider is configured
* @property {ProviderUsage|null} usage - Usage data if successful
* @property {string|null} [error] - Error message if not successful
* @property {number} fetchedAt - Unix timestamp when the result was fetched
*/
/**
* @typedef {Function} ProviderQuotaFetcher
* @returns {Promise<QuotaProviderResult>}
*/
/**
* @typedef {Function} ProviderConfigurationChecker
* @param {Object.<string, unknown>} [auth]
* @returns {boolean}
*/
/**
* @typedef {Object} QuotaProvider
* @property {string} providerId
* @property {string} providerName
* @property {string[]} aliases
* @property {ProviderConfigurationChecker} isConfigured
* @property {ProviderQuotaFetcher} fetchQuota
*/
@@ -12,7 +12,7 @@ import {
export const providerId = 'kimi-for-coding';
export const providerName = 'Kimi for Coding';
export const aliases = ['kimi-for-coding', 'kimi'];
const aliases = ['kimi-for-coding', 'kimi'];
export const isConfigured = () => {
const auth = readAuthFile();
@@ -1,140 +1,15 @@
// MiniMax Coding Plan Provider (minimaxi.com)
import { readAuthFile } from '../../opencode/auth.js';
import {
getAuthEntry,
normalizeAuthEntry,
buildResult,
toUsageWindow,
toNumber,
toTimestamp,
} from '../utils/index.js';
import { createMiniMaxCodingPlanProvider } from './minimax-shared.js';
export const providerId = 'minimax-cn-coding-plan';
export const providerName = 'MiniMax Coding Plan (minimaxi.com)';
export const aliases = ['minimax-cn-coding-plan'];
const provider = createMiniMaxCodingPlanProvider({
providerId: 'minimax-cn-coding-plan',
providerName: 'MiniMax Coding Plan (minimaxi.com)',
aliases: ['minimax-cn-coding-plan'],
tokenPlanUrl: 'https://api.minimaxi.com/v1/token_plan/remains',
codingPlanUrl: 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
});
export const isConfigured = () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
return Boolean(entry?.key || entry?.token);
};
export const fetchQuota = async () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
const apiKey = entry?.key ?? entry?.token;
if (!apiKey) {
return buildResult({
providerId,
providerName,
ok: false,
configured: false,
error: 'Not configured',
});
}
try {
const response = await fetch(
'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
{
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: `API error: ${response.status}`,
});
}
const payload = await response.json();
const baseResp = payload?.base_resp;
if (baseResp && baseResp.status_code !== 0) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: baseResp.status_msg || `API error: ${baseResp.status_code}`,
});
}
const firstModel = payload?.model_remains?.[0];
if (!firstModel) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: 'No model quota data available',
});
}
const intervalTotal = toNumber(firstModel.current_interval_total_count);
const intervalUsage = toNumber(firstModel.current_interval_usage_count);
const intervalStartAt = toTimestamp(firstModel.start_time);
const intervalResetAt = toTimestamp(firstModel.end_time);
const weeklyTotal = toNumber(firstModel.current_weekly_total_count);
const weeklyUsage = toNumber(firstModel.current_weekly_usage_count);
const weeklyStartAt = toTimestamp(firstModel.weekly_start_time);
const weeklyResetAt = toTimestamp(firstModel.weekly_end_time);
const intervalUsed = intervalTotal - intervalUsage;
const weeklyUsed = weeklyTotal - weeklyUsage;
const intervalUsedPercent =
intervalTotal > 0 && intervalUsed != null
? Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100))
: null;
const intervalWindowSeconds =
intervalStartAt && intervalResetAt && intervalResetAt > intervalStartAt
? Math.floor((intervalResetAt - intervalStartAt) / 1000)
: null;
const weeklyUsedPercent =
weeklyTotal > 0 && weeklyUsed != null
? Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100))
: null;
const weeklyWindowSeconds =
weeklyStartAt && weeklyResetAt && weeklyResetAt > weeklyStartAt
? Math.floor((weeklyResetAt - weeklyStartAt) / 1000)
: null;
const windows = {
'5h': toUsageWindow({
usedPercent: intervalUsedPercent,
windowSeconds: intervalWindowSeconds,
resetAt: intervalResetAt,
}),
weekly: toUsageWindow({
usedPercent: weeklyUsedPercent,
windowSeconds: weeklyWindowSeconds,
resetAt: weeklyResetAt,
}),
};
return buildResult({
providerId,
providerName,
ok: true,
configured: true,
usage: { windows },
});
} catch (error) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: error instanceof Error ? error.message : 'Request failed',
});
}
};
export const providerId = provider.providerId;
export const providerName = provider.providerName;
const aliases = provider.aliases;
export const isConfigured = provider.isConfigured;
export const fetchQuota = provider.fetchQuota;
@@ -1,139 +1,15 @@
import { readAuthFile } from '../../opencode/auth.js';
import {
getAuthEntry,
normalizeAuthEntry,
buildResult,
toUsageWindow,
toNumber,
toTimestamp,
} from '../utils/index.js';
import { createMiniMaxCodingPlanProvider } from './minimax-shared.js';
export const providerId = 'minimax-coding-plan';
export const providerName = 'MiniMax Coding Plan (minimax.io)';
export const aliases = ['minimax-coding-plan'];
const provider = createMiniMaxCodingPlanProvider({
providerId: 'minimax-coding-plan',
providerName: 'MiniMax Coding Plan (minimax.io)',
aliases: ['minimax-coding-plan'],
tokenPlanUrl: 'https://api.minimax.io/v1/token_plan/remains',
codingPlanUrl: 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
});
export const isConfigured = () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
return Boolean(entry?.key || entry?.token);
};
export const fetchQuota = async () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
const apiKey = entry?.key ?? entry?.token;
if (!apiKey) {
return buildResult({
providerId,
providerName,
ok: false,
configured: false,
error: 'Not configured',
});
}
try {
const response = await fetch(
'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
{
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: `API error: ${response.status}`,
});
}
const payload = await response.json();
const baseResp = payload?.base_resp;
if (baseResp && baseResp.status_code !== 0) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: baseResp.status_msg || `API error: ${baseResp.status_code}`,
});
}
const firstModel = payload?.model_remains?.[0];
if (!firstModel) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: 'No model quota data available',
});
}
const intervalTotal = toNumber(firstModel.current_interval_total_count);
const intervalUsage = toNumber(firstModel.current_interval_usage_count);
const intervalStartAt = toTimestamp(firstModel.start_time);
const intervalResetAt = toTimestamp(firstModel.end_time);
const weeklyTotal = toNumber(firstModel.current_weekly_total_count);
const weeklyUsage = toNumber(firstModel.current_weekly_usage_count);
const weeklyStartAt = toTimestamp(firstModel.weekly_start_time);
const weeklyResetAt = toTimestamp(firstModel.weekly_end_time);
const intervalUsed = intervalUsage;
const weeklyUsed = weeklyUsage;
const intervalUsedPercent =
intervalTotal > 0 && intervalUsed !== null
? Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100))
: null;
const intervalWindowSeconds =
intervalStartAt && intervalResetAt && intervalResetAt > intervalStartAt
? Math.floor((intervalResetAt - intervalStartAt) / 1000)
: null;
const weeklyUsedPercent =
weeklyTotal > 0 && weeklyUsed !== null
? Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100))
: null;
const weeklyWindowSeconds =
weeklyStartAt && weeklyResetAt && weeklyResetAt > weeklyStartAt
? Math.floor((weeklyResetAt - weeklyStartAt) / 1000)
: null;
const windows = {
'5h': toUsageWindow({
usedPercent: intervalUsedPercent,
windowSeconds: intervalWindowSeconds,
resetAt: intervalResetAt,
}),
weekly: toUsageWindow({
usedPercent: weeklyUsedPercent,
windowSeconds: weeklyWindowSeconds,
resetAt: weeklyResetAt,
}),
};
return buildResult({
providerId,
providerName,
ok: true,
configured: true,
usage: { windows },
});
} catch (error) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: error instanceof Error ? error.message : 'Request failed',
});
}
};
export const providerId = provider.providerId;
export const providerName = provider.providerName;
const aliases = provider.aliases;
export const isConfigured = provider.isConfigured;
export const fetchQuota = provider.fetchQuota;
@@ -0,0 +1,250 @@
import { readAuthFile } from '../../opencode/auth.js';
import {
getAuthEntry,
normalizeAuthEntry,
buildResult,
toUsageWindow,
toNumber,
toTimestamp,
} from '../utils/index.js';
// Status 3 indicates the window is not applicable for the current plan tier.
const WINDOW_STATUS_INACTIVE = 3;
const TEXT_MODELS = ['general', 'chat', 'text'];
const pickChatModel = (modelRemains) => {
if (!Array.isArray(modelRemains) || modelRemains.length === 0) return null;
const m3Candidate = modelRemains.find(
(m) => m?.model_name && /^minimax-m/i.test(m.model_name) && toNumber(m.current_interval_total_count) > 0
);
if (m3Candidate) return m3Candidate;
const textCandidate = modelRemains.find(
(m) => m?.model_name && TEXT_MODELS.includes(m.model_name.toLowerCase())
);
if (textCandidate) return textCandidate;
const percentCandidate = modelRemains.find(
(m) => typeof m?.current_interval_remaining_percent === 'number'
);
if (percentCandidate) return percentCandidate;
return modelRemains[0];
};
const isUsablePayload = (payload) => {
const baseResp = payload?.base_resp;
if (baseResp && baseResp.status_code !== 0) return false;
const rems = payload?.model_remains;
return Array.isArray(rems) && rems.length > 0;
};
const fetchEndpoint = async (url, apiKey) => {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) return null;
const payload = await response.json();
if (!isUsablePayload(payload)) return null;
return payload;
} catch {
return null;
}
};
const coercePercent = (value) => {
const n = toNumber(value);
return n !== null ? Math.max(0, Math.min(100, n)) : null;
};
/**
* Check if a window (interval or weekly) is active for the current plan.
* Status 3 means the window is not applicable (e.g. legacy plans without weekly limits).
* When the status field is absent, default to active.
*/
const isWindowActive = (status) => {
const n = toNumber(status);
return n === null || n !== WINDOW_STATUS_INACTIVE;
};
/**
* Calculate window duration in seconds from API timestamps or remains_time.
* MiniMax API returns remains_time in milliseconds (confirmed via live API testing:
* 9664502 ms = 2.68h in a 5h window, consistent with remaining_percent).
*/
const calculateWindowSeconds = (startAt, resetAt, remainsTimeMs) => {
if (startAt && resetAt && resetAt > startAt) {
return Math.floor((resetAt - startAt) / 1000);
}
if (remainsTimeMs && remainsTimeMs > 0) {
return Math.floor(remainsTimeMs / 1000);
}
return null;
};
const calculateUsage = (model, isTokenPlan) => {
const intervalTotal = toNumber(model.current_interval_total_count);
const intervalUsageRaw = toNumber(model.current_interval_usage_count);
const intervalStartAt = toTimestamp(model.start_time);
const intervalResetAt = toTimestamp(model.end_time);
const intervalRemainsTime = toNumber(model.remains_time);
const intervalRemainingPercent = coercePercent(model.current_interval_remaining_percent);
const weeklyTotal = toNumber(model.current_weekly_total_count);
const weeklyUsageRaw = toNumber(model.current_weekly_usage_count);
const weeklyStartAt = toTimestamp(model.weekly_start_time);
const weeklyResetAt = toTimestamp(model.weekly_end_time);
const weeklyRemainsTime = toNumber(model.weekly_remains_time);
const weeklyRemainingPercent = coercePercent(model.current_weekly_remaining_percent);
let intervalUsedPercent = null;
if (intervalRemainingPercent !== null) {
intervalUsedPercent = 100 - intervalRemainingPercent;
} else if (intervalTotal > 0 && intervalUsageRaw !== null) {
const intervalUsed = isTokenPlan
? Math.max(0, intervalTotal - intervalUsageRaw)
: intervalUsageRaw;
intervalUsedPercent = Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100));
}
let weeklyUsedPercent = null;
if (weeklyRemainingPercent !== null) {
weeklyUsedPercent = 100 - weeklyRemainingPercent;
} else if (weeklyTotal > 0 && weeklyUsageRaw !== null) {
const weeklyUsed = isTokenPlan
? Math.max(0, weeklyTotal - weeklyUsageRaw)
: weeklyUsageRaw;
weeklyUsedPercent = Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100));
}
const intervalWindowSeconds = calculateWindowSeconds(intervalStartAt, intervalResetAt, intervalRemainsTime);
const weeklyWindowSeconds = calculateWindowSeconds(weeklyStartAt, weeklyResetAt, weeklyRemainsTime);
return {
intervalUsedPercent,
intervalWindowSeconds,
intervalResetAt,
weeklyUsedPercent,
weeklyWindowSeconds,
weeklyResetAt,
};
};
export const createMiniMaxCodingPlanProvider = ({ providerId, providerName, aliases, tokenPlanUrl, codingPlanUrl }) => {
const isConfigured = () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
return Boolean(entry?.key || entry?.token);
};
const fetchQuota = async () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
const apiKey = entry?.key ?? entry?.token;
if (!apiKey) {
return buildResult({
providerId,
providerName,
ok: false,
configured: false,
error: 'Not configured',
});
}
try {
let payload = await fetchEndpoint(tokenPlanUrl, apiKey);
let isTokenPlan = true;
if (!payload) {
payload = await fetchEndpoint(codingPlanUrl, apiKey);
isTokenPlan = false;
}
if (!payload) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: 'API returned no usable quota data',
});
}
const model = pickChatModel(payload.model_remains);
if (!model) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: 'No model quota data available',
});
}
const {
intervalUsedPercent,
intervalWindowSeconds,
intervalResetAt,
weeklyUsedPercent,
weeklyWindowSeconds,
weeklyResetAt,
} = calculateUsage(model, isTokenPlan);
const windows = {
'5h': toUsageWindow({
usedPercent: intervalUsedPercent,
windowSeconds: intervalWindowSeconds,
resetAt: intervalResetAt,
}),
};
// Only include the weekly window when the plan tier supports it.
// Status 3 = not applicable (e.g. legacy Coding Plan without weekly limits).
const weeklyActive = isWindowActive(model.current_weekly_status);
const hasWeeklyData =
weeklyActive &&
(coercePercent(model.current_weekly_remaining_percent) !== null ||
toNumber(model.current_weekly_total_count) > 0);
if (hasWeeklyData) {
windows.weekly = toUsageWindow({
usedPercent: weeklyUsedPercent,
windowSeconds: weeklyWindowSeconds,
resetAt: weeklyResetAt,
});
}
return buildResult({
providerId,
providerName,
ok: true,
configured: true,
usage: { windows },
});
} catch (error) {
return buildResult({
providerId,
providerName,
ok: false,
configured: true,
error: error instanceof Error ? error.message : 'Request failed',
});
}
};
return {
providerId,
providerName,
aliases,
isConfigured,
fetchQuota,
};
};
@@ -12,7 +12,7 @@ const NANO_GPT_DAILY_WINDOW_SECONDS = 86400;
export const providerId = 'nano-gpt';
export const providerName = 'NanoGPT';
export const aliases = ['nano-gpt', 'nanogpt', 'nano_gpt'];
const aliases = ['nano-gpt', 'nanogpt', 'nano_gpt'];
export const isConfigured = () => {
const auth = readAuthFile();
@@ -7,7 +7,7 @@ const COOKIE_PATH = join(homedir(), '.config', 'ollama-quota', 'cookie');
export const providerId = 'ollama-cloud';
export const providerName = 'Ollama Cloud';
export const aliases = ['ollama-cloud', 'ollamacloud'];
const aliases = ['ollama-cloud', 'ollamacloud'];
const readCookieFile = () => {
try {

Some files were not shown because too many files have changed in this diff Show More