From dc8ef41421c93b939aa699512d1c24509558a175 Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Thu, 17 Sep 2026 15:47:28 -0300
Subject: [PATCH 1/2] Add locally approved browser control independent of cloud
---
bridge/package-lock.json | 4 +-
bridge/package.json | 2 +-
bridge/src/bridge-cli.test.ts | 13 +-
bridge/src/browser-access.test.ts | 161 ++++++++++++
bridge/src/browser-access.ts | 247 ++++++++++++++++++
bridge/src/centrifugo-client.ts | 69 +++--
.../src/centrifugo-local-publications.test.ts | 92 +++++++
.../src/direct-transport/loopback-server.ts | 15 +-
bridge/src/index.ts | 64 ++++-
bridge/src/local-api-server.ts | 50 +++-
bridge/src/local-browser-integration.test.ts | 187 +++++++++++++
bridge/src/local-pair-cli.test.ts | 25 ++
bridge/src/local-pair-cli.ts | 85 ++++++
bridge/src/session-wire.test.ts | 2 +
docs/design/local-browser-control.md | 109 ++++++++
docs/local-browser.md | 67 +++++
ui/src/app/local/page.tsx | 168 +-----------
ui/src/app/login/page.tsx | 2 +
ui/src/app/providers.tsx | 3 +
ui/src/components/Dashboard.tsx | 23 +-
ui/src/components/LocalDashboard.test.ts | 70 +++++
ui/src/components/LocalDashboard.tsx | 154 +++++++++++
ui/src/components/local/SoloLocalPage.tsx | 167 ++++++++++++
ui/src/hooks/useAllSessionEvents.ts | 6 +-
ui/src/hooks/useBridgeRpc.ts | 9 +-
ui/src/hooks/useBridges.ts | 8 +-
ui/src/hooks/useLoops.ts | 6 +-
ui/src/hooks/useSessions.ts | 13 +-
.../hybrid-terminal-transport.ts | 20 +-
ui/src/lib/local-browser-client.test.ts | 147 +++++++++++
ui/src/lib/local-browser-client.ts | 192 ++++++++++++++
ui/src/lib/message-client.ts | 31 +++
32 files changed, 1968 insertions(+), 243 deletions(-)
create mode 100644 bridge/src/browser-access.test.ts
create mode 100644 bridge/src/browser-access.ts
create mode 100644 bridge/src/centrifugo-local-publications.test.ts
create mode 100644 bridge/src/local-browser-integration.test.ts
create mode 100644 bridge/src/local-pair-cli.test.ts
create mode 100644 bridge/src/local-pair-cli.ts
create mode 100644 docs/design/local-browser-control.md
create mode 100644 docs/local-browser.md
create mode 100644 ui/src/components/LocalDashboard.test.ts
create mode 100644 ui/src/components/LocalDashboard.tsx
create mode 100644 ui/src/components/local/SoloLocalPage.tsx
create mode 100644 ui/src/lib/local-browser-client.test.ts
create mode 100644 ui/src/lib/local-browser-client.ts
create mode 100644 ui/src/lib/message-client.ts
diff --git a/bridge/package-lock.json b/bridge/package-lock.json
index 2b154cf..40b84af 100644
--- a/bridge/package-lock.json
+++ b/bridge/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ftown-bridge",
- "version": "0.19.27",
+ "version": "0.19.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ftown-bridge",
- "version": "0.19.27",
+ "version": "0.19.28",
"license": "MIT",
"dependencies": {
"@xterm/addon-serialize": "^0.14.0",
diff --git a/bridge/package.json b/bridge/package.json
index 9f27d83..0d4ece7 100644
--- a/bridge/package.json
+++ b/bridge/package.json
@@ -1,6 +1,6 @@
{
"name": "ftown-bridge",
- "version": "0.19.27",
+ "version": "0.19.28",
"description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay",
"type": "module",
"main": "dist/index.js",
diff --git a/bridge/src/bridge-cli.test.ts b/bridge/src/bridge-cli.test.ts
index 20f91ba..f7e3a47 100644
--- a/bridge/src/bridge-cli.test.ts
+++ b/bridge/src/bridge-cli.test.ts
@@ -14,5 +14,16 @@ test('ftown-bridge uses the hosted API when --api-url is omitted', () => {
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /--api-url /);
- assert.match(result.stdout, /default: "https:\/\/ftown\.ia\.br"/);
+ assert.match(result.stdout.replace(/\s+/g, ' '), /default: "https:\/\/ftown\.ia\.br"/);
+});
+
+
+test('local browser commands are available before cloud onboarding', () => {
+ for (const args of [['pair', '--help'], ['devices', '--help'], ['revoke', '--help']]) {
+ const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/index.ts', ...args],
+ { cwd: bridgeRoot, encoding: 'utf8' });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /--data-dir/);
+ assert.doesNotMatch(result.stdout, /Authenticating|device pairing/);
+ }
});
diff --git a/bridge/src/browser-access.test.ts b/bridge/src/browser-access.test.ts
new file mode 100644
index 0000000..1fa4606
--- /dev/null
+++ b/bridge/src/browser-access.test.ts
@@ -0,0 +1,161 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { BrowserAccess } from './browser-access.js';
+import type { Command, CommandResponse } from './types.js';
+
+const origin = 'https://ftown.ia.br';
+const other = 'https://preview.example.com';
+const bootstrap = () => ({ version: 1 as const, userId: 'local', bridgeId: 'bridge-1', hostname: 'laptop', localPort: 1234, localNonce: 'admin-secret' });
+async function fixture(execute: (c: Command) => Promise = async c => ({ requestId: c.requestId, success: true })) {
+ const dataDir = mkdtempSync(join(tmpdir(), 'browser-access-'));
+ let revoked = 0;
+ const opts = { dataDir, allowedOrigins: [origin, other], bootstrap, execute, onRevoke: () => { revoked++; } };
+ let access = new BrowserAccess(opts);
+ const server = createServer((req, res) => { void access.handle(req, res, req.headers.authorization === 'Bearer admin' && !req.headers.origin); });
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ const address = server.address();
+ if (!address || typeof address === 'string') throw new Error('No address');
+ async function call(path: string, method = 'GET', body?: unknown, token = '', requestOrigin = origin) {
+ const response = await fetch(`http://127.0.0.1:${address!.port}/api/browser/${path}`, { method, headers: { ...(requestOrigin ? { Origin: requestOrigin } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) });
+ return { status: response.status, body: await response.json(), headers: response.headers };
+ }
+ const admin = (path: string, method = 'GET', body?: unknown) => call(path, method, body, 'admin', '');
+ async function pair(remember = true) {
+ await admin('window', 'POST', {});
+ const request = await call('pairings', 'POST', { remember });
+ assert.equal(request.status, 201);
+ await admin(`pairings/${request.body.id}/decision`, 'POST', { approve: true });
+ const result = await call(`pairings/${request.body.id}`, 'GET', undefined, request.body.pollToken);
+ return { ...request.body, token: result.body.credential as string };
+ }
+ return { get access() { return access; }, dataDir, call, admin, pair, get revoked() { return revoked; }, restart() { access.close(); access = new BrowserAccess(opts); }, async close() { access.close(); server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); rmSync(dataDir, { recursive: true, force: true }); } };
+}
+
+test('HTTP consent gates access, binds origin, persists only hashes, revokes and survives restart', async () => {
+ const f = await fixture();
+ try {
+ assert.equal((await f.call('pairings', 'POST', { remember: true })).status, 409);
+ assert.equal((await f.call('bootstrap', 'POST', {})).status, 401);
+ assert.equal((await f.call('window', 'POST', {}, 'admin')).status, 401);
+ assert.equal((await f.call('pairings', 'POST', { remember: true }, '', 'https://evil.test')).status, 403);
+ const p = await f.pair();
+ const good = await f.call('bootstrap', 'POST', {}, p.token);
+ assert.equal(good.status, 200); assert.equal(good.body.localNonce, p.token);
+ assert.equal(f.access.authorize(p.token, origin), true);
+ assert.equal((await f.call('bootstrap', 'POST', {}, p.token, other)).status, 401);
+ assert.equal((await f.call(`pairings/${p.id}`, 'GET', undefined, p.pollToken, other)).status, 401);
+ assert.equal((await f.call(`pairings/${p.id}`, 'GET', undefined, p.token)).status, 401);
+ assert.equal((await f.call(`pairings/${p.id}`, 'GET', undefined, p.pollToken)).body.credential, p.token);
+ assert.equal((await f.admin(`pairings/${p.id}/decision`, 'POST', { approve: false })).status, 409);
+ const disk = readFileSync(join(f.dataDir, 'browser-devices.json'), 'utf8');
+ assert.ok(!disk.includes(p.token)); assert.ok(!disk.includes(p.pollToken));
+ f.restart(); assert.equal(f.access.authorize(p.token, origin), true);
+ const devices = await f.admin('devices');
+ const id = devices.body.devices[0].id;
+ assert.equal((await f.admin(`devices/${id}`, 'DELETE')).status, 200);
+ assert.equal(f.revoked, 1); assert.equal(f.access.authorize(p.token, origin), false);
+ f.restart(); assert.equal(f.access.authorize(p.token, origin), false);
+ } finally { await f.close(); }
+});
+
+test('denial, expiry, replacement, session-only credentials and pairing caps', async () => {
+ const f = await fixture(); const now = Date.now;
+ try {
+ const session = await f.pair(false); f.restart(); assert.equal(f.access.authorize(session.token, origin), false);
+ await f.admin('window', 'POST', {});
+ const p = await f.call('pairings', 'POST', { remember: false });
+ await f.admin(`pairings/${p.body.id}/decision`, 'POST', { approve: false });
+ assert.deepEqual((await f.call(`pairings/${p.body.id}`, 'GET', undefined, p.body.pollToken)).body, { status: 'denied' });
+ await f.admin('window', 'POST', {});
+ assert.equal((await f.call(`pairings/${p.body.id}`, 'GET', undefined, p.body.pollToken)).status, 401);
+ const expiring = await f.call('pairings', 'POST', { remember: false });
+ const timestamp = now(); Date.now = () => timestamp + 120_001;
+ assert.equal((await f.admin(`pairings/${expiring.body.id}/decision`, 'POST', { approve: true })).status, 404);
+ assert.equal((await f.call('pairings', 'POST', { remember: false })).status, 409);
+ Date.now = now;
+ await f.admin('window', 'POST', {});
+ for (let i = 0; i < 5; i++) assert.equal((await f.call('pairings', 'POST', { remember: false })).status, 201);
+ assert.equal((await f.call('pairings', 'POST', { remember: false })).status, 429);
+ } finally { Date.now = now; await f.close(); }
+});
+
+test('same request shares in-flight execution, conflicts reject, device namespaces differ', async () => {
+ let calls = 0; let release!: () => void;
+ const pending = new Promise(resolve => { release = resolve; });
+ const f = await fixture(async c => { calls++; await pending; return { requestId: c.requestId, success: true, data: calls }; });
+ try {
+ const p = await f.pair();
+ const body = { type: 'list_sessions', payload: { bridgeId: 'bridge-1' }, requestId: 'one' };
+ const a = f.call('commands', 'POST', body, p.token);
+ const b = f.call('commands', 'POST', body, p.token);
+ const conflict = await f.call('commands', 'POST', { ...body, type: 'list_loops' }, p.token);
+ assert.equal(conflict.status, 409); assert.equal(calls, 1);
+ release(); assert.deepEqual((await a).body, (await b).body);
+ assert.equal((await f.call('commands', 'POST', body, p.token)).status, 200); assert.equal(calls, 1);
+ assert.equal((await f.call('commands', 'POST', { ...body, payload: { bridgeId: 'other' } }, p.token)).status, 403);
+ assert.equal((await f.call('commands', 'POST', { ...body, type: 'nonsense' }, p.token)).status, 400);
+ const p2 = await f.pair(false); await f.call('commands', 'POST', body, p2.token); assert.equal(calls, 2);
+ } finally { release(); await f.close(); }
+});
+
+test('events preserve bounded history, wake longpoll, cap requests and terminate on revoke', async () => {
+ const f = await fixture();
+ try {
+ const p = await f.pair();
+ const waiter = f.call('events?cursor=0', 'GET', undefined, p.token);
+ const second = f.call('events?cursor=0', 'GET', undefined, p.token);
+ assert.equal((await f.call('events?cursor=0', 'GET', undefined, p.token)).status, 429);
+ f.access.publish('sessions', { sessionId: 'abc' });
+ assert.deepEqual((await waiter).body.events, [{ channel: 'sessions', data: { sessionId: 'abc' } }]); await second;
+ for (let i = 0; i < 300; i++) f.access.publish('sessions', { i });
+ const result = await f.call('events?cursor=1', 'GET', undefined, p.token);
+ assert.equal(result.body.reset, true); assert.equal(result.body.events.length, 256);
+ const waiting = f.call(`events?cursor=${result.body.cursor}`, 'GET', undefined, p.token);
+ const id = (await f.admin('devices')).body.devices[0].id;
+ await f.admin(`devices/${id}`, 'DELETE'); assert.equal((await waiting).status, 401);
+ } finally { await f.close(); }
+});
+
+
+test('CORS preflights require exact origin, methods and headers including private network permission', async () => {
+ const f = await fixture();
+ try {
+ // Exercise IncomingMessage/ServerResponse through a real HTTP listener.
+ const server = createServer((req, res) => { void f.access.handle(req, res, false); });
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ const address = server.address() as { port: number };
+ try {
+ const url = `http://127.0.0.1:${address.port}/api/browser/bootstrap`;
+ const headers = { Origin: origin, 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'authorization, content-type', 'Access-Control-Request-Private-Network': 'true' };
+ const response = await fetch(url, { method: 'OPTIONS', headers });
+ assert.equal(response.status, 204); assert.equal(response.headers.get('access-control-allow-origin'), origin);
+ assert.equal(response.headers.get('access-control-allow-private-network'), 'true');
+ assert.equal(response.headers.get('access-control-allow-credentials'), null);
+ assert.equal((await fetch(url, { method: 'OPTIONS', headers: { ...headers, Origin: 'null' } })).status, 403);
+ assert.equal((await fetch(url, { method: 'OPTIONS', headers: { ...headers, 'Access-Control-Request-Headers': 'x-admin-token' } })).status, 403);
+ assert.equal((await fetch(url, { method: 'OPTIONS', headers: { ...headers, 'Access-Control-Request-Method': 'PUT' } })).status, 403);
+ } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); }
+ } finally { await f.close(); }
+});
+
+test('command capacity rejects before execution and completed cache entries expire', async () => {
+ let calls = 0;
+ const f = await fixture(async c => { calls++; return { requestId: c.requestId, success: true }; });
+ const now = Date.now;
+ try {
+ const p = await f.pair();
+ for (let i = 0; i < 1000; i++) {
+ const result = await f.call('commands', 'POST', { type: 'list_sessions', payload: {}, requestId: String(i) }, p.token);
+ assert.equal(result.status, 200);
+ }
+ assert.equal((await f.call('commands', 'POST', { type: 'list_sessions', payload: {}, requestId: 'overflow' }, p.token)).status, 429);
+ assert.equal(calls, 1000);
+ const timestamp = now(); Date.now = () => timestamp + 600_001;
+ assert.equal((await f.call('commands', 'POST', { type: 'list_sessions', payload: {}, requestId: 'overflow' }, p.token)).status, 200);
+ assert.equal(calls, 1001);
+ } finally { Date.now = now; await f.close(); }
+});
diff --git a/bridge/src/browser-access.ts b/bridge/src/browser-access.ts
new file mode 100644
index 0000000..f8327dc
--- /dev/null
+++ b/bridge/src/browser-access.ts
@@ -0,0 +1,247 @@
+import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto';
+import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import type { Command, CommandResponse } from './types.js';
+
+export type BrowserBootstrap = { version: 1; userId: string; bridgeId: string; hostname: string; localPort: number; localNonce: string };
+type Options = { dataDir: string; allowedOrigins: string[]; bootstrap: () => BrowserBootstrap; execute: (command: Command) => Promise; onRevoke?: () => void };
+type Device = { id: string; origin: string; createdAt: string; hash: string; bridgeId: string; remember: boolean };
+type Pairing = { id: string; code: string; origin: string; remember: boolean; expiresAt: string; pollHash: string; status: 'pending' | 'denied' | 'approved'; credential?: string };
+type Cached = { body: string; result: Promise; completedAt?: number };
+type Event = { sequence: number; channel: string; data: unknown; bytes: number };
+const WINDOW_MS = 120_000;
+const hash = (value: string) => createHash('sha256').update(value).digest('hex');
+const secret = () => randomBytes(32).toString('base64url');
+const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value);
+function canonical(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
+ if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
+ return JSON.stringify(value);
+}
+class HttpError extends Error { constructor(public status: number, public code: string, message: string) { super(message); } }
+const fail = (status: number, code: string, message: string): never => { throw new HttpError(status, code, message); };
+const commandTypes = new Set(['create_session', 'stop_session', 'list_sessions', 'get_history', 'retry_session', 'send_message', 'rename_session', 'remove_session', 'bridge_exec', 'clear_terminal', 'update_session_parent', 'get_session_usage', 'get_sessions_usage', 'create_loop', 'list_loops', 'update_loop', 'delete_loop', 'run_loop_now', 'get_loop_runs']);
+
+/** Loopback browser consent and control. No cloud dependency and no raw persisted tokens. */
+export class BrowserAccess {
+ private devices = new Map();
+ private pairings = new Map();
+ private rates = new Map();
+ private commands = new Map();
+ private events: Event[] = [];
+ private eventBytes = 0;
+ private sequence = 0;
+ private waiters = new Map<() => void, string>();
+ private windowExpires = 0;
+ private closed = false;
+ private readonly file: string;
+ constructor(private readonly opts: Options) {
+ this.file = join(opts.dataDir, 'browser-devices.json');
+ try {
+ const saved: unknown = JSON.parse(readFileSync(this.file, 'utf8'));
+ if (!Array.isArray(saved)) throw new Error('Invalid browser credential store');
+ for (const d of saved) {
+ if (!object(d) || typeof d.id !== 'string' || typeof d.origin !== 'string' || typeof d.createdAt !== 'string' || typeof d.hash !== 'string' || !/^[a-f0-9]{64}$/.test(d.hash) || typeof d.bridgeId !== 'string') throw new Error('Invalid browser credential store');
+ if (this.devices.size >= 100) throw new Error('Browser credential store exceeds limit');
+ this.devices.set(d.id, { ...d, remember: true } as Device);
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
+ }
+ }
+ private persist() {
+ mkdirSync(this.opts.dataDir, { recursive: true, mode: 0o700 });
+ const temp = `${this.file}.${randomUUID()}.tmp`;
+ writeFileSync(temp, JSON.stringify([...this.devices.values()].filter(d => d.remember)), { mode: 0o600 });
+ renameSync(temp, this.file);
+ }
+ private device(token: string, origin: string): Device | undefined {
+ if (this.closed || !this.opts.allowedOrigins.includes(origin) || !/^[A-Za-z0-9_-]{43}$/.test(token)) return;
+ const digest = hash(token);
+ return [...this.devices.values()].find(d => d.hash === digest && d.origin === origin && d.bridgeId === this.opts.bootstrap().bridgeId);
+ }
+ authorize(token: string, origin: string): boolean { return !!this.device(token, origin); }
+ private sweep() {
+ const now = Date.now();
+ for (const [id, pair] of this.pairings) if (Date.parse(pair.expiresAt) <= now) this.pairings.delete(id);
+ for (const [id, entry] of this.commands) if (entry.completedAt !== undefined && now - entry.completedAt >= 600_000) this.commands.delete(id);
+ }
+ private send(res: ServerResponse, status: number, body: unknown) {
+ if (res.destroyed || res.writableEnded) return;
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(body));
+ }
+ private async body(req: IncomingMessage): Promise> {
+ if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) fail(400, 'invalid_body', 'Expected application/json');
+ let size = 0;
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) {
+ const bytes = Buffer.from(chunk); size += bytes.length;
+ if (size > 65536) fail(400, 'invalid_body', 'Request body too large');
+ chunks.push(bytes);
+ }
+ if (this.closed) fail(503, 'closed', 'Bridge is closing');
+ let result: unknown;
+ try { result = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { fail(400, 'invalid_body', 'Invalid JSON'); }
+ if (!object(result)) return fail(400, 'invalid_body', 'Expected an object');
+ return result;
+ }
+ async handle(req: IncomingMessage, res: ServerResponse, admin: boolean): Promise {
+ let requestId = randomUUID() as string;
+ try {
+ if (this.closed) fail(503, 'closed', 'Bridge is closing');
+ this.sweep();
+ const url = new URL(req.url ?? '/', 'http://localhost');
+ const path = url.pathname;
+ if (!path.startsWith('/api/browser/')) fail(404, 'not_found', 'Not found');
+ const origin = req.headers.origin ?? '';
+ admin = admin && !origin;
+ if (!admin) {
+ if (!this.opts.allowedOrigins.includes(origin)) fail(403, 'origin_forbidden', 'Origin is not allowed');
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Vary', 'Origin');
+ if (req.method === 'OPTIONS') {
+ if (!['GET', 'POST', 'DELETE'].includes(req.headers['access-control-request-method'] ?? '')) fail(403, 'preflight_forbidden', 'Method is not allowed');
+ const headers = String(req.headers['access-control-request-headers'] ?? '').split(',').map(h => h.trim().toLowerCase()).filter(Boolean);
+ if (headers.some(h => !['authorization', 'content-type'].includes(h))) fail(403, 'preflight_forbidden', 'Headers are not allowed');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE');
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
+ if (req.headers['access-control-request-private-network'] === 'true') res.setHeader('Access-Control-Allow-Private-Network', 'true');
+ res.writeHead(204); res.end(); return;
+ }
+ }
+ const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(req.headers.authorization ?? '')?.[1] ?? '';
+ if (admin) {
+ if (path === '/api/browser/window' && req.method === 'POST') {
+ await this.body(req); this.windowExpires = Date.now() + WINDOW_MS; this.pairings.clear();
+ this.send(res, 200, { expiresAt: new Date(this.windowExpires).toISOString() }); return;
+ }
+ if (path === '/api/browser/pairings' && req.method === 'GET') {
+ this.send(res, 200, { pairings: [...this.pairings.values()].filter(p => p.status === 'pending').map(({ id, code, origin, remember, expiresAt }) => ({ id, code, origin, remember, expiresAt })) }); return;
+ }
+ const decision = /^\/api\/browser\/pairings\/([^/]+)\/decision$/.exec(path);
+ if (decision && req.method === 'POST') {
+ const body = await this.body(req);
+ if (typeof body.approve !== 'boolean') fail(400, 'invalid_decision', 'approve must be boolean');
+ this.sweep();
+ const pair = this.pairings.get(decision[1]);
+ if (!pair) fail(404, 'not_found', 'Pairing expired or missing');
+ const state = body.approve ? 'approved' : 'denied';
+ if (pair!.status !== 'pending' && pair!.status !== state) fail(409, 'decision_conflict', 'Pairing already decided');
+ if (pair!.status === 'pending' && body.approve) {
+ if (this.devices.size >= 100) fail(429, 'device_limit', 'Too many paired browsers');
+ const credential = secret();
+ const device: Device = { id: randomUUID(), origin: pair!.origin, createdAt: new Date().toISOString(), hash: hash(credential), bridgeId: this.opts.bootstrap().bridgeId, remember: pair!.remember };
+ this.devices.set(device.id, device);
+ try { if (device.remember) this.persist(); } catch (error) { this.devices.delete(device.id); throw error; }
+ pair!.credential = credential;
+ }
+ pair!.status = state; this.send(res, 200, { ok: true }); return;
+ }
+ if (path === '/api/browser/devices' && req.method === 'GET') {
+ this.send(res, 200, { devices: [...this.devices.values()].map(({ id, origin, createdAt }) => ({ id, origin, createdAt })) }); return;
+ }
+ const deletion = /^\/api\/browser\/devices\/([^/]+)$/.exec(path);
+ if (deletion && req.method === 'DELETE') {
+ const previous = this.devices.get(deletion[1]);
+ this.devices.delete(deletion[1]);
+ try { if (previous?.remember) this.persist(); } catch (error) { this.devices.set(previous!.id, previous!); throw error; }
+ for (const wake of [...this.waiters.keys()]) wake();
+ this.opts.onRevoke?.(); this.send(res, 200, { ok: true }); return;
+ }
+ fail(404, 'not_found', 'Unknown admin route');
+ }
+ if (path === '/api/browser/pairings' && req.method === 'POST') {
+ const recent = (this.rates.get(origin) ?? []).filter(t => Date.now() - t < 60_000);
+ this.rates.set(origin, recent);
+ if (recent.length >= 10) fail(429, 'pairing_rate_limit', 'Too many pairing requests');
+ recent.push(Date.now());
+ if (Date.now() >= this.windowExpires) fail(409, 'window_closed', 'Run ftown-bridge pair to open approval');
+ const body = await this.body(req);
+ if (Date.now() >= this.windowExpires) fail(409, 'window_closed', 'Pairing window expired');
+ if (typeof body.remember !== 'boolean') fail(400, 'invalid_pairing', 'remember must be boolean');
+ if (this.pairings.size >= 5) fail(429, 'pairing_limit', 'Too many pairing requests');
+ const pollToken = secret();
+ let code: string;
+ do { code = String(randomInt(1_000_000)).padStart(6, '0'); } while ([...this.pairings.values()].some(p => p.code === code));
+ const pair: Pairing = { id: randomUUID(), code, origin, remember: body.remember as boolean, expiresAt: new Date(Math.min(this.windowExpires, Date.now() + WINDOW_MS)).toISOString(), pollHash: hash(pollToken), status: 'pending' };
+ this.pairings.set(pair.id, pair);
+ this.send(res, 201, { id: pair.id, code: pair.code, pollToken, expiresAt: pair.expiresAt }); return;
+ }
+ const poll = /^\/api\/browser\/pairings\/([^/]+)$/.exec(path);
+ if (poll && req.method === 'GET') {
+ const pair = this.pairings.get(poll[1]);
+ if (!pair || pair.origin !== origin || !bearer || pair.pollHash !== hash(bearer)) fail(401, 'unauthorized', 'Invalid pairing credential');
+ this.send(res, 200, { status: pair!.status, ...(pair!.credential ? { credential: pair!.credential } : {}) }); return;
+ }
+ const device = this.device(bearer, origin);
+ if (!device) fail(401, 'unauthorized', 'Pair this browser first');
+ if (path === '/api/browser/bootstrap' && req.method === 'POST') {
+ await this.body(req);
+ if (!this.authorize(bearer, origin)) fail(401, 'unauthorized', 'Browser credential revoked');
+ this.send(res, 200, { ...this.opts.bootstrap(), localNonce: bearer }); return;
+ }
+ if (path === '/api/browser/commands' && req.method === 'POST') {
+ const body = await this.body(req);
+ if (typeof body.requestId !== 'string' || !body.requestId || body.requestId.length > 128 || typeof body.type !== 'string' || !commandTypes.has(body.type) || !object(body.payload)) fail(400, 'invalid_command', 'Invalid command envelope');
+ if (!this.authorize(bearer, origin)) fail(401, 'unauthorized', 'Browser credential revoked');
+ requestId = body.requestId as string;
+ const payload = body.payload as Record;
+ if (payload.bridgeId !== undefined && payload.bridgeId !== this.opts.bootstrap().bridgeId) fail(403, 'wrong_bridge', 'Command targets another bridge');
+ const key = `${device!.id}:${requestId}`;
+ const serialized = canonical(body);
+ let cached = this.commands.get(key);
+ if (cached && cached.body !== serialized) fail(409, 'request_conflict', 'Request ID was used for a different command');
+ if (!cached) {
+ if (this.commands.size >= 1000) fail(429, 'command_limit', 'Command cache is full; wait before issuing new commands');
+ const entry: Cached = { body: serialized, result: Promise.resolve(undefined as unknown as CommandResponse) };
+ entry.result = Promise.resolve().then(() => this.opts.execute(body as unknown as Command)).catch(() => ({ requestId, success: false, error: 'Local command failed' })).finally(() => { entry.completedAt = Date.now(); });
+ this.commands.set(key, entry); cached = entry;
+ }
+ this.send(res, 200, await cached!.result); return;
+ }
+ if (path === '/api/browser/events' && req.method === 'GET') {
+ const raw = url.searchParams.get('cursor') ?? '0';
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) fail(400, 'invalid_cursor', 'Invalid cursor');
+ const cursor = Number(raw);
+ const reply = () => {
+ const reset = cursor > this.sequence || cursor < (this.events[0]?.sequence ?? this.sequence + 1) - 1;
+ return { cursor: this.sequence, reset, events: this.events.filter(e => reset || e.sequence > cursor).map(({ channel, data }) => ({ channel, data })) };
+ };
+ if (cursor !== this.sequence) { this.send(res, 200, reply()); return; }
+ if ([...this.waiters.values()].filter(id => id === device!.id).length >= 2) fail(429, 'poll_limit', 'Too many pending event requests');
+ await new Promise(resolve => {
+ const wake = () => { clearTimeout(timer); this.waiters.delete(wake); res.off('close', wake); resolve(); };
+ const timer = setTimeout(wake, 20_000); timer.unref();
+ this.waiters.set(wake, device!.id); res.once('close', wake);
+ });
+ if (!this.authorize(bearer, origin)) fail(401, 'unauthorized', 'Browser credential revoked');
+ this.send(res, 200, reply()); return;
+ }
+ fail(404, 'not_found', 'Not found');
+ } catch (error) {
+ const known = error instanceof HttpError;
+ this.send(res, known ? error.status : 503, { error: known ? error.message : 'Local bridge unavailable', code: known ? error.code : 'unavailable', requestId });
+ }
+ }
+ publish(channel: string, data: unknown): void {
+ if (this.closed) return;
+ // Clone publications so later caller mutations cannot alter history or byte accounting.
+ const serialized = JSON.stringify({ channel, data });
+ const bytes = Buffer.byteLength(serialized);
+ const clone = JSON.parse(serialized) as { channel: string; data: unknown };
+ this.sequence++;
+ if (bytes > 2 * 1024 * 1024) { this.events = []; this.eventBytes = 0; }
+ else {
+ this.events.push({ sequence: this.sequence, ...clone, bytes }); this.eventBytes += bytes;
+ while (this.events.length > 256 || this.eventBytes > 2 * 1024 * 1024) this.eventBytes -= this.events.shift()!.bytes;
+ }
+ for (const wake of [...this.waiters.keys()]) wake();
+ }
+ close(): void {
+ this.closed = true; this.windowExpires = 0; this.pairings.clear();
+ for (const wake of [...this.waiters.keys()]) wake();
+ this.devices.clear(); this.events = []; this.commands.clear(); this.rates.clear();
+ }
+}
diff --git a/bridge/src/centrifugo-client.ts b/bridge/src/centrifugo-client.ts
index e93a787..9fff27f 100644
--- a/bridge/src/centrifugo-client.ts
+++ b/bridge/src/centrifugo-client.ts
@@ -78,14 +78,17 @@ export class CentrifugoClient {
private readonly subscriptions: Map = new Map();
private readonly onReconnect?: () => void | Promise;
private hasConnected = false;
+ private readonly disabled: boolean;
+ private readonly localListeners = new Set<(channel: string, data: unknown) => void>();
constructor(
url: string,
token: string,
getToken: () => Promise,
- opts?: { onReconnect?: () => void | Promise },
+ opts?: { onReconnect?: () => void | Promise; disabled?: boolean },
) {
this.onReconnect = opts?.onReconnect;
+ this.disabled = opts?.disabled ?? false;
this.client = new Centrifuge(url, {
token,
getToken,
@@ -117,9 +120,9 @@ export class CentrifugoClient {
this.client.on('disconnected', (ctx) => {
console.log(`[Centrifugo] Disconnected: code=${ctx.code} reason=${ctx.reason}`);
- if (ctx.code === 3) {
+ if (!this.disabled && ctx.code === 3) {
console.log(`[Centrifugo] Reconnecting after message size limit disconnect...`);
- setTimeout(() => this.client.connect(), 1000);
+ setTimeout(() => this.connect(), 1000);
}
});
@@ -128,6 +131,35 @@ export class CentrifugoClient {
});
}
+ /** Observe bridge publications without depending on the cloud transport. */
+ onLocalPublication(listener: (channel: string, data: unknown) => void): () => void {
+ this.localListeners.add(listener);
+ return () => { this.localListeners.delete(listener); };
+ }
+
+ private publish(channel: string, data: Record): Promise {
+ for (const listener of this.localListeners) {
+ try {
+ listener(channel, data);
+ } catch (err) {
+ console.error('[Centrifugo] Local publication listener failed:', err);
+ }
+ }
+ // Mutations have already committed to the local store. Cloud delivery must
+ // not delay their acknowledgement or make a successful mutation look failed.
+ // Disconnected updates are reconciled by the existing reconnect snapshot.
+ if (!this.disabled && this.client.state === 'connected') {
+ try {
+ void this.client.publish(channel, data).catch((err: unknown) => {
+ console.error(`[Centrifugo] Failed to publish to ${channel}:`, err);
+ });
+ } catch (err) {
+ console.error(`[Centrifugo] Failed to publish to ${channel}:`, err);
+ }
+ }
+ return Promise.resolve();
+ }
+
private async runOnReconnect(): Promise {
if (!this.onReconnect) return;
try {
@@ -138,6 +170,7 @@ export class CentrifugoClient {
}
connect(): void {
+ if (this.disabled) return;
this.client.connect();
}
@@ -166,7 +199,7 @@ export class CentrifugoClient {
async publishSessionUpdate(userId: string, session: Session): Promise {
const channel = `sessions:updates#${userId}`;
try {
- await this.client.publish(channel, {
+ await this.publish(channel, {
type: 'session_update',
session: toWireSession(session),
timestamp: new Date().toISOString(),
@@ -178,6 +211,7 @@ export class CentrifugoClient {
}
subscribeToLoops(userId: string): void {
+ if (this.disabled) return;
const channel = `loops:updates#${userId}`;
const sub = this.client.newSubscription(channel);
sub.subscribe();
@@ -188,7 +222,7 @@ export class CentrifugoClient {
const channel = `loops:updates#${userId}`;
// Loop carries no secret env-like field, so — unlike sessions — nothing is stripped.
try {
- await this.client.publish(channel, { type: 'loop_update', loop, timestamp: new Date().toISOString() });
+ await this.publish(channel, { type: 'loop_update', loop, timestamp: new Date().toISOString() });
} catch (err) {
console.error(`[Centrifugo] Failed to publish loop update to ${channel}:`, err);
throw err;
@@ -198,7 +232,7 @@ export class CentrifugoClient {
async publishLoopRemoved(userId: string, loopId: string): Promise {
const channel = `loops:updates#${userId}`;
try {
- await this.client.publish(channel, { type: 'loop_removed', loopId, timestamp: new Date().toISOString() });
+ await this.publish(channel, { type: 'loop_removed', loopId, timestamp: new Date().toISOString() });
} catch (err) {
console.error(`[Centrifugo] Failed to publish loop removed to ${channel}:`, err);
throw err;
@@ -218,7 +252,7 @@ export class CentrifugoClient {
// with a 10000-entry history, so each reconnect replayed that backlog and
// starved the app-level ping. Removed — we publish directly.
try {
- await this.client.publish(channel, truncateData({ type: 'output', data }));
+ await this.publish(channel, truncateData({ type: 'output', data }));
} catch (err) {
console.error(`[Centrifugo] Failed to publish terminal data to ${channel}:`, err);
}
@@ -231,6 +265,7 @@ export class CentrifugoClient {
onResize: TerminalResizeHandler,
onInit?: TerminalInitHandler,
): void {
+ if (this.disabled) return;
const channel = `terminal-input:${sessionId}#${userId}`;
if (this.subscriptions.has(channel)) {
return;
@@ -265,6 +300,7 @@ export class CentrifugoClient {
}
subscribeToCommands(userId: string, handler: CommandHandler, onDirectCommand?: DirectCommandHandler): void {
+ if (this.disabled) return;
const channel = `commands:rpc#${userId}`;
const existingSub = this.subscriptions.get(channel);
@@ -331,6 +367,7 @@ export class CentrifugoClient {
// `info` claim, not subscription data — Centrifugo ignores subscribe data
// without a subscribe proxy; presence exposes conn_info only.
joinBridgesChannel(userId: string, bridgeId: string): void {
+ if (this.disabled) return;
const channel = `bridges:presence#${userId}`;
const presenceInfo: BridgePresenceInfo = {
@@ -358,7 +395,7 @@ export class CentrifugoClient {
async publishTerminalScreen(userId: string, sessionId: string, raw: string): Promise {
const channel = `terminal:${sessionId}#${userId}`;
try {
- await this.client.publish(channel, {
+ await this.publish(channel, {
type: 'screen_dump',
raw,
timestamp: new Date().toISOString(),
@@ -370,16 +407,10 @@ export class CentrifugoClient {
async publishHookEvent(userId: string, sessionId: string, event: Record): Promise {
const channel = `events:${sessionId}#${userId}`;
- if (!this.subscriptions.has(channel)) {
- const sub = this.client.newSubscription(channel);
- this.subscriptions.set(channel, sub);
- await new Promise((resolve) => {
- sub.on('subscribed', () => resolve());
- sub.subscribe();
- });
- }
+ // The events namespace permits client publishing without subscribing.
+ // Waiting for a subscription here deadlocks local hooks during cloud loss.
try {
- await this.client.publish(channel, truncateData(event));
+ await this.publish(channel, truncateData(event));
} catch (err) {
console.error(`[Centrifugo] Failed to publish hook event to ${channel}:`, err);
}
@@ -389,7 +420,7 @@ export class CentrifugoClient {
async publishSignal(userId: string, msg: SignalMessage): Promise {
const channel = `commands:rpc#${userId}`;
try {
- await this.client.publish(channel, msg as unknown as Record);
+ await this.publish(channel, msg as unknown as Record);
} catch (err) {
console.error(`[Centrifugo] Failed to publish signal to ${channel}:`, err);
}
@@ -398,7 +429,7 @@ export class CentrifugoClient {
async publishCommandResponse(userId: string, response: CommandResponse): Promise {
const channel = `commands:rpc#${userId}`;
try {
- await this.client.publish(channel, truncateData({
+ await this.publish(channel, truncateData({
type: 'command_response',
response: response as unknown as Record,
timestamp: new Date().toISOString(),
diff --git a/bridge/src/centrifugo-local-publications.test.ts b/bridge/src/centrifugo-local-publications.test.ts
new file mode 100644
index 0000000..60d0685
--- /dev/null
+++ b/bridge/src/centrifugo-local-publications.test.ts
@@ -0,0 +1,92 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { CentrifugoClient } from './centrifugo-client.js';
+import type { Session, Loop } from './types.js';
+
+function fixture(disabled = false) {
+ const client = new CentrifugoClient('ws://127.0.0.1:1/connection/websocket', '', async () => '', { disabled });
+ const transport = (client as unknown as { client: {
+ state: string;
+ publish: (channel: string, data: unknown) => Promise;
+ connect: () => void;
+ newSubscription: () => never;
+ } }).client;
+ return { client, transport };
+}
+
+test('disabled local mode never connects or subscribes, and publishes local events with session secrets removed', async () => {
+ const { client, transport } = fixture(true);
+ transport.connect = () => { throw new Error('unexpected cloud connect'); };
+ transport.newSubscription = () => { throw new Error('unexpected cloud subscription'); };
+ transport.publish = () => { throw new Error('unexpected cloud publish'); };
+ client.connect();
+ client.subscribeToSessions('local');
+ client.subscribeToLoops('local');
+ client.subscribeToCommands('local', () => {});
+ client.subscribeToTerminalInput('local', 's', () => {}, () => {});
+ client.joinBridgesChannel('local', 'b');
+ const events: Array<{ channel: string; data: unknown }> = [];
+ const unsubscribe = client.onLocalPublication((channel, data) => events.push({ channel, data }));
+ const session = { id: 's', env: { TOKEN: 'secret' } } as unknown as Session;
+ await client.publishSessionUpdate('local', session);
+ await client.publishLoopUpdate('local', { id: 'l' } as Loop);
+ await client.publishLoopRemoved('local', 'l');
+ await client.publishTerminalData('local', 's', 'hello');
+ await client.publishTerminalScreen('local', 's', 'screen');
+ await client.publishHookEvent('local', 's', { type: 'hook', data: 'event' });
+ await client.publishCommandResponse('local', { requestId: 'r', success: true });
+ assert.deepEqual(events.map((event) => event.channel), [
+ 'sessions:updates#local', 'loops:updates#local', 'loops:updates#local',
+ 'terminal:s#local', 'terminal:s#local', 'events:s#local', 'commands:rpc#local',
+ ]);
+ assert.equal((events[0].data as { session: Session }).session.env, undefined);
+ assert.equal(session.env?.TOKEN, 'secret');
+ assert.equal((events[6].data as { response: { requestId: string } }).response.requestId, 'r');
+ unsubscribe();
+ await client.publishLoopRemoved('local', 'another');
+ assert.equal(events.length, 7);
+});
+
+test('disconnected cloud does not queue or delay local hook and command acknowledgements', async () => {
+ const { client, transport } = fixture();
+ transport.publish = () => { throw new Error('unexpected disconnected publish'); };
+ transport.newSubscription = () => { throw new Error('unexpected hook subscription'); };
+ const events: unknown[] = [];
+ client.onLocalPublication((_channel, data) => events.push(data));
+ await client.publishHookEvent('user', 's', { type: 'hook' });
+ await client.publishCommandResponse('user', { requestId: 'r', success: true });
+ assert.equal(events.length, 2);
+});
+
+test('cloud publish which never acknowledges cannot stall an already committed local mutation', async () => {
+ const { client, transport } = fixture();
+ transport.state = 'connected';
+ const remote: unknown[] = [];
+ transport.publish = (channel, data) => {
+ remote.push({ channel, data });
+ return new Promise(() => {});
+ };
+ const local: unknown[] = [];
+ client.onLocalPublication((channel, data) => local.push({ channel, data }));
+ let timer: ReturnType | undefined;
+ try {
+ await Promise.race([
+ client.publishLoopRemoved('user', 'l'),
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('publication stalled')), 100); }),
+ ]);
+ } finally {
+ clearTimeout(timer);
+ }
+ assert.deepEqual(remote, local);
+ assert.equal(local.length, 1);
+});
+
+test('rejected cloud delivery does not reject a committed local mutation', async () => {
+ const { client, transport } = fixture();
+ transport.state = 'connected';
+ transport.publish = async () => { throw new Error('cloud disconnected during publish'); };
+ const local: unknown[] = [];
+ client.onLocalPublication((_channel, data) => local.push(data));
+ await assert.doesNotReject(client.publishLoopRemoved('user', 'l'));
+ assert.equal(local.length, 1);
+});
diff --git a/bridge/src/direct-transport/loopback-server.ts b/bridge/src/direct-transport/loopback-server.ts
index f3747e2..96184e2 100644
--- a/bridge/src/direct-transport/loopback-server.ts
+++ b/bridge/src/direct-transport/loopback-server.ts
@@ -39,8 +39,7 @@ function constantTimeEq(a: string, b: string): boolean {
/** Same loopback host guard the HTTP handler applies (defense in depth). */
function isLoopbackHost(hostHeader: string | undefined): boolean {
if (!hostHeader) return false;
- const host = hostHeader.split(':')[0];
- return host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
+ return /^(127\.0\.0\.1|localhost|\[::1\])(?::[0-9]{1,5})?$/.test(hostHeader);
}
function isLocalhostOrigin(origin: string): boolean {
@@ -52,6 +51,8 @@ export interface LoopbackPeerServerOptions {
bridgeId: string;
/** Per-process nonce; upgrade requires `?nonce=` to match this exactly. */
nonce: string;
+ /** Additional origin-bound browser credentials issued by local approval. */
+ authorize?: (token: string, origin: string) => boolean;
/** Exact non-localhost origins allowed to upgrade (typically the api-url origin). */
allowedOrigins: string[];
/** `input` frames feed here (same sink as terminal-input / DataChannel). */
@@ -216,6 +217,7 @@ class LoopbackPeer {
export class LoopbackPeerServer {
readonly bridgeId: string;
private readonly nonce: string;
+ private readonly authorize?: (token: string, origin: string) => boolean;
private readonly allowedOrigins: string[];
private readonly onInputCb: (sessionId: string, data: string) => void;
private readonly onResizeCb: (sessionId: string, cols: number, rows: number) => void;
@@ -228,6 +230,7 @@ export class LoopbackPeerServer {
constructor(options: LoopbackPeerServerOptions) {
this.bridgeId = options.bridgeId;
this.nonce = options.nonce;
+ this.authorize = options.authorize;
this.allowedOrigins = options.allowedOrigins;
this.onInputCb = options.onInput;
this.onResizeCb = options.onResize;
@@ -257,6 +260,11 @@ export class LoopbackPeerServer {
return false;
}
+ disconnectPeers(): void {
+ for (const peer of this.peers) peer.close();
+ this.peers.clear();
+ }
+
closeAll(): void {
if (this.httpServer && this.upgradeHandler) {
this.httpServer.removeListener('upgrade', this.upgradeHandler);
@@ -308,7 +316,8 @@ export class LoopbackPeerServer {
this.reject(socket, 403, 'Forbidden');
return;
}
- if (!this.nonceMatches(url.searchParams.get('nonce'))) {
+ const token = url.searchParams.get('nonce');
+ if (!this.nonceMatches(token) && !(token && req.headers.origin && this.authorize?.(token, req.headers.origin))) {
this.reject(socket, 403, 'Forbidden');
return;
}
diff --git a/bridge/src/index.ts b/bridge/src/index.ts
index d882083..45405c9 100644
--- a/bridge/src/index.ts
+++ b/bridge/src/index.ts
@@ -19,6 +19,9 @@ import { ProcessRunner } from './claude-runner.js';
import { SessionStore } from './session-store.js';
import { MailStore } from './mail-store.js';
import { LocalApiServer } from './local-api-server.js';
+import { BrowserAccess } from './browser-access.js';
+import { registerLocalBrowserCommands } from './local-pair-cli.js';
+import type { CommandResponse } from './types.js';
import { TerminalManager } from './terminal-manager.js';
import { installClaudeHooks } from './hook-installer.js';
import { installCursorHooks } from './cursor-hook-installer.js';
@@ -99,6 +102,7 @@ const program = new Commander();
program
.name('ftown-bridge')
.description('ftown orchestrator bridge for Centrifugo')
+ .option('--local', 'Local browser control only: no cloud account or connection')
.option('--solo', 'Single-port LAN deployment: no account service, managed hub + panel children, key-based auth')
.option('--port ', 'Public port for --solo front (default: see DEFAULT_SOLO_PORT)')
.option('--rotate-key', 'With --solo: regenerate the access key, print the new banner, exit (offline)')
@@ -106,7 +110,8 @@ program
.option('--api-url ', 'ftown UI API URL', DEFAULT_API_URL)
.option('--data-dir ', 'Directory for session data (default: ~/.ftown/data)')
.option('--bridge-id ', 'Bridge instance ID (default: persisted per data dir)')
- .action(async (opts: { solo?: boolean; port?: string; rotateKey?: boolean; token?: string; apiUrl: string; dataDir?: string; bridgeId?: string }) => {
+ .action(async (opts: { local?: boolean; solo?: boolean; port?: string; rotateKey?: boolean; token?: string; apiUrl: string; dataDir?: string; bridgeId?: string }) => {
+ if (opts.local && opts.solo) program.error('--local and --solo cannot be combined');
const apiUrl = new URL(opts.apiUrl);
const isLocalHost =
apiUrl.hostname === 'localhost' ||
@@ -179,7 +184,16 @@ program
const localApiServer = new LocalApiServer();
const apiToken = randomBytes(32).toString('hex');
localApiServer.setAuthToken(apiToken);
- const hookPort = await localApiServer.start();
+ // Reuse the local port so remembered browsers survive restarts. If another
+ // process took it, the server chooses an available port and pair prints it.
+ const localPortPath = join(dataDir, 'local-api-port');
+ let preferredLocalPort = 0;
+ try {
+ const saved = Number(readFileSync(localPortPath, 'utf8').trim());
+ if (Number.isInteger(saved) && saved > 0 && saved <= 65535) preferredLocalPort = saved;
+ } catch { /* first start */ }
+ const hookPort = await localApiServer.start(preferredLocalPort);
+ writeFileSync(localPortPath, `${hookPort}\n`, { mode: 0o600 });
console.log(`[Bridge] Local API server started on port ${hookPort}`);
const localNonce = randomBytes(16).toString('hex');
@@ -387,7 +401,9 @@ program
};
let auth: BridgeAuthResponse;
- if (solo) {
+ if (opts.local) {
+ auth = { userId: 'local', token: '', refreshToken: '', centrifugoUrl: 'ws://127.0.0.1/disabled' };
+ } else if (solo) {
// Solo mode: identity is synthesized locally (contract S2/S10). No
// refresh token exists; getToken() mints a fresh hub JWT on demand.
// The bridge's own connection token carries `info` (matching the cloud
@@ -418,9 +434,9 @@ program
auth = await onboard();
}
const currentRefreshToken = auth.refreshToken;
- if (!solo) persistRefreshToken(currentRefreshToken);
+ if (!solo && !opts.local) persistRefreshToken(currentRefreshToken);
- const tokenRefresher = solo
+ const tokenRefresher = solo || opts.local
? null
: new RotatingTokenRefresher({
initialRefreshToken: currentRefreshToken,
@@ -477,6 +493,7 @@ program
const runner = new ProcessRunner();
const centrifugo = new CentrifugoClient(centrifugoUrl, auth.token, getToken, {
+ disabled: opts.local,
// On a transport reconnect, re-publish the session snapshot. The UI does
// not re-request its list on reconnect, so without this its session list
// goes stale/empty after a Centrifugo blip until a page reload.
@@ -535,11 +552,13 @@ program
// over TCP on the existing 127.0.0.1 local API server. Bypasses VPN/endpoint
// filters that kill UDP hairpin. Input/resize/attach feed the SAME sinks as
// the WebRTC peer manager; upgrades are gated on the per-process nonce (L1/L2).
+ let browserAccess: BrowserAccess | undefined;
let apiOrigin = '';
try { apiOrigin = new URL(opts.apiUrl).origin; } catch { /* leave empty; only localhost origins accepted */ }
const loopbackServer = new LoopbackPeerServer({
bridgeId,
nonce: localNonce,
+ authorize: (token, origin) => browserAccess?.authorize(token, origin) ?? false,
allowedOrigins: apiOrigin ? [apiOrigin] : [],
onInput: (sid, data) => { runner.write(sid, data); },
onResize: (sid, cols, rows) => { handleClientResize(sid, cols, rows); },
@@ -802,6 +821,7 @@ program
bridgePointerPath,
JSON.stringify({
port: hookPort,
+ apiUrl: opts.apiUrl,
token: apiToken,
bridgeId,
pid: process.pid,
@@ -817,11 +837,12 @@ program
}
const cleanupPointer = (): void => {
- try { unlinkSync(bridgePointerPath); } catch { /* already gone */ }
+ try {
+ const pointer = JSON.parse(readFileSync(bridgePointerPath, 'utf8'));
+ if (pointer.pid === process.pid && pointer.token === apiToken) unlinkSync(bridgePointerPath);
+ } catch { /* already gone or owned by a newer bridge */ }
};
process.on('exit', cleanupPointer);
- process.on('SIGINT', () => { cleanupPointer(); process.exit(0); });
- process.on('SIGTERM', () => { cleanupPointer(); process.exit(0); });
function publishScreenDump(sid: string): void {
// Phase 1: viewport-only dump (~rows*cols bytes) for instant render.
@@ -866,6 +887,26 @@ program
});
});
+ let ready = false;
+ browserAccess = new BrowserAccess({
+ dataDir,
+ allowedOrigins: apiOrigin ? [apiOrigin] : [],
+ bootstrap: () => ({ version: 1, userId, bridgeId, hostname: osHostname(), localPort: hookPort, localNonce: '' }),
+ execute: async (command) => {
+ if (!ready) return { requestId: command.requestId, success: false, error: 'Bridge is starting; try again shortly' };
+ let result: CommandResponse | undefined;
+ await createCommandHandler({
+ bridgeId, sessionController, loopController,
+ publishCommandResponse: async (response) => { result = response; },
+ })(command);
+ return result ?? { requestId: command.requestId, success: false, error: 'Command was not handled' };
+ },
+ onRevoke: () => loopbackServer.disconnectPeers(),
+ });
+ localApiServer.setBrowserAccess(browserAccess);
+ centrifugo.onLocalPublication((channel, data) => browserAccess?.publish(channel, data));
+ console.log(`[Bridge] Local browser access: run ftown-bridge pair${opts.dataDir ? ` --data-dir ${JSON.stringify(dataDir)}` : ''}`);
+
const handleCommand = createCommandHandler({
bridgeId,
sessionController,
@@ -890,7 +931,6 @@ program
centrifugo.subscribeToSessions(userId);
centrifugo.subscribeToLoops(userId);
- let ready = false;
centrifugo.subscribeToCommands(userId, (command) => {
if (!ready) return;
handleCommand(command).catch((err) => {
@@ -950,4 +990,8 @@ program
process.on('SIGTERM', shutdown);
});
-program.parse();
+registerLocalBrowserCommands(program);
+program.parseAsync().catch((err: unknown) => {
+ console.error(err instanceof Error ? err.message : String(err));
+ process.exit(1);
+});
diff --git a/bridge/src/local-api-server.ts b/bridge/src/local-api-server.ts
index 5476550..6d2bdef 100644
--- a/bridge/src/local-api-server.ts
+++ b/bridge/src/local-api-server.ts
@@ -1,6 +1,7 @@
+import type { BrowserAccess } from './browser-access.js';
import { createServer } from 'node:http';
import { EventEmitter } from 'node:events';
-import { timingSafeEqual } from 'node:crypto';
+import { randomUUID, timingSafeEqual } from 'node:crypto';
import type { Server, IncomingMessage, ServerResponse } from 'node:http';
@@ -146,9 +147,8 @@ function constantTimeEq(a: string, b: string): boolean {
function isLoopbackHost(hostHeader: string | undefined, expectedPort: number): boolean {
if (!hostHeader) return false;
- const [host, port] = hostHeader.split(':');
- if (port && parseInt(port, 10) !== expectedPort) return false;
- return host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
+ const match = /^(127\.0\.0\.1|localhost|\[::1\])(?::([0-9]{1,5}))?$/.exec(hostHeader);
+ return !!match && (!match[2] || Number(match[2]) === expectedPort);
}
function extractBearer(req: IncomingMessage): string | null {
@@ -171,6 +171,7 @@ function getQueryInt(url: URL, name: string, defaultValue: number): number {
export class LocalApiServer extends EventEmitter {
private server: Server | null = null;
+ private browserAccess: BrowserAccess | null = null;
private store: SessionStore | null = null;
private runner: ProcessRunner | null = null;
private centrifugo: CentrifugoClient | null = null;
@@ -184,6 +185,10 @@ export class LocalApiServer extends EventEmitter {
private loopController: LoopController | null = null;
private sessionController: SessionController | null = null;
+ setBrowserAccess(access: BrowserAccess): void {
+ this.browserAccess = access;
+ }
+
setAuthToken(token: string): void {
this.authToken = token;
}
@@ -264,17 +269,22 @@ export class LocalApiServer extends EventEmitter {
return this.sessionController;
}
- async start(): Promise {
+ async start(preferredPort = 0): Promise {
return new Promise((resolve, reject) => {
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
this.handleRequest(req, res);
});
- server.on('error', (err: Error) => {
- console.error('[LocalApiServer] Server error:', err.message);
+ server.on('error', (err: NodeJS.ErrnoException) => {
+ if (err.code === 'EADDRINUSE' && preferredPort !== 0 && !this.server) {
+ preferredPort = 0;
+ server.listen(0, '127.0.0.1');
+ return;
+ }
+ reject(err);
});
- server.listen(0, '127.0.0.1', () => {
+ server.once('listening', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
@@ -285,6 +295,7 @@ export class LocalApiServer extends EventEmitter {
console.log(`[LocalApiServer] Listening on port ${address.port}`);
resolve(address.port);
});
+ server.listen(preferredPort, '127.0.0.1');
});
}
@@ -298,6 +309,7 @@ export class LocalApiServer extends EventEmitter {
}
stop(): void {
+ this.browserAccess?.close();
this.mail.stop();
if (this.server) {
this.server.close();
@@ -311,15 +323,31 @@ export class LocalApiServer extends EventEmitter {
return;
}
+ let url: URL;
+ try { url = new URL(req.url ?? '/', `http://${req.headers.host}`); }
+ catch { jsonResponse(res, 400, { error: 'Invalid request URL' }); return; }
+ const path = url.pathname;
+ if (path.startsWith('/api/browser/')) {
+ if (!this.browserAccess) {
+ jsonResponse(res, 503, { error: 'Local browser access is starting', code: 'NOT_READY', requestId: randomUUID() });
+ return;
+ }
+ const presented = extractBearer(req);
+ const admin = req.headers.origin === undefined && !!this.authToken && !!presented &&
+ constantTimeEq(presented, this.authToken);
+ void this.browserAccess.handle(req, res, admin).catch(() => {
+ if (!res.headersSent) jsonResponse(res, 500, { error: 'Internal server error', code: 'INTERNAL_ERROR', requestId: randomUUID() });
+ else res.end();
+ });
+ return;
+ }
+
const origin = req.headers.origin;
if (typeof origin === 'string' && origin && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(origin)) {
jsonResponse(res, 403, { error: 'Forbidden origin' });
return;
}
- const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
- const path = url.pathname;
-
if (this.authToken) {
const presented = extractBearer(req);
if (!presented || !constantTimeEq(presented, this.authToken)) {
diff --git a/bridge/src/local-browser-integration.test.ts b/bridge/src/local-browser-integration.test.ts
new file mode 100644
index 0000000..0390c0e
--- /dev/null
+++ b/bridge/src/local-browser-integration.test.ts
@@ -0,0 +1,187 @@
+import assert from 'node:assert/strict';
+import { once } from 'node:events';
+import { request as httpRequest } from 'node:http';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { randomUUID } from 'node:crypto';
+import { test } from 'node:test';
+import WebSocket from 'ws';
+import { BrowserAccess } from './browser-access.js';
+import { LocalApiServer } from './local-api-server.js';
+import { LoopbackPeerServer } from './direct-transport/loopback-server.js';
+import { DIRECT_PROTOCOL_VERSION } from './direct-transport/contract.js';
+import { SessionStore } from './session-store.js';
+import { SessionController } from './session-controller.js';
+import { LoopController } from './loop-controller.js';
+import { CentrifugoClient } from './centrifugo-client.js';
+import { createCommandHandler } from './command-rpc.js';
+import { removeFtownSession } from './remove-ftown-session.js';
+import type { ProcessRunner } from './claude-runner.js';
+import type { Command, CommandResponse } from './types.js';
+
+const ORIGIN = 'https://ftown.ia.br';
+const BRIDGE = 'integration-bridge';
+const ADMIN = 'integration-admin-token';
+async function harness() {
+ const dataDir = mkdtempSync(join(tmpdir(), 'local-browser-integration-'));
+ const store = new SessionStore(dataDir);
+ const launched: Array<{ id: string; command: string }> = [];
+ const stopped: string[] = [];
+ const inputs: Array<[string, string]> = [];
+ const runner = {
+ run: (id: string, command: string) => { launched.push({ id, command }); },
+ stop: (id: string) => { stopped.push(id); return true; },
+ getPreferredRuntime: () => 'pty',
+ isRunning: (id: string) => launched.some(s => s.id === id) && !stopped.includes(id),
+ } as unknown as ProcessRunner;
+ // Real client stays disconnected throughout; never call connect or rely on Fly.
+ const centrifugo = new CentrifugoClient('ws://127.0.0.1:1/connection/websocket', '', async () => '');
+ const localApi = new LocalApiServer();
+ localApi.setAuthToken(ADMIN);
+ localApi.setDependencies(store, runner, centrifugo, 'local-user');
+ const port = await localApi.start();
+ const factory = { store, runner, centrifugo, userId: 'local-user', bridgeId: BRIDGE, hookPort: port, hookToken: ADMIN, notifyScriptPath: '', wireTerminalInput: () => {} };
+ localApi.setSessionFactory(factory);
+ const sessionController = new SessionController({ store, runner, sessionFactory: factory,
+ publishSessionUpdate: session => centrifugo.publishSessionUpdate('local-user', session),
+ removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId: 'local-user' }, id, options),
+ });
+ const loopController = new LoopController({ bridgeId: BRIDGE, scheduler: { kick() {}, onLoopDeleted() {} }, isSessionRunning: id => runner.isRunning(id), publishLoopUpdate: loop => centrifugo.publishLoopUpdate('local-user', loop), publishLoopRemoved: id => centrifugo.publishLoopRemoved('local-user', id), listWireSessions: () => store.listSessions(), loadTerminalLog: id => store.loadTerminalLog(id) });
+ let loopback: LoopbackPeerServer;
+ const access = new BrowserAccess({ dataDir, allowedOrigins: [ORIGIN],
+ bootstrap: () => ({ version: 1, userId: 'local-user', bridgeId: BRIDGE, hostname: 'integration', localPort: port, localNonce: 'legacy-nonce' }),
+ execute: async command => {
+ let response: CommandResponse | undefined;
+ await createCommandHandler({ bridgeId: BRIDGE, sessionController, loopController, publishCommandResponse: async value => { response = value; } })(command);
+ if (!response) throw new Error('RPC did not respond');
+ return response;
+ },
+ onRevoke: () => loopback.disconnectPeers(),
+ });
+ localApi.setBrowserAccess(access);
+ const unpublish = centrifugo.onLocalPublication((channel, data) => access.publish(channel, data));
+ loopback = new LoopbackPeerServer({ nonce: 'legacy-nonce', allowedOrigins: [ORIGIN], bridgeId: BRIDGE, authorize: (token, origin) => access.authorize(token, origin), onInput: (id, input) => inputs.push([id, input]), onResize() {}, onAttach: () => 'local screen' });
+ loopback.attach(localApi.getHttpServer()!);
+ async function request(path: string, method = 'GET', body?: unknown, token = '', origin = ORIGIN, extraHeaders: Record = {}) {
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, { method, headers: { ...(origin ? { Origin: origin } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...extraHeaders }, body: body === undefined ? undefined : JSON.stringify(body) });
+ return { status: response.status, body: await response.json() };
+ }
+ async function pair() {
+ assert.equal((await request('/api/browser/window', 'POST', {}, ADMIN, '')).status, 200);
+ const pending = await request('/api/browser/pairings', 'POST', { remember: true });
+ assert.equal(pending.status, 201);
+ const listed = await request('/api/browser/pairings', 'GET', undefined, ADMIN, '');
+ assert.equal(listed.body.pairings[0].code, pending.body.code);
+ assert.equal((await request(`/api/browser/pairings/${pending.body.id}/decision`, 'POST', { approve: true }, ADMIN, '')).status, 200);
+ const approved = await request(`/api/browser/pairings/${pending.body.id}`, 'GET', undefined, pending.body.pollToken);
+ return approved.body.credential as string;
+ }
+ const rpc = (token: string, type: Command['type'], payload: Command['payload'] = {}) => request('/api/browser/commands', 'POST', { type, payload, requestId: randomUUID() }, token);
+ return { port, dataDir, store, access, localApi, loopback, inputs, launched, stopped, request, pair, rpc,
+ close() { unpublish(); access.close(); loopback.closeAll(); localApi.getHttpServer()?.closeAllConnections(); localApi.stop(); rmSync(dataDir, { recursive: true, force: true }); },
+ };
+}
+
+function connect(port: number, nonce: string, origin = ORIGIN): Promise<{ socket?: WebSocket; status?: number }> {
+ return new Promise((resolve, reject) => {
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/ws?nonce=${encodeURIComponent(nonce)}`, { origin });
+ socket.once('open', () => resolve({ socket }));
+ socket.once('error', reject);
+ socket.once('unexpected-response', (_request, response) => { response.resume(); socket.removeListener('error', reject); socket.on('error', () => {}); socket.terminate(); resolve({ status: response.statusCode }); });
+ });
+}
+
+test('local consent controls real session store/controllers and streams updates while cloud is disconnected', { timeout: 10_000 }, async () => {
+ const h = await harness();
+ try {
+ const token = await h.pair();
+ const bootstrap = await h.request('/api/browser/bootstrap', 'POST', {}, token);
+ assert.equal(bootstrap.body.bridgeId, BRIDGE); assert.equal(bootstrap.body.localNonce, token);
+ assert.equal((await h.rpc(token, 'list_sessions')).body.data.sessions.length, 0);
+ const events = h.request('/api/browser/events?cursor=0', 'GET', undefined, token);
+ const created = await h.rpc(token, 'create_session', { shellType: 'shell', command: 'printf local', name: 'Local integration session', workingDir: h.dataDir, bridgeId: BRIDGE });
+ assert.equal(created.status, 200); assert.equal(created.body.success, true);
+ const id = created.body.data.session.id as string;
+ assert.equal(h.launched[0].id, id); assert.equal(h.launched[0].command, 'printf local');
+ assert.equal((await h.store.loadSession(id))?.name, 'Local integration session');
+ const update = await events;
+ assert.equal(update.body.events[0].channel, 'sessions:updates#local-user');
+ assert.equal(update.body.events[0].data.session.id, id);
+ assert.equal((await h.rpc(token, 'list_sessions')).body.data.sessions[0].id, id);
+ // Same controller/store behavior remains exposed to CLI admin endpoints.
+ const cliList = await h.request('/api/sessions', 'GET', undefined, ADMIN, '');
+ assert.equal(cliList.status, 200); assert.equal(cliList.body.sessions[0].id, id);
+ const removalEvents = h.request(`/api/browser/events?cursor=${update.body.cursor}`, 'GET', undefined, token);
+ assert.equal((await h.rpc(token, 'remove_session', { sessionId: id })).body.success, true);
+ assert.equal(await h.store.loadSession(id), null); assert.ok(h.stopped.includes(id));
+ assert.equal((await removalEvents).body.events[0].data.session.status, 'removed');
+ assert.equal((await h.rpc(token, 'list_sessions')).body.data.sessions.length, 0);
+ } finally { h.close(); }
+});
+
+test('approved terminal upgrades work, revocation closes peers and prevents reconnection', { timeout: 10_000 }, async () => {
+ const h = await harness(); let socket: WebSocket | undefined;
+ try {
+ const token = await h.pair();
+ assert.equal((await connect(h.port, 'wrong-token')).status, 403);
+ assert.equal((await connect(h.port, token, 'https://unrelated.example')).status, 403);
+ socket = (await connect(h.port, token)).socket;
+ assert.ok(socket);
+ const hello = once(socket, 'message');
+ socket.send(JSON.stringify({ kind: 'hello', clientId: 'integration-browser', protocolVersion: DIRECT_PROTOCOL_VERSION }));
+ assert.equal(JSON.parse(String((await hello)[0])).kind, 'hello_ack');
+ const screen = once(socket, 'message'); socket.send(JSON.stringify({ kind: 'attach', sessionId: 's' }));
+ assert.equal(JSON.parse(String((await screen)[0])).kind, 'screen');
+ socket.send(JSON.stringify({ kind: 'input', sessionId: 's', data: 'echo test\n' }));
+ // A later ordered frame response proves the preceding input was processed.
+ const ordered = once(socket, 'message'); socket.send(JSON.stringify({ kind: 'attach', sessionId: 's' })); await ordered;
+ assert.deepEqual(h.inputs, [['s', 'echo test\n']]);
+ const devices = await h.request('/api/browser/devices', 'GET', undefined, ADMIN, '');
+ const closed = once(socket, 'close');
+ assert.equal((await h.request(`/api/browser/devices/${devices.body.devices[0].id}`, 'DELETE', undefined, ADMIN, '')).status, 200);
+ await closed;
+ assert.equal((await connect(h.port, token)).status, 403);
+ assert.equal((await h.rpc(token, 'list_sessions')).status, 401);
+ const replacementToken = await h.pair();
+ socket = (await connect(h.port, replacementToken)).socket;
+ assert.ok(socket, 'revocation must preserve the upgrade listener for newly approved browsers');
+ const replacementHello = once(socket, 'message');
+ socket.send(JSON.stringify({ kind: 'hello', clientId: 'replacement-browser', protocolVersion: DIRECT_PROTOCOL_VERSION }));
+ assert.equal(JSON.parse(String((await replacementHello)[0])).kind, 'hello_ack');
+ } finally { socket?.terminate(); h.close(); }
+});
+
+test('hosted origins cannot access legacy admin routes and spoofed Host cannot reach browser routes', async () => {
+ const h = await harness();
+ try {
+ const token = await h.pair();
+ assert.equal((await h.request('/api/sessions', 'GET', undefined, ADMIN)).status, 403);
+ assert.equal((await h.request('/api/sessions', 'POST', { command: 'printf should-not-run', shellType: 'shell' }, token)).status, 403);
+ assert.equal((await h.request('/api/browser/window', 'POST', {}, ADMIN)).status, 401);
+ const spoofedStatus = await new Promise((resolve, reject) => {
+ const req = httpRequest({ hostname: '127.0.0.1', port: h.port, path: '/api/browser/bootstrap', method: 'POST', headers: { Host: `evil.example:${h.port}`, Origin: ORIGIN, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }, response => { response.resume(); resolve(response.statusCode); });
+ req.on('error', reject); req.end('{}');
+ });
+ assert.equal(spoofedStatus, 421);
+ const malformedStatus = await new Promise((resolve, reject) => {
+ const req = httpRequest({ hostname: '127.0.0.1', port: h.port, path: '/api/browser/bootstrap', method: 'POST', headers: { Host: `127.0.0.1:${h.port}abc`, Origin: ORIGIN, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }, response => { response.resume(); resolve(response.statusCode); });
+ req.on('error', reject); req.end('{}');
+ });
+ assert.equal(malformedStatus, 421);
+ assert.equal((await h.request('/api/browser/bootstrap', 'POST', {}, token)).status, 200, 'malformed Host must not crash the listener');
+ assert.equal(h.launched.length, 0);
+ } finally { h.close(); }
+});
+
+test('local API uses remembered port after restart and falls back when occupied', async () => {
+ const first = new LocalApiServer(); const second = new LocalApiServer(); const restarted = new LocalApiServer();
+ try {
+ const preferred = await first.start();
+ const fallback = await second.start(preferred);
+ assert.notEqual(fallback, preferred);
+ const http = first.getHttpServer()!;
+ const closed = once(http, 'close'); first.stop(); await closed;
+ assert.equal(await restarted.start(preferred), preferred);
+ } finally { first.stop(); second.stop(); restarted.stop(); }
+});
diff --git a/bridge/src/local-pair-cli.test.ts b/bridge/src/local-pair-cli.test.ts
new file mode 100644
index 0000000..f29a3a4
--- /dev/null
+++ b/bridge/src/local-pair-cli.test.ts
@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createServer } from 'node:http';
+import { localAdminRequest } from './local-pair-cli.js';
+
+test('local admin client uses loopback bearer without Origin and rejects redirects', async (t) => {
+ let count = 0;
+ const server = createServer((req, res) => {
+ count++;
+ assert.equal(req.headers.origin, undefined);
+ assert.equal(req.headers.authorization, 'Bearer test-admin-secret');
+ if (req.url?.endsWith('/redirect')) {
+ res.writeHead(302, { Location: '/api/browser/stolen' }); res.end(); return;
+ }
+ assert.equal(req.url, '/api/browser/window');
+ assert.equal(req.method, 'POST');
+ res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}');
+ });
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ t.after(() => { server.closeAllConnections(); server.close(); });
+ const port = (server.address() as { port: number }).port;
+ assert.deepEqual(await localAdminRequest({ port, token: 'test-admin-secret' }, 'window', 'POST', {}), { ok: true });
+ await assert.rejects(localAdminRequest({ port, token: 'test-admin-secret' }, 'redirect'));
+ assert.equal(count, 2);
+});
diff --git a/bridge/src/local-pair-cli.ts b/bridge/src/local-pair-cli.ts
new file mode 100644
index 0000000..f60833d
--- /dev/null
+++ b/bridge/src/local-pair-cli.ts
@@ -0,0 +1,85 @@
+import type { Command } from 'commander';
+import { readFileSync } from 'node:fs';
+import { resolve, join } from 'node:path';
+import { createInterface } from 'node:readline/promises';
+import { setTimeout as delay } from 'node:timers/promises';
+import { resolveDefaultDataDir, resolveFtownHome } from './ftown-home.js';
+
+type Pointer = { port: number; token: string; apiUrl?: string };
+type Pairing = { id: string; code: string; origin: string; remember: boolean; expiresAt: string };
+
+export function readLocalPointer(dataDir?: string): Pointer {
+ const defaultDir = resolveDefaultDataDir();
+ const home = resolveFtownHome(dataDir ? resolve(dataDir) : defaultDir, defaultDir);
+ let pointer: Pointer;
+ try { pointer = JSON.parse(readFileSync(join(home, 'bridge.json'), 'utf8')); }
+ catch { throw new Error('No running bridge found. Start ftown-bridge --local (or your cloud bridge) first.'); }
+ if (!Number.isInteger(pointer.port) || pointer.port < 1 || pointer.port > 65535 || typeof pointer.token !== 'string' || !pointer.token) {
+ throw new Error('Invalid bridge pointer. Restart the bridge and try again.');
+ }
+ return pointer;
+}
+
+export async function localAdminRequest(pointer: Pointer, path: string, method = 'GET', body?: unknown): Promise {
+ const response = await fetch(`http://127.0.0.1:${pointer.port}/api/browser/${path}`, {
+ method,
+ headers: { Authorization: `Bearer ${pointer.token}`, 'Content-Type': 'application/json' },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ signal: AbortSignal.timeout(5000),
+ redirect: 'error',
+ });
+ const result = await response.json() as T & { error?: string };
+ if (!response.ok) throw new Error(result.error ?? `Local bridge returned ${response.status}`);
+ return result;
+}
+
+export async function pairLocalBrowser(pointer: Pointer): Promise {
+ if (!process.stdin.isTTY) throw new Error('Browser approval requires an interactive terminal. Run ftown-bridge pair in your terminal.');
+ const window = await localAdminRequest<{ expiresAt: string }>(pointer, 'window', 'POST', {});
+ const url = new URL('/local', pointer.apiUrl ?? 'https://ftown.ia.br');
+ url.searchParams.set('port', String(pointer.port));
+ console.log(`Open ${url.href}`);
+ console.log(`Local port: ${pointer.port}. Waiting for a browser request (two minutes).`);
+ const input = createInterface({ input: process.stdin, output: process.stdout });
+ try {
+ while (Date.now() < Date.parse(window.expiresAt)) {
+ const { pairings } = await localAdminRequest<{ pairings: Pairing[] }>(pointer, 'pairings');
+ const pairing = pairings[0];
+ if (!pairing) { await delay(500); continue; }
+ console.log(`\nBrowser origin: ${pairing.origin}\nMatching code: ${pairing.code}`);
+ console.log(`Access: create and remove sessions, run commands, and control terminals on this computer.`);
+ console.log(pairing.remember ? 'This browser requested remembered access.' : 'This browser requested access for this session.');
+ const remaining = Date.parse(pairing.expiresAt) - Date.now();
+ if (remaining <= 0) continue;
+ let answer: string;
+ try {
+ answer = await input.question('Does the code match your browser? Approve [y/N]: ', { signal: AbortSignal.timeout(remaining) });
+ } catch { console.log('\nApproval expired. Run ftown-bridge pair again.'); return; }
+ const approve = /^y(es)?$/i.test(answer.trim());
+ await localAdminRequest(pointer, `pairings/${encodeURIComponent(pairing.id)}/decision`, 'POST', { approve });
+ console.log(approve ? 'Browser approved. Local access is ready.' : 'Browser request denied.');
+ return;
+ }
+ console.log('No browser approved before the window expired. Run ftown-bridge pair to try again.');
+ } finally { input.close(); }
+}
+
+export function registerLocalBrowserCommands(program: Command): void {
+ const pointerFor = (command: Command) => readLocalPointer(command.optsWithGlobals().dataDir as string | undefined);
+ program.command('pair').description('Approve a browser for direct local control (no cloud login)')
+ .option('--data-dir ', 'Data directory of the running bridge')
+ .action(async (_opts, command: Command) => { await pairLocalBrowser(pointerFor(command)); });
+ program.command('devices').description('List browsers approved for local control')
+ .option('--data-dir ', 'Data directory of the running bridge')
+ .action(async (_opts, command: Command) => {
+ const { devices } = await localAdminRequest<{ devices: { id: string; origin: string; createdAt: string }[] }>(pointerFor(command), 'devices');
+ if (!devices.length) console.log('No browsers are approved.');
+ for (const device of devices) console.log(`${device.id} ${device.origin} ${device.createdAt}`);
+ });
+ program.command('revoke ').description('Revoke an approved local browser and disconnect local terminals')
+ .option('--data-dir ', 'Data directory of the running bridge')
+ .action(async (id: string, _opts, command: Command) => {
+ await localAdminRequest(pointerFor(command), `devices/${encodeURIComponent(id)}`, 'DELETE');
+ console.log('Browser access revoked.');
+ });
+}
diff --git a/bridge/src/session-wire.test.ts b/bridge/src/session-wire.test.ts
index 555406b..05a3a92 100644
--- a/bridge/src/session-wire.test.ts
+++ b/bridge/src/session-wire.test.ts
@@ -69,6 +69,7 @@ describe('toWireSession', () => {
});
interface PublishCapture {
+ state: 'connected';
publish(channel: string, data: Record): Promise;
}
@@ -78,6 +79,7 @@ describe('CentrifugoClient.publishSessionUpdate — token never crosses the wire
const captured: { channel?: string; data?: Record } = {};
(client as unknown as { client: PublishCapture }).client = {
+ state: 'connected',
publish: async (channel: string, data: Record) => {
captured.channel = channel;
captured.data = data;
diff --git a/docs/design/local-browser-control.md b/docs/design/local-browser-control.md
new file mode 100644
index 0000000..4ae501d
--- /dev/null
+++ b/docs/design/local-browser-control.md
@@ -0,0 +1,109 @@
+# Local browser approval and management — implementation contract
+
+## Module map / API style inventory
+Existing LocalApiServer binds ephemeral 127.0.0.1, validates Host and bearer admin token,
+uses plural /api/sessions resources, UUID IDs, camelCase properties, raw named envelopes
+({sessions}, {session}), {error: string} HTTP errors, no version prefix. Existing command-rpc
+uses Command {type,payload,requestId}, CommandResponse {requestId,success,data?,error?};
+session/loop controllers own all mutations. Existing direct loopback terminal uses port/nonce.
+UI Dashboard/hooks consume subscription-style events and command responses.
+
+New browser API uses /api/browser/*, existing envelopes with additive error code/requestId.
+Local admin routes (no Origin + existing process admin bearer) open a 2 minute pairing window
+and approve/revoke; browser routes require exact allowed Origin and per-device bearer tokens.
+Unpaired request/poll is the sole public exception during a CLI-opened window. No wildcard CORS.
+Credentials are bound to exact Origin and bridge. Persist only hashes for remembered devices;
+session devices stay memory-only. UI credentials go only to 127.0.0.1, never Vercel APIs.
+
+## Frozen API and module seam
+Parent owns LocalApiServer wiring, index.ts and loopback-server auth wiring.
+Backend module: bridge/src/browser-access.ts exports class BrowserAccess.
+Constructor opts: { dataDir: string; allowedOrigins: string[]; bootstrap: () => BrowserBootstrap;
+ execute: (command: Command) => Promise; onRevoke?: () => void }.
+Export BrowserBootstrap = { version: 1; userId: string; bridgeId: string; hostname: string;
+ localPort: number; localNonce: string } (module overrides localNonce with presented browser token).
+Methods:
+- handle(req: IncomingMessage, res: ServerResponse, admin: boolean): Promise
+ handles ONLY /api/browser/* (parent enforces loopback Host; admin=true ONLY valid existing
+ admin bearer with absent Origin). Module owns browser CORS/preflight, parsing and route auth.
+- authorize(token: string, origin: string): boolean (used for terminal websocket auth)
+- publish(channel: string, data: unknown): void (bounded local event history, live publication)
+- close(): void (cancel timers/longpoll, clear ephemeral pairing state).
+Backend owns browser-access.ts and browser-access.test.ts only. New local-pair-cli.ts owned
+by parent, uses endpoints below; no other backend files owned by backend worker.
+
+Admin operations, all require admin=true, never browser-accessible:
+POST /api/browser/window {} -> { expiresAt: ISO }; replaces pending window.
+GET /api/browser/pairings -> { pairings: [{id,code,origin,remember,expiresAt}] }; max 5 pending.
+POST /api/browser/pairings/:id/decision {approve:boolean} -> {ok:true}; repeat same decision safe.
+GET /api/browser/devices -> {devices:[{id,origin,createdAt}]}; cap 100 remembered devices.
+DELETE /api/browser/devices/:id -> {ok:true}; idempotent, invokes onRevoke.
+
+Browser operations require exact allowlisted Origin; OPTIONS permits only GET,POST,DELETE
+and Authorization,Content-Type; no cookies; private-network preflight supported when requested.
+POST /api/browser/pairings {remember:boolean} -> 201 {id,code,pollToken,expiresAt};
+window must be active. Code random 6 digits, pollToken random 32 bytes, max 5 outstanding;
+rate-limit unpaired requests 10/minute per origin; 2 minute expiry. No command access yet.
+GET /api/browser/pairings/:id Authorization: Bearer pollToken ->
+{status:'pending'|'denied'|'approved', credential?:string}; approved delivers random 32-byte
+credential to this polling token only, repeat polls safe until expiry. Origin must match.
+POST /api/browser/bootstrap {} bearer device credential -> BrowserBootstrap;
+localNonce is that credential (terminal auth validates through authorize).
+POST /api/browser/commands Command -> CommandResponse; validate shape, same bridge only;
+requestId idempotency scoped device+id, duplicate same body shares result; different body 409.
+Cache max 1000 results /10 min; do not evict pending operations. Overflow 429 before execution.
+Never retry commands automatically across cloud/local routes after uncertain delivery.
+GET /api/browser/events?cursor=N bearer credential ->
+{cursor:number,reset:boolean,events:[{channel:string,data:unknown}]}; max256 events/2MiB history,
+longpoll up to20s, reset=true when cursor fell behind. Per-device max2 pending polls.
+Data is existing typed publication payload; generic unknown because multiple existing channel
+contracts share this transport. Consumer refreshes authoritative session/loop snapshots on reset.
+Errors use {error:string,code:string,requestId:string}, 400 invalid,401 unauthorized,
+403 forbidden,404 not found,409 conflict/window closed,429 capped,503 not ready.
+No new version machinery: additive fields ignored; incompatible semantics require new routes.
+
+UI implementation: existing Dashboard is reused in /local without NextAuth. Entry points on
+login and hosted dashboard link to /local. Ask for port printed by ftown-bridge pair; no scanning.
+Use http://127.0.0.1: only. Pairing polls, matching code, approval pending message,
+remember-browser default true, session-only option, forget button. Store credential keyed port
+and use only after authenticated bootstrap returns same bridge id. Restore on reload independent
+of cloud. Local adapter exposes existing subscription/RPC semantics to shared hooks, backed by
+commands HTTP and events longpoll. Presence is current local bootstrap bridge+local advert.
+Use existing HybridTerminalTransport with loopback terminal. No fake cloud login/JWT.
+Connection indicator must identify local mode accurately; do not probe Fly in local mode.
+Existing cloud dashboard should offer a paired-local entry even while cloud presence is empty.
+Full local management includes session list/create/remove/stop/retry/rename and loop commands
+through existing Command types. Correct results/events must reach existing hooks.
+
+## Ownership and sequencing
+T1 Backend auth/API: browser-access.ts, browser-access.test.ts.
+T2 UI: ui/src/lib/local-browser-client.ts, its tests; ui/src/app/local/page.tsx;
+ui/src/components/LocalDashboard.tsx; UI wiring/hooks/type interfaces necessary to reuse Dashboard.
+T3 Parent integration: bridge/src/index.ts, local-api-server.ts, centrifugo-client.ts,
+direct-transport/loopback-server.ts, local-pair-cli.ts plus tests, docs.
+T1/T2 needs shared contract (this document frozen); independent after freeze.
+T1/T2 before T3 runtime smoke test. Graph acyclic. Disjoint ownership (T2 ui only,T1 exact files).
+T4 independent non-author security review after implementation; read-only review task.
+
+## Walkthrough / acceptance
+CLI opens window -> UI requests pairing -> both show matching code -> CLI approval ->
+UI polls credential -> bootstrap -> subscribe events -> list/create/remove -> terminal IO.
+Reject/expiry/revocation disallow all access. Unrelated origins cannot pair, poll, or control.
+Fly unreachable must not block local controller mutation completion or live updates.
+Bridge --local starts without cloud onboarding (parent implementation), shares data/controllers.
+Test actual HTTP pairing gate, one-shot consent, remembered reload/revocation, request dedup;
+assembled smoke creates/removes a shell session while cloud is unavailable. No deployment.
+
+## Integration verification
+
+- Bridge suite: 796 tests pass; production TypeScript build passes.
+- UI suite: 267 tests pass; production Next build, lint and types pass using
+ dummy build-only authentication secrets (no production credentials needed).
+- Assembled local integration uses real HTTP, WebSocket, approval, RPC,
+ controllers, SessionStore and disconnected publication transport; only process
+ execution is replaced by a runner fake. It covers session create/list/remove,
+ event delivery, terminal input, revocation, subsequent new-browser reconnect,
+ hostile origins/Host headers and local port reuse/fallback.
+- Independent non-author backend and UI review completed. Fixed early local RPC
+ timeout and bootstrap refresh after a bridge changes identity mode on restart.
+- No live bridge restart, deployment or PWA caching is part of this validation.
diff --git a/docs/local-browser.md b/docs/local-browser.md
new file mode 100644
index 0000000..ab9b63c
--- /dev/null
+++ b/docs/local-browser.md
@@ -0,0 +1,67 @@
+# Direct local browser control
+
+Choose **This computer** on the hosted login page or cloud dashboard to manage a
+bridge on the same computer without cloud login. Session and loop operations use
+the same bridge controllers as cloud mode; terminals connect over loopback.
+
+## Start and approve
+
+For a bridge that never contacts the cloud:
+
+```sh
+ftown-bridge --local
+```
+
+An ordinary cloud-connected bridge also exposes local approval. In a second
+terminal, run:
+
+```sh
+ftown-bridge pair
+```
+
+Open the `/local?port=...` link printed by the command, click **Connect locally**,
+and compare its six-digit code with the terminal. Approve only the matching
+request. Approval grants command execution and terminal/session control on that
+computer. A browser reaching the port alone does not authorize it.
+
+For a bridge with a custom data directory, pass the same `--data-dir` to `pair`,
+`devices`, and `revoke`. The allowed browser origin is the bridge's `--api-url`
+(default `https://ftown.ia.br`); for development, start with
+`--api-url http://localhost:3000` and use that exact origin.
+
+**Remember this browser** stores the credential in that origin's local storage.
+Unchecked, it uses tab session storage and the bridge keeps its credential only
+until the bridge exits. Credentials are bound to the bridge identity and exact
+browser origin; only hashes are persisted on the bridge.
+
+```sh
+ftown-bridge devices
+ftown-bridge revoke
+```
+
+Revocation removes that credential and disconnects existing loopback terminals.
+Other approved browsers may reconnect. **Forget saved access** removes the
+browser's saved credential; use `revoke` to invalidate it on the bridge too.
+
+## Outages and restarts
+
+While the local bridge runs, the local dashboard supports creating, removing,
+renaming, stopping, and retrying sessions, managing loops, and using terminals
+without Fly. The cloud dashboard's **This computer** link switches to this local
+route. It does not automatically replay a cloud command whose delivery is
+uncertain, or switch the dashboard back to cloud mode.
+
+The local dashboard retries its event connection and terminal connection after a
+local transport interruption. A missed event history triggers a fresh snapshot.
+Mutation requests are not automatically retried; the bridge deduplicates repeated
+request IDs for ten minutes after completion (up to 1,000 retained commands).
+
+The bridge reuses its saved loopback port on restart. If another process occupies
+that port, it selects a new one; run `pair` and use the new link. A changed bridge
+identity or revoked credential requires approval again.
+
+The hosted UI still needs to load initially from its host. This change does not
+add a PWA or offline UI cache. Browsers may request local-network permission;
+that permission and local bridge approval are both necessary. `--local` binds
+only to loopback, so it controls this computer, not another LAN machine. Existing
+`--solo` remains the bundled LAN panel/hub deployment described in [solo.md](solo.md).
diff --git a/ui/src/app/local/page.tsx b/ui/src/app/local/page.tsx
index 3f78d45..923437c 100644
--- a/ui/src/app/local/page.tsx
+++ b/ui/src/app/local/page.tsx
@@ -1,167 +1,5 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-
-import { DashboardClient } from "@/components/DashboardClient";
-import { KeyEntry } from "@/components/local/KeyEntry";
-import { StartingState } from "@/components/local/StartingState";
-import {
- SoloAuthError,
- bootstrap,
- captureKeyFromHash,
- clearKey,
- getHealth,
- getStoredKey,
- mintToken,
- storeKey,
-} from "@/lib/solo-client";
-
-/**
- * Solo first-run experience (bridge contract: ui/src/app/local/page.tsx).
- *
- * mount → consume #k= fragment (or stored key) → GET /api/solo/bootstrap
- * ├─ no key → KeyEntry (inline 401 validation)
- * ├─ key accepted → DashboardClient wired to solo token refresh
- * └─ children booting → StartingState polling /healthz every 2s
- */
-
-type Phase = "connecting" | "needs-key" | "starting" | "ready";
-
-interface BootstrapInfo {
- userId: string;
- centrifugoUrl: string;
- token: string;
-}
-
-const HEALTH_POLL_INTERVAL_MS = 2000;
-
+import { LocalDashboard } from "@/components/LocalDashboard";
+import SoloLocalPage from "@/components/local/SoloLocalPage";
export default function LocalPage() {
- const [phase, setPhase] = useState("connecting");
- const [boot, setBoot] = useState(null);
- const [submitting, setSubmitting] = useState(false);
- const [keyError, setKeyError] = useState(null);
- const [startDetail, setStartDetail] = useState(null);
- const keyRef = useRef(null);
-
- const runBootstrap = useCallback(async (key: string): Promise => {
- try {
- const result = await bootstrap(key);
- setBoot(result);
- setStartDetail(null);
- setPhase("ready");
- } catch (err) {
- if (err instanceof SoloAuthError) {
- // Rejected cached or mistyped key: forget it and re-prompt.
- clearKey();
- keyRef.current = null;
- setKeyError("That key was rejected. Copy it again from the ftown-bridge --solo banner.");
- setPhase("needs-key");
- } else {
- // Network failure or 502 — the hub/panel children may still be
- // booting; let the healthz poller drive the retry.
- setPhase("starting");
- }
- }
- }, []);
-
- useEffect(() => {
- const captured = captureKeyFromHash();
- const key = captured ?? getStoredKey();
- if (!key) {
- setPhase("needs-key");
- return;
- }
- keyRef.current = key;
- void runBootstrap(key);
- }, [runBootstrap]);
-
- const handleSubmit = useCallback(
- (key: string) => {
- storeKey(key);
- keyRef.current = key;
- setSubmitting(true);
- setKeyError(null);
- void runBootstrap(key).finally(() => setSubmitting(false));
- },
- [runBootstrap]
- );
-
- useEffect(() => {
- if (phase !== "starting") return undefined;
- let disposed = false;
- let triggered = false;
-
- const tick = async (): Promise => {
- try {
- const health = await getHealth();
- if (disposed || triggered) return;
- if (health.hub === "down") {
- setStartDetail("Waiting for the realtime hub…");
- return;
- }
- if (health.panel === "down") {
- setStartDetail("Waiting for the web panel…");
- return;
- }
- triggered = true;
- const key = keyRef.current;
- if (!key) {
- setPhase("needs-key");
- return;
- }
- await runBootstrap(key);
- } catch {
- if (!disposed && !triggered) setStartDetail("Reaching ftown Solo…");
- }
- };
-
- void tick();
- const interval = window.setInterval(() => void tick(), HEALTH_POLL_INTERVAL_MS);
- return () => {
- disposed = true;
- window.clearInterval(interval);
- };
- }, [phase, runBootstrap]);
-
- const refreshHubToken = useCallback((): Promise => {
- const key = keyRef.current;
- if (!key) return Promise.reject(new Error("Solo access key is not available."));
- return mintToken(key).then((minted) => minted.token);
- }, []);
-
- const handleUnauthorized = useCallback(() => {
- // The bridge rejected the stored key mid-session: wipe it and fall back
- // to the entry form. Unmounting DashboardClient disconnects the socket.
- clearKey();
- keyRef.current = null;
- setBoot(null);
- setKeyError("Your access key stopped working. Enter it again to reconnect.");
- setPhase("needs-key");
- }, []);
-
- if (phase === "ready" && boot) {
- return (
-
- );
- }
-
- return (
-
- {phase === "connecting" && (
-
- Connecting to ftown Solo…
-
- )}
- {phase === "needs-key" && (
-
- )}
- {phase === "starting" && }
-
- );
+ return process.env.NEXT_PUBLIC_SOLO === "1" ? : ;
}
diff --git a/ui/src/app/login/page.tsx b/ui/src/app/login/page.tsx
index 24653af..1969b49 100644
--- a/ui/src/app/login/page.tsx
+++ b/ui/src/app/login/page.tsx
@@ -50,6 +50,8 @@ export default function LoginPage() {
Sign in to your account
+ This computer — no account needed
+