Decouple bundled UI from runtime API and add remote instance tooling (#1228)

Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
+3
View File
@@ -21,6 +21,7 @@ export const createBootstrapRuntime = (dependencies) => {
verboseRequestLogs,
uiPassword,
tunnelAuthController,
remoteClientAuthRuntime,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
sayTTSCapability,
@@ -65,6 +66,7 @@ export const createBootstrapRuntime = (dependencies) => {
const uiAuthController = createUiAuth({
password: uiPassword,
readSettingsFromDiskMigrated,
clientAuthController: remoteClientAuthRuntime,
});
if (uiAuthController.enabled) {
console.log('UI password protection enabled for browser sessions');
@@ -74,6 +76,7 @@ export const createBootstrapRuntime = (dependencies) => {
express,
tunnelAuthController,
uiAuthController,
remoteClientAuthRuntime,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
});
@@ -36,6 +36,7 @@ export const runCliEntryIfMain = (dependencies) => {
attachSignals: true,
exitOnShutdown: true,
uiPassword: cliOptions.uiPassword,
apiOnly: cliOptions.apiOnly,
}).catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
@@ -19,6 +19,7 @@ export const parseServeCliOptions = ({
: undefined;
const envTunnelToken = env.OPENCHAMBER_TUNNEL_TOKEN || undefined;
const envTunnelHostname = env.OPENCHAMBER_TUNNEL_HOSTNAME || undefined;
const envApiOnly = env.OPENCHAMBER_API_ONLY === '1' || env.OPENCHAMBER_API_ONLY === 'true';
const options = {
port: defaultPort,
@@ -30,6 +31,7 @@ export const parseServeCliOptions = ({
tunnelConfigPath: envTunnelConfig,
tunnelToken: envTunnelToken,
tunnelHostname: envTunnelHostname,
apiOnly: envApiOnly,
};
const consumeValue = (currentIndex, inlineValue) => {
@@ -75,6 +77,11 @@ export const parseServeCliOptions = ({
continue;
}
if (optionName === 'api-only') {
options.apiOnly = true;
continue;
}
if (optionName === 'try-cf-tunnel') {
options.tryCfTunnel = true;
continue;
+179 -5
View File
@@ -22,6 +22,42 @@ const parseLoopbackUrl = (rawUrl) => {
return url;
};
const getRequestPathname = (req) => {
const rawUrl = req?.originalUrl || req?.url || '';
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return '';
try {
return new URL(rawUrl, 'http://localhost').pathname;
} catch {
return '';
}
};
const getQueryParam = (req, name) => {
const rawUrl = req?.originalUrl || req?.url || '';
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return '';
try {
return new URL(rawUrl, 'http://localhost').searchParams.get(name)?.trim() || '';
} catch {
return '';
}
};
const getCookieValue = (req, name) => {
const cookieHeader = req?.headers?.cookie;
if (typeof cookieHeader !== 'string' || cookieHeader.length === 0) return '';
for (const segment of cookieHeader.split(';')) {
const [rawName, ...rawValueParts] = segment.split('=');
if (rawName?.trim() !== name) continue;
return rawValueParts.join('=').trim();
}
return '';
};
const hasPreviewProxyCredential = (req) => {
if (!getRequestPathname(req).startsWith('/api/preview/proxy/')) return false;
return Boolean(getQueryParam(req, 'oc_preview_token') || getCookieValue(req, 'oc_preview_token'));
};
export const registerServerStatusRoutes = (app, dependencies) => {
const {
express,
@@ -56,6 +92,19 @@ export const registerServerStatusRoutes = (app, dependencies) => {
});
};
const compatibility = {
apiVersion: 1,
minClientApiVersion: 1,
capabilities: [
'api.health.v1',
'api.runtime-url.v1',
'api.raw-file.v1',
'realtime.sse.v1',
'realtime.websocket.global-events.v1',
'terminal.websocket.v1',
],
};
const isDevShutdownAllowed = () => {
// Dev-only escape hatch: allow terminating the whole dev process group.
// This should never be enabled in production runtimes.
@@ -166,10 +215,23 @@ export const registerServerStatusRoutes = (app, dependencies) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
openchamberVersion,
runtime: runtimeName,
compatibility,
...getHealthSnapshot(),
});
});
app.get('/api/version', (_req, res) => {
res.json({
status: 'ok',
openchamberVersion,
runtime: runtimeName,
startedAt: serverStartedAt,
compatibility,
});
});
app.post('/api/system/shutdown', (_req, res) => {
res.json({ ok: true });
gracefulShutdown({ exitProcess: true }).catch((error) => {
@@ -271,11 +333,69 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
express,
tunnelAuthController,
uiAuthController,
remoteClientAuthRuntime,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
} = dependencies;
const runWithUiAuth = async (req, res, next, handler, options = {}) => {
try {
const requireAuth = options.sessionOnly === true && typeof uiAuthController.requireSessionAuth === 'function'
? uiAuthController.requireSessionAuth
: uiAuthController.requireAuth;
await requireAuth(req, res, async () => {
await handler();
});
} catch (error) {
next(error);
}
};
const runWithClientManagementAuth = 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' || context?.type === 'client') {
await handler(context);
return;
}
}
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;
};
const clientRecordFromAuthContext = async (context) => {
if (context?.client && typeof context.client === 'object') {
return context.client;
}
const clientId = clientIdFromAuthContext(context);
if (!clientId) return null;
const clients = await remoteClientAuthRuntime.listClients();
return clients.find((client) => client.id === clientId) || null;
};
const requireApiAuth = async (req, res, next) => {
// Preview proxy requests carry a target-scoped capability token that the
// preview proxy validates against the registered target id/TTL. Let those
// requests reach that stricter check instead of failing the global UI auth
// gate when the short-lived browser URL auth token expires.
if (hasPreviewProxyCredential(req)) {
return next();
}
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return tunnelAuthController.requireTunnelSession(req, res, next);
@@ -309,6 +429,14 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return uiAuthController.handleSessionCreate(req, res);
});
app.post('/auth/url-token', async (req, res, next) => {
try {
await uiAuthController.handleUrlAuthToken(req, res);
} catch (error) {
next(error);
}
});
app.get('/auth/passkey/status', (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
@@ -339,7 +467,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.requireSessionAuth(req, res, async () => {
await uiAuthController.handlePasskeyRegistrationOptions(req, res);
});
} catch (error) {
@@ -353,7 +481,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.requireSessionAuth(req, res, async () => {
await uiAuthController.handlePasskeyRegistrationVerify(req, res);
});
} catch (error) {
@@ -367,7 +495,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.requireSessionAuth(req, res, async () => {
await uiAuthController.handlePasskeyList(req, res);
});
} catch (error) {
@@ -381,7 +509,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.requireSessionAuth(req, res, async () => {
await uiAuthController.handlePasskeyRevoke(req, res);
});
} catch (error) {
@@ -395,7 +523,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return res.status(403).json({ error: 'Global sign-out is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.requireSessionAuth(req, res, async () => {
await uiAuthController.handleResetAuth(req, res);
});
} catch (error) {
@@ -403,6 +531,52 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
}
});
app.get('/api/client-auth/clients', async (req, res, next) => {
await runWithClientManagementAuth(req, res, next, async (authContext) => {
if (authContext.type === 'client') {
const client = await clientRecordFromAuthContext(authContext);
return res.json({ clients: client ? [client] : [] });
}
const clients = await remoteClientAuthRuntime.listClients();
res.json({ clients });
});
});
app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => {
await runWithUiAuth(req, res, next, async () => {
const result = await remoteClientAuthRuntime.createClient({
label: req.body?.label,
clientKind: req.body?.clientKind,
dedupeKey: req.body?.dedupeKey,
});
res.setHeader('Cache-Control', 'no-store');
res.status(201).json(result);
}, { sessionOnly: true });
});
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
await runWithClientManagementAuth(req, res, next, async (authContext) => {
if (authContext.type === 'client') {
const clientId = clientIdFromAuthContext(authContext);
if (!clientId || clientId !== req.params?.id) {
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
}
}
const result = await remoteClientAuthRuntime.revokeClient(req.params?.id);
if (!result.revoked) {
return res.status(404).json({ revoked: false, error: 'Client not found' });
}
res.json(result);
});
});
app.delete('/api/client-auth/clients', async (req, res, next) => {
await runWithUiAuth(req, res, next, async () => {
const result = await remoteClientAuthRuntime.purgeRevokedClients();
res.json(result);
}, { sessionOnly: true });
});
app.get('/connect', async (req, res) => {
try {
const token = typeof req.query?.t === 'string' ? req.query.t : '';
@@ -83,4 +83,190 @@ describe('core-routes', () => {
globalThis.fetch = originalFetch;
}
});
it('should let preview proxy credentials reach preview proxy validation', async () => {
const app = express();
const requireAuth = vi.fn((_req, res) => res.status(401).type('text/plain').send('Authentication required'));
registerAuthAndAccessRoutes(app, {
express,
tunnelAuthController: {
classifyRequestScope: () => 'local',
requireTunnelSession: vi.fn(),
getTunnelSessionFromRequest: vi.fn(),
clearTunnelSessionCookie: vi.fn(),
exchangeBootstrapToken: vi.fn(),
},
uiAuthController: {
requireAuth,
handleSessionStatus: vi.fn(),
handleSessionCreate: vi.fn(),
handlePasskeyStatus: vi.fn(),
handlePasskeyAuthenticationOptions: vi.fn(),
handlePasskeyAuthenticationVerify: vi.fn(),
handlePasskeyRegistrationOptions: vi.fn(),
handlePasskeyRegistrationVerify: vi.fn(),
handlePasskeyList: vi.fn(),
handlePasskeyRevoke: vi.fn(),
handleResetAuth: vi.fn(),
},
readSettingsFromDiskMigrated: vi.fn(async () => ({})),
normalizeTunnelSessionTtlMs: vi.fn(),
});
app.use('/api/preview/proxy', (_req, res) => res.json({ reached: true }));
await request(app)
.get('/api/preview/proxy/abc123/?oc_preview_token=preview-secret')
.expect(200, { reached: true });
await request(app)
.get('/api/preview/proxy/abc123/')
.set('Cookie', 'oc_preview_token=preview-secret')
.expect(200, { reached: true });
await request(app)
.get('/api/preview/proxy/abc123/')
.expect(401, 'Authentication required');
expect(requireAuth).toHaveBeenCalledTimes(1);
});
});
describe('client auth routes', () => {
const createDependencies = (options = {}) => {
const clients = [];
const requireAuth = vi.fn((_req, _res, next) => next());
const requireSessionAuth = vi.fn((_req, _res, next) => next());
const resolveAuthContext = vi.fn(options.resolveAuthContext || (async () => ({ type: 'session' })));
return {
express,
tunnelAuthController: {
classifyRequestScope: () => 'local',
getTunnelSessionFromRequest: () => null,
clearTunnelSessionCookie: () => {},
requireTunnelSession: (_req, _res, next) => next(),
},
uiAuthController: {
handleSessionStatus: (_req, res) => res.json({ authenticated: true }),
handleSessionCreate: (_req, res) => res.json({ authenticated: true }),
handlePasskeyStatus: (_req, res) => res.json({ enabled: false }),
handlePasskeyAuthenticationOptions: (_req, res) => res.json({}),
handlePasskeyAuthenticationVerify: (_req, res) => res.json({ authenticated: true }),
requireAuth,
requireSessionAuth,
resolveAuthContext,
handlePasskeyRegistrationOptions: (_req, res) => res.json({}),
handlePasskeyRegistrationVerify: (_req, res) => res.json({}),
handlePasskeyList: (_req, res) => res.json({ passkeys: [] }),
handlePasskeyRevoke: (_req, res) => res.json({ revoked: true }),
handleResetAuth: (_req, res) => res.json({ cleared: true }),
},
remoteClientAuthRuntime: {
listClients: async () => clients,
createClient: async ({ label, clientKind }) => {
const client = {
id: `client-${clients.length + 1}`,
label: label || 'Remote client',
createdAt: 'now',
lastUsedAt: null,
revokedAt: null,
clientKind: clientKind || null,
};
clients.push(client);
return { client, token: 'oc_client_secret' };
},
revokeClient: async (id) => {
const client = clients.find((entry) => entry.id === id);
if (!client) return { revoked: false };
client.revokedAt = 'revoked';
return { revoked: true, client };
},
purgeRevokedClients: async () => {
const before = clients.length;
for (let index = clients.length - 1; index >= 0; index -= 1) {
if (clients[index].revokedAt) clients.splice(index, 1);
}
return { purged: before - clients.length };
},
},
readSettingsFromDiskMigrated: async () => ({}),
normalizeTunnelSessionTtlMs: () => 1000,
testHooks: { clients, requireAuth, requireSessionAuth, resolveAuthContext },
};
};
it('creates, lists, and revokes remote client tokens', async () => {
const app = express();
const dependencies = createDependencies();
registerAuthAndAccessRoutes(app, dependencies);
const created = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Laptop' });
expect(created.status).toBe(201);
expect(created.body.token).toBe('oc_client_secret');
expect(created.headers['cache-control']).toBe('no-store');
const listed = await request(app).get('/api/client-auth/clients');
expect(listed.status).toBe(200);
expect(listed.body.clients).toHaveLength(1);
expect(listed.body.clients[0]).not.toHaveProperty('token');
const revoked = await request(app).delete('/api/client-auth/clients/client-1');
expect(revoked.status).toBe(200);
expect(revoked.body.revoked).toBe(true);
const purged = await request(app).delete('/api/client-auth/clients');
expect(purged.status).toBe(200);
expect(purged.body.purged).toBe(1);
const listedAfterPurge = await request(app).get('/api/client-auth/clients');
expect(listedAfterPurge.body.clients).toHaveLength(0);
expect(dependencies.testHooks.requireSessionAuth).toHaveBeenCalled();
expect(dependencies.testHooks.requireAuth).not.toHaveBeenCalled();
});
it('allows client credentials to list and revoke only the authenticated client', async () => {
const app = express();
let authContext = { type: 'session' };
const dependencies = createDependencies({
resolveAuthContext: async () => authContext,
});
registerAuthAndAccessRoutes(app, dependencies);
const current = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
const other = await request(app)
.post('/api/client-auth/clients')
.send({ label: 'Other device' });
authContext = { type: 'client', clientId: current.body.client.id, client: current.body.client };
const listed = await request(app).get('/api/client-auth/clients');
expect(listed.status).toBe(200);
expect(listed.body.clients).toEqual([current.body.client]);
const denied = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`);
expect(denied.status).toBe(403);
expect(denied.body.revoked).toBe(false);
const revoked = await request(app).delete(`/api/client-auth/clients/${current.body.client.id}`);
expect(revoked.status).toBe(200);
expect(revoked.body.revoked).toBe(true);
expect(revoked.body.client.id).toBe(current.body.client.id);
});
it('requires UI-session auth for passkey registration management routes', async () => {
const app = express();
const dependencies = createDependencies();
registerAuthAndAccessRoutes(app, dependencies);
await request(app).post('/auth/passkey/register/options').expect(200);
await request(app).post('/auth/passkey/register/verify').expect(200);
expect(dependencies.testHooks.requireSessionAuth).toHaveBeenCalledTimes(2);
expect(dependencies.testHooks.requireAuth).not.toHaveBeenCalled();
});
});
@@ -152,6 +152,10 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
restartCmdFallback += ` --ui-password '${escapedPw}'`;
}
}
if (storedOptions.apiOnly === true) {
restartCmdPrimary += ' --api-only';
restartCmdFallback += ' --api-only';
}
const restartCmd = isForegroundService ? '' : `(${restartCmdPrimary}) || (${restartCmdFallback})`;
const updateLogPath = path.join(openchamberDataDir, 'update-install.log');
const logPreamble = [
+58 -1
View File
@@ -123,6 +123,61 @@ export const registerOpenCodeProxy = (app, deps) => {
realpath: fs?.promises?.realpath?.bind(fs.promises),
});
const hasParsedBodyValue = (body) => {
if (body === undefined || body === null) return false;
if (Buffer.isBuffer(body)) return body.length > 0;
if (typeof body === 'string') return body.length > 0;
if (Array.isArray(body)) return body.length > 0;
if (typeof body === 'object') return Object.keys(body).length > 0;
return true;
};
const getContentType = (proxyReq, req) => {
const value = proxyReq.getHeader?.('content-type') ?? req.headers?.['content-type'] ?? '';
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
};
const serializeUrlEncodedBody = (body) => {
if (!body || typeof body !== 'object' || Buffer.isBuffer(body)) {
return String(body ?? '');
}
const params = new URLSearchParams();
for (const [key, value] of Object.entries(body)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const entry of value) {
if (entry !== undefined && entry !== null) params.append(key, String(entry));
}
continue;
}
params.append(key, String(value));
}
return params.toString();
};
const serializeParsedBody = (req, proxyReq) => {
if (req.method === 'GET' || req.method === 'HEAD') return null;
if (req.body === undefined || req.body === null) return null;
const originalContentLength = Number.parseInt(req.headers?.['content-length'] || '0', 10) || 0;
if (!hasParsedBodyValue(req.body) && originalContentLength <= 0) return null;
const contentType = getContentType(proxyReq, req).toLowerCase();
if (Buffer.isBuffer(req.body)) return req.body;
if (contentType.includes('application/json')) return Buffer.from(JSON.stringify(req.body));
if (contentType.includes('application/x-www-form-urlencoded')) return Buffer.from(serializeUrlEncodedBody(req.body));
if (typeof req.body === 'string') return Buffer.from(req.body);
return null;
};
const replayParsedBody = (proxyReq, req) => {
const body = serializeParsedBody(req, proxyReq);
if (!body) return;
proxyReq.setHeader('content-length', String(body.length));
proxyReq.write(body);
};
const normalizeProxyTarget = (candidate) => {
if (typeof candidate !== 'string') {
return null;
@@ -417,7 +472,7 @@ export const registerOpenCodeProxy = (app, deps) => {
// Dynamic target — port can change after restart
router: () => resolveProxyTarget(),
on: {
proxyReq: (proxyReq) => {
proxyReq: (proxyReq, req) => {
// Inject OpenCode auth headers
const authHeaders = getOpenCodeAuthHeaders();
if (authHeaders.Authorization) {
@@ -427,6 +482,8 @@ export const registerOpenCodeProxy = (app, deps) => {
// Defensive: request identity encoding from upstream OpenCode.
// This avoids compressed-body/header mismatches in multi-proxy setups.
proxyReq.setHeader('accept-encoding', 'identity');
replayParsedBody(proxyReq, req);
},
proxyRes: (proxyRes) => {
for (const key of Object.keys(proxyRes.headers || {})) {
@@ -51,6 +51,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
onTunnelReady,
tunnelRuntimeContext,
attachSignals,
apiOnly,
} = options;
const terminalRuntime = createTerminalRuntime({
@@ -88,7 +89,11 @@ export const createStartupPipelineRuntime = (dependencies) => {
scheduleOpenCodeApiDetection();
void bootstrapOpenCodeAtStartup();
staticRoutesRuntime.registerStaticRoutes(app);
if (apiOnly) {
staticRoutesRuntime.registerApiOnlyFallbackRoutes(app);
} else {
staticRoutesRuntime.registerStaticRoutes(app);
}
const serverStartupRuntime = createServerStartupRuntime({
process,
@@ -59,7 +59,207 @@ export const createStaticRoutesRuntime = (dependencies) => {
});
};
const registerApiOnlyFallbackRoutes = (app) => {
app.get(/^(?!\/api|\/auth|\/health|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
const command = 'openchamber connect-url --help';
res.status(200).format({
html: () => {
res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenChamber API-only mode</title>
<style>
:root {
color-scheme: dark;
--surface-background: #151313;
--surface-elevated: #1c1b1a;
--surface-foreground: #cdccc3;
--surface-muted-foreground: #b6b4ab;
--interactive-border: rgba(57,56,54,.72);
--primary-base: #edb449;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--surface-background: oklch(0.97 0.02 85);
--surface-elevated: oklch(0.99 0.01 90);
--surface-foreground: oklch(0.25 0.02 40);
--surface-muted-foreground: oklch(0.45 0.02 50);
--interactive-border: rgba(194,151,77,.22);
--primary-base: oklch(0.65 0.2 55);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
background: var(--surface-background);
color: var(--surface-foreground);
padding: 32px;
}
main {
width: min(448px, 100%);
text-align: center;
}
.logo {
width: 86px;
height: 86px;
margin: 0 auto 28px;
display: block;
color: var(--surface-foreground);
opacity: .88;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.025em;
}
p {
margin: 10px auto 0;
max-width: 400px;
color: var(--surface-muted-foreground);
font-size: 14px;
line-height: 1.6;
}
.command {
margin: 24px auto 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
max-width: 100%;
padding: 12px 16px;
border: 1px solid var(--interactive-border);
border-radius: 10px;
background: color-mix(in srgb, var(--surface-background) 60%, transparent);
backdrop-filter: blur(8px);
}
code {
color: var(--surface-foreground);
font: 13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: nowrap;
overflow-x: auto;
text-align: left;
}
button {
appearance: none;
border: 0;
background: transparent;
color: var(--surface-muted-foreground);
cursor: pointer;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
transition: color .15s ease;
}
button:hover { color: var(--surface-foreground); }
button svg { width: 16px; height: 16px; display: block; }
.check-icon { display: none; }
button[data-copied="true"] .copy-icon { display: none; }
button[data-copied="true"] .check-icon { display: block; color: var(--primary-base); }
</style>
</head>
<body>
<main>
<svg class="logo" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber logo">
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="currentColor" fill-opacity=".15" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M8.432 26 L18.824 32 L18.824 44 L8.432 38 Z" fill="currentColor" fill-opacity=".2"/>
<path d="M18.824 32 L29.216 38 L29.216 50 L18.824 44 Z" fill="currentColor" fill-opacity=".45"/>
<path d="M29.216 38 L39.608 44 L39.608 56 L29.216 50 Z" fill="currentColor" fill-opacity=".15"/>
<path d="M39.608 44 L50 50 L50 62 L39.608 56 Z" fill="currentColor" fill-opacity=".55"/>
<path d="M8.432 38 L18.824 44 L18.824 56 L8.432 50 Z" fill="currentColor" fill-opacity=".35"/>
<path d="M18.824 44 L29.216 50 L29.216 62 L18.824 56 Z" fill="currentColor" fill-opacity=".1"/>
<path d="M29.216 50 L39.608 56 L39.608 68 L29.216 62 Z" fill="currentColor" fill-opacity=".5"/>
<path d="M39.608 56 L50 62 L50 74 L39.608 68 Z" fill="currentColor" fill-opacity=".25"/>
<path d="M8.432 50 L18.824 56 L18.824 68 L8.432 62 Z" fill="currentColor" fill-opacity=".4"/>
<path d="M18.824 56 L29.216 62 L29.216 74 L18.824 68 Z" fill="currentColor" fill-opacity=".3"/>
<path d="M29.216 62 L39.608 68 L39.608 80 L29.216 74 Z" fill="currentColor" fill-opacity=".45"/>
<path d="M39.608 68 L50 74 L50 86 L39.608 80 Z" fill="currentColor" fill-opacity=".15"/>
<path d="M8.432 62 L18.824 68 L18.824 80 L8.432 74 Z" fill="currentColor" fill-opacity=".55"/>
<path d="M18.824 68 L29.216 74 L29.216 86 L18.824 80 Z" fill="currentColor" fill-opacity=".2"/>
<path d="M29.216 74 L39.608 80 L39.608 92 L29.216 86 Z" fill="currentColor" fill-opacity=".35"/>
<path d="M39.608 80 L50 86 L50 98 L39.608 92 Z" fill="currentColor" fill-opacity=".1"/>
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="currentColor" fill-opacity=".15" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="currentColor" fill-opacity=".3"/>
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="currentColor" fill-opacity=".15"/>
<path d="M70.784 38 L81.176 32 L81.176 44 L70.784 50 Z" fill="currentColor" fill-opacity=".45"/>
<path d="M81.176 32 L91.568 26 L91.568 38 L81.176 44 Z" fill="currentColor" fill-opacity=".25"/>
<path d="M50 62 L60.392 56 L60.392 68 L50 74 Z" fill="currentColor" fill-opacity=".5"/>
<path d="M60.392 56 L70.784 50 L70.784 62 L60.392 68 Z" fill="currentColor" fill-opacity=".35"/>
<path d="M70.784 50 L81.176 44 L81.176 56 L70.784 62 Z" fill="currentColor" fill-opacity=".1"/>
<path d="M81.176 44 L91.568 38 L91.568 50 L81.176 56 Z" fill="currentColor" fill-opacity=".4"/>
<path d="M50 74 L60.392 68 L60.392 80 L50 86 Z" fill="currentColor" fill-opacity=".2"/>
<path d="M60.392 68 L70.784 62 L70.784 74 L60.392 80 Z" fill="currentColor" fill-opacity=".55"/>
<path d="M70.784 62 L81.176 56 L81.176 68 L70.784 74 Z" fill="currentColor" fill-opacity=".3"/>
<path d="M81.176 56 L91.568 50 L91.568 62 L81.176 68 Z" fill="currentColor" fill-opacity=".15"/>
<path d="M50 86 L60.392 80 L60.392 92 L50 98 Z" fill="currentColor" fill-opacity=".45"/>
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="currentColor" fill-opacity=".25"/>
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="currentColor" fill-opacity=".4"/>
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="currentColor" fill-opacity=".2"/>
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<g transform="matrix(.866 .5 -.866 .5 50 26) scale(.75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="currentColor"/>
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="currentColor" fill-opacity=".4"/>
</g>
</svg>
<h1>OpenChamber is running in headless mode</h1>
<p>This server is ready. Open it from the OpenChamber desktop or mobile app to use it.</p>
<div class="command">
<code id="connect-command">${command}</code>
<button type="button" id="copy-command" aria-label="Copy command" title="Copy command">
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="M8 7.2C8 6.08 8 5.52 8.218 5.092a2 2 0 0 1 .874-.874C9.52 4 10.08 4 11.2 4h5.6c1.12 0 1.68 0 2.108.218a2 2 0 0 1 .874.874C20 5.52 20 6.08 20 7.2v5.6c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874C18.48 16 17.92 16 16.8 16h-5.6c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874C8 14.48 8 13.92 8 12.8V7.2Z" stroke="currentColor" stroke-width="1.8"/>
<path d="M4 8v8.8C4 17.92 4 18.48 4.218 18.908a2 2 0 0 0 .874.874C5.52 20 6.08 20 7.2 20H16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<svg class="check-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12.5 9.5 17 19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</main>
<script>
const button = document.getElementById('copy-command');
const command = document.getElementById('connect-command');
let copyTimer;
button?.addEventListener('click', async () => {
const text = command?.textContent || '';
try {
await navigator.clipboard.writeText(text);
button.dataset.copied = 'true';
window.clearTimeout(copyTimer);
copyTimer = window.setTimeout(() => {
button.dataset.copied = 'false';
}, 1400);
} catch {
button.dataset.copied = 'false';
}
});
</script>
</body>
</html>`);
},
json: () => {
res.json({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
},
default: () => {
res.type('text/plain').send('OpenChamber is running in API-only mode');
},
});
});
};
return {
registerApiOnlyFallbackRoutes,
registerStaticRoutes,
};
};
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'bun:test';
import express from 'express';
import request from 'supertest';
import { createStaticRoutesRuntime } from './static-routes-runtime.js';
const createRuntime = () => createStaticRoutesRuntime({
fs: { existsSync: () => false },
path: { join: (...parts) => parts.join('/'), resolve: (value) => value, sep: '/' },
process: { env: {} },
__dirname: '/server',
express,
resolveProjectDirectory: () => '',
buildOpenCodeUrl: () => '',
getOpenCodeAuthHeaders: () => ({}),
readSettingsFromDiskMigrated: async () => ({}),
normalizePwaAppName: (value) => value,
normalizePwaOrientation: (value) => value,
});
describe('static routes runtime', () => {
it('returns API-only HTML fallback for browser UI routes', async () => {
const app = express();
createRuntime().registerApiOnlyFallbackRoutes(app);
const response = await request(app).get('/sessions/abc').set('Accept', 'text/html');
expect(response.status).toBe(200);
expect(response.text).toContain('OpenChamber is running in headless mode');
expect(response.text).toContain('Open it from the OpenChamber desktop or mobile app');
expect(response.text).toContain('openchamber connect-url --help');
expect(response.text).toContain('Copy command');
});
it('returns API-only info JSON for JSON clients', async () => {
const app = express();
createRuntime().registerApiOnlyFallbackRoutes(app);
const response = await request(app).get('/sessions/abc').set('Accept', 'application/json');
expect(response.status).toBe(200);
expect(response.body).toEqual({
ok: true,
mode: 'api-only',
message: 'OpenChamber is running in API-only mode',
});
});
it('does not intercept API, auth, or health routes in API-only mode', async () => {
const app = express();
createRuntime().registerApiOnlyFallbackRoutes(app);
const api = await request(app).get('/api/version');
const auth = await request(app).get('/auth/session');
const health = await request(app).get('/health');
expect(api.body).not.toEqual({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
expect(auth.body).not.toEqual({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
expect(health.body).not.toEqual({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
});
});