From 16d1032bd4b5b038d4e3149d95fbb1bf0336dfd3 Mon Sep 17 00:00:00 2001 From: Lia Date: Wed, 21 Oct 2026 22:22:39 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=AA=AA=20feat:=20Add=20Key-Bound=20Br?= =?UTF-8?q?owser=20Enrollment=20And=20Resumable=20Setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/package.json | 4 + .../code/src/browser-enrollment-protocol.ts | 253 +++++++++++ packages/code/src/browser-setup-cli.ts | 101 +++++ packages/code/src/browser-setup.test.ts | 197 +++++++++ packages/code/src/browser-setup.ts | 325 ++++++++++++++ packages/code/src/cli.ts | 8 +- packages/code/src/index.ts | 1 + packages/code/src/setup-cli.ts | 35 +- packages/code/src/worker.test.ts | 20 +- packages/code/src/worker.ts | 8 +- .../bridge/browser-enrollment-router.test.ts | 178 ++++++++ service/src/bridge/browser-enrollment.test.ts | 413 ++++++++++++++++++ service/src/bridge/pairing.ts | 311 ++++++++++++- service/src/bridge/router.ts | 73 ++++ 14 files changed, 1921 insertions(+), 6 deletions(-) create mode 100644 packages/code/src/browser-enrollment-protocol.ts create mode 100644 packages/code/src/browser-setup-cli.ts create mode 100644 packages/code/src/browser-setup.test.ts create mode 100644 packages/code/src/browser-setup.ts create mode 100644 service/src/bridge/browser-enrollment-router.test.ts create mode 100644 service/src/bridge/browser-enrollment.test.ts diff --git a/packages/code/package.json b/packages/code/package.json index 95eea841..d3e6395b 100644 --- a/packages/code/package.json +++ b/packages/code/package.json @@ -19,6 +19,10 @@ "types": "./dist/identity.d.ts", "import": "./dist/identity.js" }, + "./browser-enrollment": { + "types": "./dist/browser-enrollment-protocol.d.ts", + "import": "./dist/browser-enrollment-protocol.js" + }, "./worker": { "types": "./dist/worker.d.ts", "import": "./dist/worker.js" diff --git a/packages/code/src/browser-enrollment-protocol.ts b/packages/code/src/browser-enrollment-protocol.ts new file mode 100644 index 00000000..ef1c712b --- /dev/null +++ b/packages/code/src/browser-enrollment-protocol.ts @@ -0,0 +1,253 @@ +import { createHash, createPublicKey, sign, verify } from 'node:crypto'; + +export type BrowserSetupRequest = BrowserEnrollmentRequest; +export type EnrollmentRedemption = BrowserEnrollmentRedemptionContract; +export type EnrollmentApproval = BrowserEnrollmentApproval; + +export const BROWSER_ENROLLMENT_PROTOCOL_VERSION = 1 as const; +export const BROWSER_ENROLLMENT_MAX_TTL_MS = 10 * 60_000; +export const BROWSER_SETUP_PROOF_DOMAIN = 'librechat-code:browser-setup:v1'; +export const BROWSER_ENROLLMENT_REDEMPTION_DOMAIN = 'librechat-code:browser-enrollment-redeem:v1'; + +export interface BrowserEnrollmentRequest { + version: 1; + instance: string; + attemptId: string; + createdAt: string; + publicKey: string; + machineName: string; + workspace: { type: 'private' | 'directory' }; + allowWorkspaceWrites: boolean; + allowWorkspaceCommands: boolean; +} + +export interface BrowserSetupProof { + operation: 'setup.start' | 'setup.poll' | 'setup.cancel' | 'setup.complete'; + instance: string; + attemptId: string; + requestHash: string; + timestamp: string; + nonce: string; +} + +export interface BrowserEnrollmentRedemptionContract { + operation: 'enrollment.redeem'; + serverId: string; + instance: string; + attemptId: string; + requestHash: string; + workerId: string; + expiresAt: string; +} + +export interface BrowserEnrollmentStatusRequest extends BrowserEnrollmentRedemptionContract { + protocolVersion: 1; +} + +export interface BrowserEnrollmentRedemption extends BrowserEnrollmentRedemptionContract { + protocolVersion: 1; + signature: string; +} + +export interface BrowserEnrollmentBinding { + tenantId: string; + principal: { type: 'deployment' | 'tenant' | 'user' | 'role' | 'group'; id: string }; +} + +export interface BrowserEnrollmentApproval extends BrowserEnrollmentRedemptionContract { + protocolVersion: 1; + publicKey: string; + binding: BrowserEnrollmentBinding; +} + +export interface BrowserEnrollmentCapabilities { + browserEnrollmentVersion: 1; + serverId: string; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function keys(value: Record, names: string[]): boolean { + return Object.keys(value).length === names.length && names.every(name => Object.prototype.hasOwnProperty.call(value, name)); +} + +export function isBrowserEnrollmentTimestamp(value: unknown): value is string { + if (typeof value !== 'string' || value.length !== 24) return false; + const time = Date.parse(value); + return Number.isFinite(time) && new Date(time).toISOString() === value; +} + +export function isBrowserEnrollmentToken(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value) && + Buffer.from(value, 'base64url').toString('base64url') === value; +} + +export function isBrowserEnrollmentSignature(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{86}$/.test(value) && + Buffer.from(value, 'base64url').toString('base64url') === value; +} + +export function isBrowserEnrollmentPublicKey(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 4096 || !value.startsWith('-----BEGIN PUBLIC KEY-----\n')) return false; + try { + const key = createPublicKey(value); + return key.asymmetricKeyType === 'ed25519' && key.export({ type: 'spki', format: 'pem' }) === value; + } catch { + return false; + } +} + +export function normalizeBrowserEnrollmentInstance(value: string): string { + if (value.length > 2048 || value !== value.trim()) throw new TypeError('Invalid LibreChat instance URL'); + const url = new URL(value); + if ( + (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) || + url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '' + ) { + throw new TypeError('LibreChat instance requires HTTPS (or loopback HTTP), without credentials, query, or fragment'); + } + return `${url.origin}${url.pathname.replace(/\/+$/, '')}`; +} + +function instance(value: unknown): value is string { + try { + return typeof value === 'string' && normalizeBrowserEnrollmentInstance(value) === value; + } catch { + return false; + } +} + +function serverOrigin(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 256) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && url.origin === value && instance(value); + } catch { + return false; + } +} + +function requestHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +} + +export function isBrowserEnrollmentRequest(value: unknown): value is BrowserEnrollmentRequest { + return record(value) && keys(value, [ + 'version', 'instance', 'attemptId', 'createdAt', 'publicKey', 'machineName', + 'workspace', 'allowWorkspaceWrites', 'allowWorkspaceCommands', + ]) && value.version === 1 && instance(value.instance) && isBrowserEnrollmentToken(value.attemptId) && + isBrowserEnrollmentTimestamp(value.createdAt) && isBrowserEnrollmentPublicKey(value.publicKey) && + typeof value.machineName === 'string' && /^[A-Za-z0-9][A-Za-z0-9 ._()-]{0,79}$/.test(value.machineName) && + value.machineName === value.machineName.trim() && record(value.workspace) && keys(value.workspace, ['type']) && + (value.workspace.type === 'private' || value.workspace.type === 'directory') && + typeof value.allowWorkspaceWrites === 'boolean' && typeof value.allowWorkspaceCommands === 'boolean'; +} + +export function canonicalBrowserEnrollmentRequest(input: BrowserEnrollmentRequest): string { + if (!isBrowserEnrollmentRequest(input)) throw new TypeError('Invalid browser enrollment request'); + return JSON.stringify({ + version: input.version, + instance: input.instance, + attemptId: input.attemptId, + createdAt: input.createdAt, + publicKey: input.publicKey, + machineName: input.machineName, + workspace: { type: input.workspace.type }, + allowWorkspaceWrites: input.allowWorkspaceWrites, + allowWorkspaceCommands: input.allowWorkspaceCommands, + }); +} + +export function hashBrowserEnrollmentRequest(input: BrowserEnrollmentRequest): string { + return createHash('sha256').update(canonicalBrowserEnrollmentRequest(input)).digest('hex'); +} + +export function isBrowserSetupProof(value: unknown): value is BrowserSetupProof { + return record(value) && keys(value, ['operation', 'instance', 'attemptId', 'requestHash', 'timestamp', 'nonce']) && + typeof value.operation === 'string' && ['setup.start', 'setup.poll', 'setup.cancel', 'setup.complete'].includes(value.operation) && + instance(value.instance) && isBrowserEnrollmentToken(value.attemptId) && requestHash(value.requestHash) && + isBrowserEnrollmentTimestamp(value.timestamp) && isBrowserEnrollmentToken(value.nonce); +} + +export function canonicalBrowserSetupProof(input: BrowserSetupProof): string { + if (!isBrowserSetupProof(input)) throw new TypeError('Invalid browser setup proof'); + return [BROWSER_SETUP_PROOF_DOMAIN, input.operation, input.instance, input.attemptId, + input.requestHash, input.timestamp, input.nonce].join('\n'); +} + +export function isBrowserEnrollmentRedemptionContract(value: unknown): value is BrowserEnrollmentRedemptionContract { + return record(value) && value.operation === 'enrollment.redeem' && serverOrigin(value.serverId) && + instance(value.instance) && isBrowserEnrollmentToken(value.attemptId) && requestHash(value.requestHash) && + typeof value.workerId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.workerId) && + isBrowserEnrollmentTimestamp(value.expiresAt); +} + +export function isBrowserEnrollmentBinding(value: unknown): value is BrowserEnrollmentBinding { + return record(value) && keys(value, ['tenantId', 'principal']) && + typeof value.tenantId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.tenantId) && + record(value.principal) && keys(value.principal, ['type', 'id']) && + typeof value.principal.type === 'string' && ['deployment', 'tenant', 'user', 'role', 'group'].includes(value.principal.type) && + typeof value.principal.id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.principal.id); +} + +const CONTRACT_KEYS = ['operation', 'serverId', 'instance', 'attemptId', 'requestHash', 'workerId', 'expiresAt']; + +export function isBrowserEnrollmentApproval(value: unknown): value is BrowserEnrollmentApproval { + return isBrowserEnrollmentRedemptionContract(value) && record(value) && + keys(value, [...CONTRACT_KEYS, 'protocolVersion', 'publicKey', 'binding']) && + value.protocolVersion === 1 && isBrowserEnrollmentPublicKey(value.publicKey) && isBrowserEnrollmentBinding(value.binding); +} + +export function isBrowserEnrollmentStatusRequest(value: unknown): value is BrowserEnrollmentStatusRequest { + return isBrowserEnrollmentRedemptionContract(value) && record(value) && + keys(value, [...CONTRACT_KEYS, 'protocolVersion']) && value.protocolVersion === 1; +} + +export function isBrowserEnrollmentRedemption(value: unknown): value is BrowserEnrollmentRedemption { + return isBrowserEnrollmentRedemptionContract(value) && record(value) && + keys(value, [...CONTRACT_KEYS, 'protocolVersion', 'signature']) && + value.protocolVersion === 1 && isBrowserEnrollmentSignature(value.signature); +} + +export function canonicalBrowserEnrollmentRedemption(input: BrowserEnrollmentRedemptionContract): string { + if (!isBrowserEnrollmentRedemptionContract(input)) throw new TypeError('Invalid browser enrollment redemption'); + return [BROWSER_ENROLLMENT_REDEMPTION_DOMAIN, input.operation, input.serverId, input.instance, + input.attemptId, input.requestHash, input.workerId, input.expiresAt].join('\n'); +} + +export function signBrowserSetupProof(privateKey: string, input: BrowserSetupProof): string { + return sign(null, Buffer.from(canonicalBrowserSetupProof(input)), privateKey).toString('base64url'); +} + +export function verifyBrowserSetupProof(publicKey: string, input: BrowserSetupProof, signature: string): boolean { + try { + return isBrowserEnrollmentPublicKey(publicKey) && isBrowserEnrollmentSignature(signature) && + verify(null, Buffer.from(canonicalBrowserSetupProof(input)), publicKey, Buffer.from(signature, 'base64url')); + } catch { + return false; + } +} + +export function signBrowserEnrollmentRedemption(privateKey: string, input: BrowserEnrollmentRedemptionContract): string { + return sign(null, Buffer.from(canonicalBrowserEnrollmentRedemption(input)), privateKey).toString('base64url'); +} + +export function verifyBrowserEnrollmentRedemption(publicKey: string, input: BrowserEnrollmentRedemptionContract, signature: string): boolean { + try { + return isBrowserEnrollmentPublicKey(publicKey) && isBrowserEnrollmentSignature(signature) && + verify(null, Buffer.from(canonicalBrowserEnrollmentRedemption(input)), publicKey, Buffer.from(signature, 'base64url')); + } catch { + return false; + } +} + +export const browserSetupRequestHash = hashBrowserEnrollmentRequest; +export const canonicalBrowserSetupRequest = canonicalBrowserEnrollmentRequest; +export const canonicalEnrollmentRedemption = canonicalBrowserEnrollmentRedemption; +export const signEnrollmentRedemption = signBrowserEnrollmentRedemption; +export const verifyEnrollmentRedemption = verifyBrowserEnrollmentRedemption; +export const validateBrowserSetupRequest = isBrowserEnrollmentRequest; +export const validateBrowserSetupProof = isBrowserSetupProof; +export const validateEnrollmentRedemption = isBrowserEnrollmentRedemptionContract; diff --git a/packages/code/src/browser-setup-cli.ts b/packages/code/src/browser-setup-cli.ts new file mode 100644 index 00000000..909bcbab --- /dev/null +++ b/packages/code/src/browser-setup-cli.ts @@ -0,0 +1,101 @@ +import { spawn } from 'node:child_process'; +import { createHash, createPublicKey } from 'node:crypto'; +import { hostname } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cancelBrowserSetup, loadBrowserSetupAttempt, setupBrowserWorker, setupInstanceUrl } from './browser-setup.js'; +import { defaultWorkerProfilePath } from './worker-profile.js'; +import { setupWorkspaceDirectory } from './setup.js'; + +/** No shell, token, or inbound callback listener is involved. */ +export async function openSetupBrowser(url: string): Promise { + if (process.env.SSH_CONNECTION || process.env.SSH_TTY || + (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)) return false; + const command = process.platform === 'darwin' ? 'open' : process.platform === 'linux' ? 'xdg-open' : undefined; + if (!command) return false; + return new Promise(resolve => { + const child = spawn(command, [url], { stdio: 'ignore', shell: false }); + const timer = setTimeout(() => { child.kill(); resolve(false); }, 5000); + child.once('error', () => { clearTimeout(timer); resolve(false); }); + child.once('exit', code => { clearTimeout(timer); resolve(code === 0); }); + }); +} + +export async function runBrowserSetupCommand( + options: Map, + prompts: { + interactive: boolean; + ask(message: string): Promise; + yesNo(message: string): Promise; + signal: AbortSignal; + }, +): Promise { + const profilePath = resolve(String(options.get('--profile') ?? defaultWorkerProfilePath())); + const instance = setupInstanceUrl(String(options.get('--instance'))); + const saved = await loadBrowserSetupAttempt(profilePath); + if (saved && saved.request.instance !== instance) throw new Error('This profile has a setup attempt for another LibreChat instance. Resume the original instance or choose another profile'); + if (options.has('--cancel')) { + await cancelBrowserSetup(profilePath, prompts.signal); + process.stdout.write('Setup cancellation recorded. The local machine key and attempt were preserved.\n'); + return; + } + let workspaceDirectory = options.get('--worker-dir') as string | undefined; + if (saved) { + if (workspaceDirectory && (saved.profile.workspace.type !== 'directory' || await setupWorkspaceDirectory(workspaceDirectory) !== saved.profile.workspace.path)) + throw new Error('The workspace differs from the saved setup attempt'); + if (options.has('--default-workspace') && saved.profile.workspace.type !== 'private') throw new Error('The workspace differs from the saved setup attempt'); + if ((options.has('--allow-workspace-writes') && !saved.profile.allowWorkspaceWrites) || + (options.has('--allow-workspace-commands') && !saved.profile.allowWorkspaceCommands)) throw new Error('Permissions differ from the saved setup attempt'); + workspaceDirectory = saved.profile.workspace.type === 'directory' ? saved.profile.workspace.path : undefined; + process.stdout.write('Resuming the saved setup attempt with its original machine key and access choices.\n'); + } else if (prompts.interactive && !workspaceDirectory && !options.has('--default-workspace')) { + workspaceDirectory = (await prompts.ask('Project directory (Enter for a new private workspace): ')) || undefined; + } + if (workspaceDirectory !== undefined) workspaceDirectory = await setupWorkspaceDirectory(workspaceDirectory); + const machineName = String(options.get('--machine-name') ?? saved?.request.machineName ?? hostname().replace(/[^A-Za-z0-9 ._()-]/g, '-').slice(0, 80)); + const allowWorkspaceWrites = saved?.profile.allowWorkspaceWrites ?? (options.has('--allow-workspace-writes') || + (prompts.interactive && await prompts.yesNo('Allow file writes within this workspace?'))); + const allowWorkspaceCommands = saved?.profile.allowWorkspaceCommands ?? (options.has('--allow-workspace-commands') || + (prompts.interactive && await prompts.yesNo('Allow sandboxed commands? Commands can modify workspace files independently of file-write tooling'))); + process.stdout.write(`\nLibreChat: ${instance}\nMachine: ${machineName}\nWorkspace: ${workspaceDirectory ?? 'new private workspace'}\nFile writes: ${allowWorkspaceWrites ? 'enabled' : 'disabled'}\nCommands: ${allowWorkspaceCommands ? 'enabled, restricted sandbox policy' : 'disabled'}\nProfile: ${profilePath}\nPairing does not attest this host or replace tool approvals.\n`); + if (!saved && prompts.interactive && !await prompts.yesNo('Request browser approval with these choices?')) { + process.stdout.write('Setup cancelled before enrollment.\n'); + return; + } + await setupBrowserWorker({ + instance, machineName, profilePath, + identityPath: options.get('--identity') as string | undefined ?? saved?.profile.identityPath, + workspaceDirectory, allowWorkspaceWrites, allowWorkspaceCommands, + signal: prompts.signal, + }, { + onVerification: async status => { + const attempt = (await loadBrowserSetupAttempt(profilePath))!; + const fingerprint = createHash('sha256').update(createPublicKey(attempt.request.publicKey).export({ type: 'spki', format: 'der' })).digest('hex'); + process.stdout.write(`Machine key: SHA256:${fingerprint}\n`); + // Always show both, even when opening a browser succeeds. + process.stdout.write(`\nApprove this machine in LibreChat:\n ${status.verificationUrl}\nVerification code: ${status.verificationCode}\nExpires: ${status.expiresAt}\nCheck the matching code and key identity before approving. Waiting for your explicit decision.\n`); + if (!options.has('--no-browser') && prompts.interactive && !await openSetupBrowser(status.verificationUrl)) + process.stdout.write('Could not open a browser here. Open the URL on another device and compare the code.\n'); + }, + }); + process.stdout.write('\nMachine enrolled. Profile saved. It is not connected or ready until the worker starts and readiness is confirmed.\n'); + const start = options.has('--start') || (prompts.interactive && await prompts.yesNo('Start the worker in the foreground now?')); + if (start) { + process.stdout.write('Starting the saved profile in the foreground. Stop with Ctrl-C.\n'); + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fileURLToPath(new URL('./cli.js', import.meta.url)), 'run', '--profile', profilePath], { stdio: 'inherit' }); + const abort = () => child.kill('SIGINT'); + prompts.signal.addEventListener('abort', abort, { once: true }); + child.once('error', reject); + child.once('exit', code => { + prompts.signal.removeEventListener('abort', abort); + if (code === 0 || prompts.signal.aborted) resolve(); + else reject(new Error('Worker stopped before a successful foreground session; enrollment and profile remain saved')); + }); + }); + } else { + const quoted = `'${profilePath.replace(/'/g, "'\\''")}'`; + process.stdout.write(`Start it with:\n librechat-code run${profilePath === resolve(defaultWorkerProfilePath()) ? '' : ` --profile ${quoted}`}\n`); + } +} diff --git a/packages/code/src/browser-setup.test.ts b/packages/code/src/browser-setup.test.ts new file mode 100644 index 00000000..8de3eca1 --- /dev/null +++ b/packages/code/src/browser-setup.test.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { createBridgeIdentity } from './identity.js'; +import { browserSetupRequestHash, verifyBrowserSetupProof, verifyEnrollmentRedemption } from './browser-enrollment-protocol.js'; +import type { BrowserSetupRequest, BrowserSetupProof } from './browser-enrollment-protocol.js'; +import { browserSetupAttemptPath, loadBrowserSetupAttempt, setupBrowserWorker, setupInstanceUrl, cancelBrowserSetup } from './browser-setup.js'; +import type { BrowserSetupOptions } from './browser-setup.js'; +import { loadBridgeIdentity, saveBridgeIdentity, savePrivateJson } from './storage.js'; +import { loadWorkerProfile } from './worker-profile.js'; +import { parseSetupArguments } from './setup-cli.js'; + +async function fixture(t: test.TestContext) { + const root = await mkdtemp(join(tmpdir(), 'browser-setup-')); + t.after(() => rm(root, { recursive: true, force: true })); + const options: BrowserSetupOptions = { + instance: 'https://chat.example', machineName: 'headless-machine', + profilePath: join(root, 'profile.json'), + allowWorkspaceWrites: false, allowWorkspaceCommands: false, + }; + let completeCalls = 0; + let startCalls = 0; + let initialRequest: BrowserSetupRequest | undefined; + let lostStart = false; + let lostComplete = false; + let status: 'approved' | 'denied' | 'expired' | 'cancelled' = 'approved'; + const credential = { protocolVersion: 1 as const, workerId: 'approved-machine', credential: 'approved-credential-that-is-long-enough', expiresAt: '2099-01-01T00:00:00.000Z' }; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/capabilities')) return Response.json({ browserEnrollmentVersion: 1 }); + const body = JSON.parse(String(init?.body)) as { request: BrowserSetupRequest; proof: BrowserSetupProof; signature: string; redemptionSignature?: string }; + assert.equal((body.request as unknown as Record).privateKey, undefined); + assert.equal(verifyBrowserSetupProof(body.request.publicKey, body.proof, body.signature), true); + assert.equal(body.proof.requestHash, browserSetupRequestHash(body.request)); + const saved = await loadBrowserSetupAttempt(options.profilePath!); + assert.equal(saved?.request.publicKey, body.request.publicKey, 'key saved before remote effect'); + assert.equal(saved?.request.attemptId, body.request.attemptId); + initialRequest ??= body.request; + assert.deepEqual(body.request, initialRequest, 'retries retain immutable request and key'); + const expiresAt = new Date(Date.parse(body.request.createdAt) + 600_000).toISOString(); + const approval = { operation: 'enrollment.redeem' as const, serverId: 'https://code.example', instance: body.request.instance, + attemptId: body.request.attemptId, requestHash: browserSetupRequestHash(body.request), workerId: credential.workerId, + expiresAt, codeApiUrl: 'https://code.example/v1' }; + const base = { version: 1, expiresAt, verificationCode: 'ABCD-EFGH', verificationUrl: `https://chat.example/code/setup/${body.request.attemptId}` }; + if (String(url).endsWith('/start')) { + startCalls++; + if (lostStart) { lostStart = false; throw new Error('lost issuance response'); } + return Response.json({ ...base, status: 'pending' }); + } + if (String(url).endsWith('/cancel')) return Response.json({ ...base, status: 'cancelled' }); + if (String(url).endsWith('/complete')) { + completeCalls++; + assert.equal(body.proof.operation, 'setup.complete'); + assert.equal(verifyEnrollmentRedemption(body.request.publicKey, approval, body.redemptionSignature!), true); + if (lostComplete) { lostComplete = false; throw new Error('lost completion response'); } + return Response.json({ ...base, status: 'completed', approval, credential }); + } + return Response.json({ ...base, status, ...(status === 'approved' ? { approval } : {}) }); + }) as typeof fetch; + return { root, options, fetchImpl, credential, counts: () => ({ completeCalls, startCalls }), + loseStart: () => { lostStart = true; }, loseComplete: () => { lostComplete = true; }, setStatus: (value: typeof status) => { status = value; } }; +} + +test('headless setup displays URL and code and saves the existing least-privilege profile', async t => { + const f = await fixture(t); + let displayed = false; + const saved = await setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0, onVerification: async value => { + assert.match(value.verificationUrl, /^https:\/\/chat.example\/code\/setup\//); + assert.equal(value.verificationCode, 'ABCD-EFGH'); + assert.ok(await loadBrowserSetupAttempt(f.options.profilePath!)); + displayed = true; + } }); + assert.equal(displayed, true); + assert.deepEqual(await loadWorkerProfile(saved.profilePath), saved.profile); + assert.equal(saved.profile.allowWorkspaceCommands, false); + assert.equal(saved.profile.allowWorkspaceWrites, false); + assert.deepEqual(saved.profile.workspace, { type: 'private' }); + const identity = await loadBridgeIdentity(saved.profile.identityPath); + assert.equal(identity.credential, f.credential.credential); + assert.equal((await stat(browserSetupAttemptPath(saved.profilePath))).mode & 0o777, 0o600); + assert.ok(!(await readFile(saved.profilePath, 'utf8')).includes('PRIVATE KEY')); +}); + +for (const boundary of ['start', 'complete'] as const) test(`lost ${boundary} response resumes the identical key and attempt`, async t => { + const f = await fixture(t); + if (boundary === 'start') f.loseStart(); else f.loseComplete(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }), /lost/); + const before = await loadBrowserSetupAttempt(f.options.profilePath!); + assert.ok(before); + await setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }); + const after = await loadBrowserSetupAttempt(f.options.profilePath!); + assert.deepEqual(after?.request, before.request); + assert.equal(after?.privateKey, before.privateKey); + assert.equal(f.counts().startCalls, 2); +}); + +test('local profile failure after enrollment resumes without remote enrollment effects', async t => { + const f = await fixture(t); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0, saveProfile: async () => { throw new Error('disk full'); } }), /enrolled.*profile persistence failed/); + assert.ok((await loadBrowserSetupAttempt(f.options.profilePath!))?.identity); + assert.equal(f.counts().completeCalls, 1); + const saved = await setupBrowserWorker(f.options, { fetchImpl: async () => { throw new Error('must not enroll again'); } }); + assert.ok(await loadWorkerProfile(saved.profilePath)); + assert.equal(f.counts().completeCalls, 1); +}); + +test('rerunning completed setup preserves a newer startup credential without network or redundant writes', async t => { + const f = await fixture(t); + const saved = await setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }); + const identity = { ...await loadBridgeIdentity(saved.profile.identityPath), credential: 'newer-credential-recovered-after-startup' }; + await saveBridgeIdentity(saved.profile.identityPath, identity); + await setupBrowserWorker(f.options, { + fetchImpl: async () => { assert.fail('completed setup does not enroll again'); }, + saveIdentity: async () => { assert.fail('do not replace a newer credential'); }, + saveProfile: async () => { assert.fail('do not rewrite an unchanged profile'); }, + }); + assert.deepEqual(await loadBridgeIdentity(saved.profile.identityPath), identity); +}); + +test('identity persistence failure after enrollment preserves the completed local receipt', async t => { + const f = await fixture(t); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0, saveIdentity: async () => { throw new Error('identity disk full'); } }), /disk full/); + assert.ok((await loadBrowserSetupAttempt(f.options.profilePath!))?.identity); + await setupBrowserWorker(f.options, { fetchImpl: async () => { throw new Error('must not enroll again'); } }); + assert.equal(f.counts().completeCalls, 1); +}); + +test('expired setup with a lost completion response recovers only existing key-bound enrollment', async t => { + const f = await fixture(t); + f.loseComplete(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }), /lost/); + const attempt = (await loadBrowserSetupAttempt(f.options.profilePath!))!; + const now = Date.now(); + t.mock.method(Date, 'now', () => now + 11 * 60_000); + const saved = await setupBrowserWorker(f.options, { + fetchImpl: async () => { assert.fail('an expired attempt must not allocate another session'); }, + recoverCredential: async options => { + assert.equal(options.privateKey, attempt.privateKey); + assert.equal(options.workerId, attempt.approval?.workerId); + assert.equal(options.codeApiUrl, attempt.approval?.codeApiUrl); + return f.credential; + }, + }); + assert.equal((await loadBridgeIdentity(saved.profile.identityPath)).publicKey, attempt.request.publicKey); + assert.equal(f.counts().completeCalls, 1); +}); + +test('unsupported servers fail closed and never invoke manual redemption', async t => { + const f = await fixture(t); + let calls = 0; + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: async url => { + calls++; + assert.ok(String(url).endsWith('/capabilities')); + return Response.json({ protocolVersion: 1 }); + } }), /will not downgrade/); + assert.equal(calls, 1); + assert.ok(await loadBrowserSetupAttempt(f.options.profilePath!)); +}); + +for (const status of ['denied', 'expired', 'cancelled'] as const) test(`${status} setup preserves the saved identity and does not complete`, async t => { + const f = await fixture(t); + f.setStatus(status); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }), new RegExp(status)); + assert.equal(f.counts().completeCalls, 0); + assert.ok(await loadBrowserSetupAttempt(f.options.profilePath!)); +}); + +test('retry cannot widen permissions, rename a machine, switch instances, or replace a key', async t => { + const f = await fixture(t); + f.loseStart(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl }), /lost/); + for (const change of [{ allowWorkspaceWrites: true }, { machineName: 'other-machine' }, { instance: 'https://other.example' }]) { + await assert.rejects(setupBrowserWorker({ ...f.options, ...change }, { fetchImpl: async () => { assert.fail('no remote effects'); } }), /choices differ/); + } + const attempt = (await loadBrowserSetupAttempt(f.options.profilePath!))!; + await savePrivateJson(browserSetupAttemptPath(f.options.profilePath!), { ...attempt, privateKey: createBridgeIdentity().privateKey }); + await assert.rejects(loadBrowserSetupAttempt(f.options.profilePath!), /key does not match/); +}); + +test('cancellation is signed by the persisted machine key', async t => { + const f = await fixture(t); + f.loseStart(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl }), /lost/); + await cancelBrowserSetup(f.options.profilePath!, undefined, f.fetchImpl); +}); + +test('browser flags preserve manual automation without accepting mixed credentials', () => { + const flags = parseSetupArguments(['--instance', 'https://chat.example', '--non-interactive', '--default-workspace', '--no-browser']); + assert.equal(flags.get('--instance'), 'https://chat.example'); + for (const manual of ['--url=https://code.example/v1', '--worker-id=worker', '--code-stdin']) + assert.throws(() => parseSetupArguments(['--instance=https://chat.example', manual]), /manual pairing/); + assert.throws(() => parseSetupArguments(['--no-browser']), /require --instance/); + assert.equal(setupInstanceUrl('https://chat.example/'), 'https://chat.example'); + assert.throws(() => setupInstanceUrl('https://user:secret@chat.example'), /credentials/); +}); diff --git a/packages/code/src/browser-setup.ts b/packages/code/src/browser-setup.ts new file mode 100644 index 00000000..30e2e5eb --- /dev/null +++ b/packages/code/src/browser-setup.ts @@ -0,0 +1,325 @@ +import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto'; +import { lstat } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { createBridgeIdentity } from './identity.js'; +import { recoverWorkerCredential } from './credential-recovery.js'; +import { + browserSetupRequestHash, + signBrowserSetupProof, + signEnrollmentRedemption, + isBrowserEnrollmentRedemptionContract, +} from './browser-enrollment-protocol.js'; +import type { + BrowserSetupRequest, + BrowserSetupProof, + EnrollmentRedemption, +} from './browser-enrollment-protocol.js'; +import { assertPrivateStorageSupported } from './private-storage.js'; +import { withProcessLock } from './process-lock.js'; +import { + assertIdentityPathIsPrivate, + loadBridgeIdentity, + readPrivateJson, + saveBridgeIdentity, + savePrivateJson, +} from './storage.js'; +import { checkSetupCommands, setupCodeApiUrl, setupWorkspaceDirectory } from './setup.js'; +import { + assertWorkerProfileIsolation, + defaultWorkerProfilePath, + loadWorkerProfile, + saveWorkerProfile, + workerProfileArguments, +} from './worker-profile.js'; +import type { WorkerProfile } from './worker-profile.js'; +import type { PairedBridgeWorkerIdentity } from './pairing.js'; + +export interface BrowserSetupAttempt { + version: 1; + request: BrowserSetupRequest; + privateKey: string; + profile: WorkerProfile; + approval?: BrowserSetupApproval; + identity?: PairedBridgeWorkerIdentity; +} + +export type BrowserSetupApproval = EnrollmentRedemption & { codeApiUrl: string }; + +export interface BrowserSetupOptions { + instance: string; + machineName: string; + profilePath?: string; + identityPath?: string; + workspaceDirectory?: string; + allowWorkspaceWrites: boolean; + allowWorkspaceCommands: boolean; + signal?: AbortSignal; +} + +interface SetupStatus { + version: 1; + status: 'pending' | 'approved' | 'completed' | 'denied' | 'cancelled' | 'expired'; + expiresAt: string; + verificationUrl: string; + verificationCode: string; + approval?: BrowserSetupApproval; + credential?: { protocolVersion: 1; workerId: string; credential: string; expiresAt: string }; +} + +interface BrowserSetupDependencies { + fetchImpl?: typeof fetch; + checkCommands?: typeof checkSetupCommands; + onVerification?: (status: Pick) => void | Promise; + pollIntervalMs?: number; + saveProfile?: typeof saveWorkerProfile; + saveIdentity?: typeof saveBridgeIdentity; + recoverCredential?: typeof recoverWorkerCredential; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function setupInstanceUrl(value: string): string { + return setupCodeApiUrl(value); +} + +export function browserSetupAttemptPath(profilePath: string): string { + return `${resolve(profilePath)}.setup.json`; +} + +export async function loadBrowserSetupAttempt(profilePath: string): Promise { + let value: unknown; + try { value = await readPrivateJson(browserSetupAttemptPath(profilePath), 32 * 1024); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + if (!record(value) || value.version !== 1 || !record(value.request) || !record(value.profile) || typeof value.privateKey !== 'string') + throw new Error('Invalid saved setup attempt; preserve it and choose another profile, do not replace its machine key'); + const attempt = value as unknown as BrowserSetupAttempt; + workerProfileArguments(attempt.profile); + if (setupInstanceUrl(attempt.request.instance) !== attempt.request.instance || + createPublicKey(createPrivateKey(attempt.privateKey)).export({ type: 'spki', format: 'pem' }) !== attempt.request.publicKey) + throw new Error('Saved setup key does not match its request'); + // The shared canonicalizer validates the immutable request before it is signed. + browserSetupRequestHash(attempt.request); + if (attempt.approval && (!isBrowserEnrollmentRedemptionContract(attempt.approval) || + attempt.approval.instance !== attempt.request.instance || attempt.approval.attemptId !== attempt.request.attemptId || + attempt.approval.requestHash !== browserSetupRequestHash(attempt.request) || + setupCodeApiUrl(attempt.approval.codeApiUrl) !== attempt.approval.codeApiUrl || + new URL(attempt.approval.codeApiUrl).origin !== attempt.approval.serverId)) + throw new Error('Saved approval does not match its setup request'); + if (attempt.identity && (!attempt.approval || attempt.identity.protocolVersion !== 1 || + attempt.identity.publicKey !== attempt.request.publicKey || attempt.identity.privateKey !== attempt.privateKey || + attempt.identity.workerId !== attempt.approval.workerId || attempt.identity.codeApiUrl !== attempt.approval.codeApiUrl || + typeof attempt.identity.credential !== 'string' || attempt.identity.credential.length < 32 || + attempt.identity.credential.length > 4096 || !Number.isFinite(Date.parse(attempt.identity.expiresAt)))) + throw new Error('Saved completion does not match its approved machine key'); + return attempt; +} + +async function boundedJson(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error('LibreChat returned an empty setup response'); + let size = 0; + const chunks: Uint8Array[] = []; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 32 * 1024) throw new Error('LibreChat setup response exceeds 32 KiB'); + chunks.push(value); + } + try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) as unknown; } + catch { throw new Error('LibreChat returned an invalid setup response'); } + } finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); } +} + +async function requestJson(instance: string, path: string, body: unknown, signal: AbortSignal | undefined, fetchImpl: typeof fetch): Promise { + const response = await fetchImpl(`${instance}/api/code-environments/setup/${path}`, { + method: body === undefined ? 'GET' : 'POST', + redirect: 'error', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), + }); + if (!response.ok) { + await response.body?.cancel(); + if ([404, 405, 501].includes(response.status)) + throw new Error('This LibreChat instance does not support browser-approved machine setup. Update compatible servers first, or explicitly use the existing manual pairing flow'); + throw new Error(`LibreChat setup request failed (HTTP ${response.status}). Your saved key and attempt are unchanged; rerun the same setup command to resume`); + } + return boundedJson(response); +} + +function machineBody(attempt: BrowserSetupAttempt, operation: BrowserSetupProof['operation']): object { + const proof: BrowserSetupProof = { + operation, + instance: attempt.request.instance, + attemptId: attempt.request.attemptId, + requestHash: browserSetupRequestHash(attempt.request), + timestamp: new Date().toISOString(), + nonce: randomBytes(32).toString('base64url'), + }; + return { + request: attempt.request, + proof, + signature: signBrowserSetupProof(attempt.privateKey, proof), + ...(operation === 'setup.complete' && attempt.approval ? { + redemptionSignature: signEnrollmentRedemption(attempt.privateKey, attempt.approval), + } : {}), + }; +} + +function parseStatus(value: unknown, attempt: BrowserSetupAttempt): SetupStatus { + if (!record(value) || value.version !== 1 || + !['pending', 'approved', 'completed', 'denied', 'cancelled', 'expired'].includes(String(value.status)) || + typeof value.expiresAt !== 'string' || !Number.isFinite(Date.parse(value.expiresAt)) || + Date.parse(value.expiresAt) > Date.parse(attempt.request.createdAt) + 10 * 60_000 || + typeof value.verificationUrl !== 'string' || typeof value.verificationCode !== 'string' || + !/^[A-Z0-9-]{6,20}$/.test(value.verificationCode)) + throw new Error('LibreChat returned an invalid setup session'); + let url: URL; + try { url = new URL(value.verificationUrl); } + catch { throw new Error('LibreChat returned an invalid approval destination'); } + if (url.href !== `${attempt.request.instance}/code/setup/${attempt.request.attemptId}` || + url.username || url.password || url.search || url.hash) + throw new Error('LibreChat returned an unexpected approval destination'); + if (value.approval !== undefined) { + const approval = value.approval as BrowserSetupApproval; + if (!isBrowserEnrollmentRedemptionContract(approval) || approval.operation !== 'enrollment.redeem' || + approval.instance !== attempt.request.instance || approval.attemptId !== attempt.request.attemptId || + approval.requestHash !== browserSetupRequestHash(attempt.request) || + approval.expiresAt !== value.expiresAt || typeof approval.workerId !== 'string' || + typeof approval.codeApiUrl !== 'string' || setupCodeApiUrl(approval.codeApiUrl) !== approval.codeApiUrl || + new URL(approval.codeApiUrl).origin !== approval.serverId) + throw new Error('LibreChat approval does not match this setup attempt'); + if (attempt.approval && JSON.stringify(attempt.approval) !== JSON.stringify(approval)) + throw new Error('LibreChat changed the saved approval; refusing to replace the machine binding'); + } + return value as unknown as SetupStatus; +} + +async function assertEmptyOrMatching(path: string, expected: unknown, load: (path: string) => Promise, same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right)): Promise { + const metadata = await lstat(path); + if (metadata.isFile() && metadata.size === 0) return false; + if (!same(await load(path), expected)) + throw new Error('Existing profile or identity differs from the saved setup attempt; refusing to replace it'); + return true; +} + +function sameEnrolledKey(left: unknown, right: unknown): boolean { + return record(left) && record(right) && ['protocolVersion', 'workerId', 'codeApiUrl', 'publicKey', 'privateKey'] + .every(key => left[key] === right[key]); +} + +/** Every remote mutation follows a durable, private key and immutable local request. */ +export async function setupBrowserWorker(options: BrowserSetupOptions, dependencies: BrowserSetupDependencies = {}): Promise<{ profilePath: string; profile: WorkerProfile }> { + options.signal?.throwIfAborted(); + assertPrivateStorageSupported(); + const instance = setupInstanceUrl(options.instance); + const profilePath = resolve(options.profilePath ?? defaultWorkerProfilePath()); + const attemptPath = browserSetupAttemptPath(profilePath); + // This stable inode is deliberately retained. Removing an advisory lock can split its holders. + await assertIdentityPathIsPrivate(`${attemptPath}.lock`); + return withProcessLock(`${attemptPath}.lock`, async () => { + let attempt = await loadBrowserSetupAttempt(profilePath); + const workspaceDirectory = options.workspaceDirectory === undefined ? undefined : await setupWorkspaceDirectory(options.workspaceDirectory); + const profile: WorkerProfile = { + version: 1, + identityPath: resolve(options.identityPath ?? `${profilePath}.identity.json`), + workspace: workspaceDirectory ? { type: 'directory', path: workspaceDirectory } : { type: 'private' }, + allowWorkspaceWrites: options.allowWorkspaceWrites, + allowWorkspaceCommands: options.allowWorkspaceCommands, + }; + workerProfileArguments(profile); + if (attempt && (attempt.request.instance !== instance || attempt.request.machineName !== options.machineName || JSON.stringify(attempt.profile) !== JSON.stringify(profile))) + throw new Error('Setup choices differ from the saved attempt. Resume its original instance and permissions, or explicitly select another profile'); + if (profile.allowWorkspaceCommands) await (dependencies.checkCommands ?? checkSetupCommands)(); + const reservations = []; + try { + reservations.push(await assertIdentityPathIsPrivate(profilePath, !attempt)); + reservations.push(await assertIdentityPathIsPrivate(profile.identityPath, !attempt)); + reservations.push(await assertIdentityPathIsPrivate(attemptPath, !attempt)); + await assertWorkerProfileIsolation(profilePath, profile.identityPath, workspaceDirectory); + await assertWorkerProfileIsolation(attemptPath, profilePath, workspaceDirectory); + await assertWorkerProfileIsolation(attemptPath, profile.identityPath, workspaceDirectory); + if (!attempt) { + const key = createBridgeIdentity(); + attempt = { + version: 1, + request: { + version: 1, instance, attemptId: randomBytes(32).toString('base64url'), + createdAt: new Date().toISOString(), publicKey: key.publicKey, + machineName: options.machineName, workspace: { type: profile.workspace.type }, + allowWorkspaceWrites: profile.allowWorkspaceWrites, allowWorkspaceCommands: profile.allowWorkspaceCommands, + }, + privateKey: key.privateKey, profile, + }; + browserSetupRequestHash(attempt.request); + await savePrivateJson(attemptPath, attempt); + } + const persist = () => savePrivateJson(attemptPath, attempt); + const publish = async () => { + if (!await assertEmptyOrMatching(profile.identityPath, attempt!.identity, loadBridgeIdentity, sameEnrolledKey)) + await (dependencies.saveIdentity ?? saveBridgeIdentity)(profile.identityPath, attempt!.identity!); + const profileSaved = await assertEmptyOrMatching(profilePath, profile, loadWorkerProfile); + try { if (!profileSaved) await (dependencies.saveProfile ?? saveWorkerProfile)(profilePath, profile); } + catch { throw new Error(`Machine enrolled and identity saved, but profile persistence failed. Rerun setup with this same profile to resume: ${profilePath}`); } + return { profilePath, profile }; + }; + if (attempt.identity) return await publish(); + const fetchImpl = dependencies.fetchImpl ?? fetch; + if (attempt.approval && Date.now() >= Date.parse(attempt.approval.expiresAt)) { + // Session expiry cannot undo enrollment. Recovery proves only existing trust. + const credential = await (dependencies.recoverCredential ?? recoverWorkerCredential)({ + codeApiUrl: attempt.approval.codeApiUrl, workerId: attempt.approval.workerId, + privateKey: attempt.privateKey, fetchImpl, validThroughMs: Date.now() + 60_000, + signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), + }); + attempt.identity = { ...credential, codeApiUrl: attempt.approval.codeApiUrl, + publicKey: attempt.request.publicKey, privateKey: attempt.privateKey }; + await persist(); + return await publish(); + } + const capabilities = await requestJson(instance, 'capabilities', undefined, options.signal, fetchImpl); + if (!record(capabilities) || capabilities.browserEnrollmentVersion !== 1) + throw new Error('This instance lacks compatible key-bound browser enrollment. Update compatible servers first; setup will not downgrade to manual pairing'); + let status = parseStatus(await requestJson(instance, 'start', machineBody(attempt, 'setup.start'), options.signal, fetchImpl), attempt); + await dependencies.onVerification?.(status); + for (;;) { + options.signal?.throwIfAborted(); + if (['denied', 'cancelled', 'expired'].includes(status.status)) throw new Error(`Machine setup ${status.status}. The original key and attempt were preserved; choose another profile only for an intentional new enrollment`); + if (status.approval) { attempt.approval = status.approval; await persist(); } + if (status.status === 'approved' || status.status === 'completed') { + if (!attempt.approval) throw new Error('LibreChat did not supply the key-bound approval contract'); + const completed = parseStatus(await requestJson(instance, 'complete', machineBody(attempt, 'setup.complete'), options.signal, fetchImpl), attempt); + const credential = completed.credential; + if (completed.status !== 'completed' || !credential || credential.protocolVersion !== 1 || + credential.workerId !== attempt.approval.workerId || typeof credential.credential !== 'string' || + credential.credential.length < 32 || credential.credential.length > 4096 || + !Number.isFinite(Date.parse(credential.expiresAt))) throw new Error('LibreChat returned an invalid enrollment credential'); + attempt.identity = { protocolVersion: 1, workerId: credential.workerId, credential: credential.credential, expiresAt: credential.expiresAt, + codeApiUrl: attempt.approval.codeApiUrl, publicKey: attempt.request.publicKey, privateKey: attempt.privateKey }; + // Keep replayable local state even if either final file cannot be published. + await persist(); + return await publish(); + } + if (Date.now() >= Date.parse(status.expiresAt)) throw new Error('Machine setup expired; the saved key and attempt were preserved'); + await delay(dependencies.pollIntervalMs ?? 2000, undefined, { signal: options.signal }); + status = parseStatus(await requestJson(instance, 'poll', machineBody(attempt, 'setup.poll'), options.signal, fetchImpl), attempt); + } + } finally { for (const reservation of reservations.reverse()) await reservation.release(); } + }, options.signal, false); +} + +export async function cancelBrowserSetup(profilePath: string, signal?: AbortSignal, fetchImpl: typeof fetch = fetch): Promise { + const attempt = await loadBrowserSetupAttempt(profilePath); + if (!attempt) throw new Error('No saved setup attempt exists for this profile'); + if (attempt.identity) throw new Error('This machine is already enrolled; revoke it through LibreChat instead'); + await requestJson(attempt.request.instance, 'cancel', machineBody(attempt, 'setup.cancel'), signal, fetchImpl); +} diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 510d67e4..d810097d 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -1491,8 +1491,14 @@ async function run( throw error; } worktreeRetirementScheduler?.start(); + let readinessReported = false; try { const worker = new BridgeWorker({ + onReady: () => { + if (readinessReported) return; + readinessReported = true; + process.stdout.write('librechat-code: connected and ready\n'); + }, instructionDescriptors: () => localWorkspaceTools?.instructionDescriptors() ?? Promise.resolve(undefined), codeApiUrl, token: configuredToken, @@ -1778,7 +1784,7 @@ async function main(): Promise { if (args.length === 1 && (args[0] === '--help' || args[0] === 'help')) { process.stdout.write(`Usage: librechat-code - setup Guided pairing and private profile setup + setup --instance Browser-approved enrollment and private profile setup run [--profile ] Start a saved profile or existing worker configuration pair --worker-id Existing one-time pairing flow projects --root Inspect local Git projects diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 6f94b190..d8c53f41 100644 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -1,5 +1,6 @@ export * from './protocol.js'; export * from './identity.js'; +export * from './browser-enrollment-protocol.js'; export * from './pairing.js'; export * from './storage.js'; export * from './runtime.js'; diff --git a/packages/code/src/setup-cli.ts b/packages/code/src/setup-cli.ts index 877a5c17..08262153 100644 --- a/packages/code/src/setup-cli.ts +++ b/packages/code/src/setup-cli.ts @@ -9,15 +9,30 @@ import { } from './setup.js'; import { defaultWorkerProfilePath } from './worker-profile.js'; import { isValidBridgeWorkerId } from './protocol.js'; +import { runBrowserSetupCommand } from './browser-setup-cli.js'; export const SETUP_HELP = `Usage: librechat-code setup [options] +Enroll with browser approval: + librechat-code setup --instance https://chat.example.com + +The browser uses LibreChat's normal login, including configured OAuth/OIDC. +SSH/headless machines display a URL and matching verification code. No inbound +listener or CLI identity-provider integration is needed. Interrupted setup +resumes the same saved key and attempt. Never delete it to retry an enrollment. + +Existing manual pairing: Create a private worker profile using a one-time pairing from LibreChat. Open Settings > Code Environments in LibreChat to add the machine first. Use its Code API URL, worker ID, and pairing code. This command does not sign in to LibreChat or bypass its enrollment policy. - --url Code API URL, including its /v1 prefix + --instance LibreChat instance URL for browser approval + --machine-name Display name (default: host name) + --no-browser Display approval instructions without opening a browser + --start Start the saved worker in the foreground after setup + --cancel Cancel this profile's pending browser setup + --url Manual Code API URL, including its /v1 prefix --worker-id Worker ID supplied by LibreChat --worker-dir Expose one existing project directory --default-workspace Create a private workspace when the worker starts @@ -35,6 +50,8 @@ start a background service, or change the restricted command policy. `; const VALUE_FLAGS = new Set([ + '--instance', + '--machine-name', '--url', '--worker-id', '--worker-dir', @@ -42,6 +59,9 @@ const VALUE_FLAGS = new Set([ '--identity', ]); const BOOLEAN_FLAGS = new Set([ + '--no-browser', + '--start', + '--cancel', '--default-workspace', '--allow-workspace-writes', '--allow-workspace-commands', @@ -86,8 +106,15 @@ export function parseSetupArguments( if (options.has('--worker-dir') && options.has('--default-workspace')) { throw new Error('Choose --worker-dir or --default-workspace, not both'); } + if (options.has('--instance') && ['--url', '--worker-id', '--code-stdin'].some(flag => options.has(flag))) + throw new Error('Browser approval cannot be combined with manual pairing options'); + if (!options.has('--instance') && ['--machine-name', '--no-browser', '--start', '--cancel'].some(flag => options.has(flag))) + throw new Error('Browser setup options require --instance'); + if (options.has('--non-interactive') && options.has('--instance') && !options.has('--cancel') && + !options.has('--worker-dir') && !options.has('--default-workspace')) + throw new Error('Non-interactive browser setup requires --worker-dir or --default-workspace'); if ( - options.has('--non-interactive') && + options.has('--non-interactive') && !options.has('--instance') && (!options.has('--url') || !options.has('--worker-id') || !options.has('--code-stdin') || @@ -177,6 +204,10 @@ export async function runSetupCommand(args: string[]): Promise { } }; try { + if (options.has('--instance')) { + await runBrowserSetupCommand(options, { interactive, ask, yesNo, signal: controller.signal }); + return; + } if (interactive) process.stdout.write( 'Connect a machine using the pairing supplied by LibreChat.\nOnly selected folders and permissions will be exposed.\n\n', diff --git a/packages/code/src/worker.test.ts b/packages/code/src/worker.test.ts index e26f212a..b555c1cd 100644 --- a/packages/code/src/worker.test.ts +++ b/packages/code/src/worker.test.ts @@ -75,11 +75,12 @@ test('worker confirms readiness only after local registration activation succeed onRegistered: async () => { events.push('activated'); }, + onReady: () => { events.push('observed-ready'); }, }); await worker.register(); - assert.deepEqual(events, ['registered', 'activated', 'ready']); + assert.deepEqual(events, ['registered', 'activated', 'ready', 'observed-ready']); }); test('worker does not confirm readiness when local activation fails', async () => { @@ -110,12 +111,29 @@ test('worker does not confirm readiness when local activation fails', async () = onRegistered: async () => { throw new Error('relay activation failed'); }, + onReady: () => { assert.fail('failed activation is not readiness'); }, }); await assert.rejects(worker.register(), /relay activation failed/); assert.equal(readinessRequests, 0); }); +test('worker never reports ready for an absent or negative readiness acknowledgement', async () => { + for (const ready of [undefined, false]) { + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', token: 'worker-secret', workerId: 'vm-1', incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { statefulWorkspace: false, sandboxProfile: 'nsjail', runtimes: ['bash'], requiresReadyConfirmation: true }, + fetchImpl: async input => Response.json(String(input).endsWith('/ready') + ? { protocolVersion: 1, ready } + : { protocolVersion: 1, workerId: 'vm-1', incarnationId, registrationGeneration: 1, + registeredAt: new Date().toISOString(), leaseTtlMs: 60_000 }), + onReady: () => { assert.fail('readiness was not confirmed'); }, + }); + await assert.rejects(worker.register(), /did not confirm worker readiness/); + } +}); + test('worker forwards a fenced assignment to the sandbox and settles the result', async () => { const requests: Array<{ url: string; init?: RequestInit }> = []; const fetchImpl: typeof fetch = async (input, init) => { diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 6b09f257..8978d4cd 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -98,6 +98,8 @@ export interface BridgeWorkerOptions { onRegistered?: ( registration: BridgeWorkerRegistrationResponse, ) => void | Promise; + /** Called only after registration and required readiness confirmation succeed. */ + onReady?: () => void | Promise; incarnationId?: string; } @@ -840,6 +842,7 @@ export class BridgeWorker { await this.confirmReady(registration, signal); } this.lastRegisteredAtMs = registrationStartedAtMs; + if (!this.maintenanceOnly) await this.options.onReady?.(); return registration; } @@ -856,7 +859,7 @@ export class BridgeWorker { 'Code API does not support explicit worker readiness confirmation', ); } - await this.timedRequest( + const ready = await this.timedRequest<{ ready?: boolean }>( `${this.codeApiUrl}${bridgeWorkerPath(this.options.workerId)}/ready`, { protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -870,6 +873,9 @@ export class BridgeWorker { ), signal, ); + if (ready.ready !== true) { + throw new BridgeProtocolError('Code API did not confirm worker readiness'); + } } async resetWorkspace( diff --git a/service/src/bridge/browser-enrollment-router.test.ts b/service/src/bridge/browser-enrollment-router.test.ts new file mode 100644 index 00000000..b3bd04c3 --- /dev/null +++ b/service/src/bridge/browser-enrollment-router.test.ts @@ -0,0 +1,178 @@ +import { randomBytes } from 'crypto'; +import { createServer, type Server } from 'http'; + +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import express, { json } from 'express'; +import Redis from 'ioredis'; +import RedisMock from 'ioredis-mock'; + +import { + browserSetupRequestHash, + signEnrollmentRedemption, +} from '../../../packages/code/src/browser-enrollment-protocol'; +import type { BrowserEnrollmentApproval, BrowserEnrollmentRedemption } from '../../../packages/code/src/browser-enrollment-protocol'; +import { createBridgeIdentity } from '../../../packages/code/src/identity'; +import { RedisBridgePairingStore } from './pairing'; +import type { BridgeRecoveryOptions } from './pairing'; +import { createBridgeRouter } from './router'; +import type { BridgeRouterOptions } from './router'; +import { RedisBridgeStore } from './store'; + +const redis = process.env.BROWSER_ENROLLMENT_REDIS_PORT + ? new Redis({ host: '127.0.0.1', port: Number(process.env.BROWSER_ENROLLMENT_REDIS_PORT) }) + : new RedisMock() as unknown as Redis; +const serverId = 'https://code.example.test'; +const adminToken = 'browser-enrollment-test-admin'; +const identity = createBridgeIdentity(); +const outsider = createBridgeIdentity(); +const servers: Server[] = []; + +function pairings(options: Partial = {}): RedisBridgePairingStore { + return new RedisBridgePairingStore(redis, 600, 900, 5_000, '', { serverId, ...options }); +} + +async function router(options: Partial = {}): Promise { + const app = express(); + app.set('trust proxy', 1); + app.use(json()); + app.use('/bridge', createBridgeRouter({ + store: new RedisBridgeStore(redis), pairings: pairings(), authMode: 'paired', adminToken, + allowDynamicWorkers: true, configuredWorkerId: 'browser-http-worker', ...options, + })); + const server = createServer(app); + servers.push(server); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') throw new Error('Expected TCP listener'); + return `http://127.0.0.1:${address.port}/bridge`; +} + +function approved(): BrowserEnrollmentApproval { + const request = { + version: 1 as const, instance: 'https://chat.example.test', attemptId: randomBytes(32).toString('base64url'), + createdAt: new Date().toISOString(), publicKey: identity.publicKey, machineName: 'HTTP worker', + workspace: { type: 'private' as const }, allowWorkspaceWrites: false, allowWorkspaceCommands: false, + }; + return { + protocolVersion: 1, operation: 'enrollment.redeem', serverId, instance: request.instance, + attemptId: request.attemptId, requestHash: browserSetupRequestHash(request), workerId: 'browser-http-worker', + expiresAt: new Date(Date.now() + 60_000).toISOString(), publicKey: identity.publicKey, + binding: { tenantId: 'tenant-one', principal: { type: 'user', id: 'owner-one' } }, + }; +} + +function proof(approval: BrowserEnrollmentApproval, privateKey = identity.privateKey): BrowserEnrollmentRedemption { + const { publicKey: _publicKey, binding: _binding, ...contract } = approval; + return { ...contract, signature: signEnrollmentRedemption(privateKey, contract) }; +} + +function post(url: string, body: object, token = adminToken, headers: Record = {}): Promise { + return fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...headers }, + body: JSON.stringify(body), + }); +} + +async function capability(url: string, token = adminToken): Promise { + return fetch(`${url}/enrollments/capabilities`, { headers: { Authorization: `Bearer ${token}` } }); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections(); + }))); + await redis.flushall(); +}); +afterAll(() => { redis.disconnect(); }); + +describe('browser enrollment HTTP boundary', () => { + test('advertises the version only for paired auth, durable recovery, dynamic workers and an authenticated admin', async () => { + const url = await router(); + const response = await capability(url); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ browserEnrollmentVersion: 1, serverId }); + expect((await capability(url, 'outsider')).status).toBe(401); + for (const options of [ + { enabled: false }, { authMode: 'static' as const }, { allowDynamicWorkers: false }, + { pairings: new RedisBridgePairingStore(redis) }, + ]) { + const disabled = await router(options); + expect((await capability(disabled)).status).toBe(404); + expect((await post(`${disabled}/enrollments`, approved())).status).toBe(404); + expect((await post(`${disabled}/enrollments/redeem`, proof(approved()))).status).toBe(404); + } + const unconfigured = await router({ adminToken: '' }); + expect((await capability(unconfigured)).status).toBe(503); + expect((await post(`${unconfigured}/enrollments`, approved())).status).toBe(503); + }); + + test('preserves old manual pairing when browser enrollment is disabled', async () => { + const url = await router({ allowDynamicWorkers: false, pairings: new RedisBridgePairingStore(redis) }); + const issue = await post(`${url}/pairings`, { workerId: 'browser-http-worker' }); + expect(issue.status).toBe(200); + const pairing = await issue.json() as { code: string }; + const result = await post(`${url}/pairings/redeem`, { + protocolVersion: 1, workerId: 'browser-http-worker', code: pairing.code, publicKey: identity.publicKey, + }, ''); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ protocolVersion: 1, workerId: 'browser-http-worker' }); + }); + + test('requires admin auth for approval, redemption and reconciliation, independently of machine proof', async () => { + const url = await router(); + const approval = approved(); + const redemption = proof(approval); + const { signature: _signature, ...statusRequest } = redemption; + for (const [path, body] of [ + ['enrollments', approval], ['enrollments/redeem', redemption], ['enrollments/status', statusRequest], + ] as const) expect((await post(`${url}/${path}`, body, 'outsider')).status).toBe(401); + expect(await redis.keys('codeapi:bridge:v1:browser-enrollment:*')).toHaveLength(0); + }); + + test('validates envelopes, binds approvals, and idempotently returns the same credential across HTTP replicas', async () => { + const first = await router(); + const second = await router(); + const approval = approved(); + for (const body of [ + { ...approval, protocolVersion: 2 }, { ...approval, publicKey: outsider.privateKey }, + { ...approval, binding: null }, { ...approval, extra: true }, + ]) expect((await post(`${first}/enrollments`, body)).status).toBe(400); + const issued = await post(`${first}/enrollments`, approval); + expect(issued.status).toBe(200); + expect(await issued.json()).toEqual(approval); + expect((await post(`${second}/enrollments`, approval)).status).toBe(200); + expect((await post(`${second}/enrollments`, { ...approval, publicKey: outsider.publicKey })).status).toBe(409); + expect((await post(`${first}/enrollments/redeem`, { ...proof(approval), signature: '?' })).status).toBe(400); + expect((await post(`${first}/enrollments/redeem`, { ...proof(approval), operation: 'setup.complete' })).status).toBe(400); + expect((await post(`${first}/enrollments/redeem`, proof(approval, outsider.privateKey))).status).toBe(401); + expect((await post(`${first}/enrollments/redeem`, proof({ ...approval, serverId: 'https://other.example.test' }))).status).toBe(401); + expect((await post(`${first}/enrollments/redeem`, proof({ ...approval, instance: 'https://other.example.test' }))).status).toBe(409); + const responses = await Promise.all([first, second].map(url => post(`${url}/enrollments/redeem`, proof(approval)))); + expect(responses.map(response => response.status)).toEqual([200, 200]); + const [original, replay] = await Promise.all(responses.map(response => response.json())); + expect(original).toEqual(replay); + expect(original).toMatchObject({ protocolVersion: 1, workerId: approval.workerId }); + expect(JSON.stringify(original)).not.toMatch(/tenant-one|owner-one|publicKey|privateKey|binding/); + const { signature: _signature, ...statusRequest } = proof(approval); + expect((await post(`${second}/enrollments/status`, statusRequest)).status).toBe(200); + expect((await post(`${second}/enrollments/status`, { ...statusRequest, requestHash: 'f'.repeat(64) })).status).toBe(409); + expect((await post(`${first}/workers/${approval.workerId}/revoke`, {})).status).toBe(200); + expect((await post(`${second}/enrollments/redeem`, proof(approval))).status).toBe(409); + expect((await post(`${second}/enrollments/status`, statusRequest)).status).toBe(409); + }); + + test('shares a connection-peer budget across replicas and ignores spoofed forwarded IPs', async () => { + const first = await router({ pairings: pairings({ maxUntrustedRequestsPerMinute: 2 }) }); + const second = await router({ pairings: pairings({ maxUntrustedRequestsPerMinute: 2 }) }); + const approval = approved(); + await post(`${first}/enrollments`, approval); + const forged = proof(approval, outsider.privateKey); + expect((await post(`${first}/enrollments/redeem`, forged, adminToken, { 'X-Forwarded-For': '198.51.100.1' })).status).toBe(401); + expect((await post(`${second}/enrollments/redeem`, forged, adminToken, { 'X-Forwarded-For': '198.51.100.2' })).status).toBe(401); + const blocked = await post(`${second}/enrollments/redeem`, proof(approved()), adminToken, { 'X-Forwarded-For': '198.51.100.3' }); + expect(blocked.status).toBe(429); + expect(blocked.headers.get('retry-after')).toBe('60'); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + }); +}); diff --git a/service/src/bridge/browser-enrollment.test.ts b/service/src/bridge/browser-enrollment.test.ts new file mode 100644 index 00000000..af6f715e --- /dev/null +++ b/service/src/bridge/browser-enrollment.test.ts @@ -0,0 +1,413 @@ +import { createHash, randomBytes } from 'crypto'; + +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import Redis from 'ioredis'; +import RedisMock from 'ioredis-mock'; + +import { + browserSetupRequestHash, + canonicalBrowserSetupProof, + canonicalBrowserSetupRequest, + canonicalEnrollmentRedemption, + isBrowserEnrollmentApproval, + isBrowserEnrollmentRedemption, + normalizeBrowserEnrollmentInstance, + signBrowserSetupProof, + signEnrollmentRedemption, + validateBrowserSetupProof, + validateBrowserSetupRequest, + verifyBrowserSetupProof, + verifyEnrollmentRedemption, +} from '../../../packages/code/src/browser-enrollment-protocol'; +import type { + BrowserEnrollmentApproval, + BrowserEnrollmentRedemption, + BrowserSetupProof, + BrowserSetupRequest, +} from '../../../packages/code/src/browser-enrollment-protocol'; +import { + createBridgeIdentity, + signBridgeRecovery, + signBridgeRecoveryStart, + signBridgeRequest, +} from '../../../packages/code/src/identity'; +import { RedisBridgePairingStore } from './pairing'; +import type { BridgeRecoveryOptions, BridgeWorkerCredential } from './pairing'; +import { RedisBridgeStore } from './store'; + +const redis = process.env.BROWSER_ENROLLMENT_REDIS_PORT + ? new Redis({ host: '127.0.0.1', port: Number(process.env.BROWSER_ENROLLMENT_REDIS_PORT) }) + : new RedisMock() as unknown as Redis; +const serverId = 'https://code.example.test'; +const adminToken = 'test-browser-enrollment-administrator-secret'; +const identity = createBridgeIdentity(); +const outsider = createBridgeIdentity(); +const workerId = 'browser-enrolled-worker'; + +function store(options: Partial = {}, credentialTtl = 900): RedisBridgePairingStore { + return new RedisBridgePairingStore(redis, 600, credentialTtl, 5_000, '', { serverId, ...options }); +} + +function request(): BrowserSetupRequest { + return { + version: 1, + instance: 'https://chat.example.test/base', + attemptId: randomBytes(32).toString('base64url'), + createdAt: new Date().toISOString(), + publicKey: identity.publicKey, + machineName: 'My VM (development)', + workspace: { type: 'directory' }, + allowWorkspaceWrites: true, + allowWorkspaceCommands: false, + }; +} + +function approval(overrides: Partial = {}): BrowserEnrollmentApproval { + const setup = request(); + return { + protocolVersion: 1, + operation: 'enrollment.redeem', + serverId, + instance: setup.instance, + attemptId: setup.attemptId, + requestHash: browserSetupRequestHash(setup), + workerId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + publicKey: identity.publicKey, + binding: { tenantId: 'tenant-one', principal: { type: 'user', id: 'owner-one' } }, + ...overrides, + }; +} + +function redemption(input: BrowserEnrollmentApproval, privateKey = identity.privateKey): BrowserEnrollmentRedemption { + const { operation, serverId, instance, attemptId, requestHash, workerId, expiresAt } = input; + const contract = { operation, serverId, instance, attemptId, requestHash, workerId, expiresAt }; + return { protocolVersion: 1, ...contract, signature: signEnrollmentRedemption(privateKey, contract) }; +} + +function authorized(credential: BridgeWorkerCredential): Parameters[0] { + const proof = { + credential: credential.credential, + method: 'POST', + path: `/bridge/workers/${workerId}/lease`, + timestamp: new Date().toISOString(), + nonce: randomBytes(32).toString('base64url'), + body: '{}', + }; + return { ...proof, workerId, signature: signBridgeRequest(identity.privateKey, proof) }; +} + +afterEach(async () => { await redis.flushall(); }); +afterAll(() => { redis.disconnect(); }); + +describe('browser enrollment proof contract', () => { + test('canonicalizes fixed request fields and preserves every authorization choice', () => { + const input = request(); + expect(normalizeBrowserEnrollmentInstance('https://CHAT.example.test:443/base///')).toBe(input.instance); + expect(() => normalizeBrowserEnrollmentInstance('http://untrusted.example.test')).toThrow(); + expect(() => normalizeBrowserEnrollmentInstance('https://user:password@chat.example.test')).toThrow(); + expect(() => normalizeBrowserEnrollmentInstance('https://chat.example.test/?query=1')).toThrow(); + const canonical = canonicalBrowserSetupRequest(input); + expect(Object.keys(JSON.parse(canonical))).toEqual([ + 'version', 'instance', 'attemptId', 'createdAt', 'publicKey', 'machineName', 'workspace', + 'allowWorkspaceWrites', 'allowWorkspaceCommands', + ]); + expect(browserSetupRequestHash(input)).toBe(createHash('sha256').update(canonical).digest('hex')); + const reordered = Object.fromEntries(Object.entries(input).reverse()) as unknown as BrowserSetupRequest; + expect(browserSetupRequestHash(reordered)).toBe(browserSetupRequestHash(input)); + for (const change of [ + { allowWorkspaceWrites: false }, { allowWorkspaceCommands: true }, { workspace: { type: 'private' as const } }, + { machineName: 'Different VM' }, { publicKey: outsider.publicKey }, + ]) expect(browserSetupRequestHash({ ...input, ...change })).not.toBe(browserSetupRequestHash(input)); + }); + + test('rejects noncanonical requests and malformed proofs without throwing from verification', () => { + const input = request(); + for (const malformed of [ + null, [], { ...input, extra: true }, { ...input, version: 2 }, { ...input, machineName: 'x\ny' }, + { ...input, machineName: 'x'.repeat(81) }, { ...input, machineName: ' leading' }, + { ...input, instance: `${input.instance}/` }, { ...input, attemptId: 'x'.repeat(43) }, + { ...input, createdAt: '2026-10-21' }, { ...input, publicKey: outsider.privateKey }, + { ...input, workspace: { type: 'directory', path: '/secret' } }, { ...input, allowWorkspaceWrites: 1 }, + ]) expect(validateBrowserSetupRequest(malformed)).toBe(false); + expect(verifyBrowserSetupProof('invalid key', {} as BrowserSetupProof, '!')).toBe(false); + }); + + test('binds operation, audience, attempt, request hash, time and nonce, with separate signing domains', () => { + const input = request(); + const proof: BrowserSetupProof = { + operation: 'setup.start', instance: input.instance, attemptId: input.attemptId, + requestHash: browserSetupRequestHash(input), timestamp: input.createdAt, + nonce: randomBytes(32).toString('base64url'), + }; + const signature = signBrowserSetupProof(identity.privateKey, proof); + expect(canonicalBrowserSetupProof(proof).split('\n')).toEqual([ + 'librechat-code:browser-setup:v1', proof.operation, proof.instance, proof.attemptId, + proof.requestHash, proof.timestamp, proof.nonce, + ]); + expect(validateBrowserSetupProof(proof)).toBe(true); + expect(verifyBrowserSetupProof(identity.publicKey, proof, signature)).toBe(true); + expect(verifyBrowserSetupProof(outsider.publicKey, proof, signature)).toBe(false); + for (const change of [ + { operation: 'setup.poll' as const }, { operation: 'setup.cancel' as const }, { operation: 'setup.complete' as const }, + { instance: 'https://different.example.test' }, { attemptId: randomBytes(32).toString('base64url') }, + { requestHash: 'f'.repeat(64) }, { timestamp: new Date(Date.now() + 1_000).toISOString() }, + { nonce: randomBytes(32).toString('base64url') }, + ]) expect(verifyBrowserSetupProof(identity.publicKey, { ...proof, ...change }, signature)).toBe(false); + const approved = approval({ instance: proof.instance, attemptId: proof.attemptId, requestHash: proof.requestHash }); + expect(canonicalEnrollmentRedemption(approved).split('\n')).toEqual([ + 'librechat-code:browser-enrollment-redeem:v1', approved.operation, approved.serverId, + approved.instance, approved.attemptId, approved.requestHash, approved.workerId, approved.expiresAt, + ]); + expect(verifyEnrollmentRedemption(identity.publicKey, approved, signature)).toBe(false); + expect(verifyBrowserSetupProof(identity.publicKey, proof, signEnrollmentRedemption(identity.privateKey, approved))).toBe(false); + expect(isBrowserEnrollmentRedemption({ ...redemption(approved), publicKey: identity.publicKey })).toBe(false); + expect(isBrowserEnrollmentApproval({ ...approved, binding: { tenantId: 'x', principal: { type: 'bad', id: 'x' } } })).toBe(false); + }); +}); + +describe('Redis browser enrollment ownership', () => { + test('retries lost issue and redemption responses across replicas without rotating keys or widening access', async () => { + const first = store(); + const approved = approval(); + await first.issueBrowserEnrollment(approved); + const second = store(); + expect(await second.issueBrowserEnrollment(approved)).toEqual(approved); + const original = await first.redeemBrowserEnrollment(redemption(approved), adminToken); + expect(await second.issueBrowserEnrollment(approved)).toEqual(approved); + expect(await second.redeemBrowserEnrollment(redemption(approved), adminToken)).toEqual(original); + const auth = await second.authorize(authorized(original)); + expect(auth).toMatchObject({ workerId, binding: approved.binding, pairingGeneration: 0 }); + const enrollmentRaw = await redis.get(`codeapi:bridge:v1:enrollment:${workerId}`); + const allValues = await redis.mget(...await redis.keys('codeapi:bridge:v1:*')); + expect(allValues.join('\n')).not.toContain(original.credential); + expect(enrollmentRaw).toContain(identity.publicKey.trim().split('\n')[1]); + await expect(second.redeemBrowserEnrollment(redemption(approved), 'wrong-encryption-key')).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await redis.get(`codeapi:bridge:v1:enrollment:${workerId}`)).toBe(enrollmentRaw); + }); + + test('concurrent duplicates commit exactly one enrollment and return the identical response', async () => { + const approved = approval(); + const replicas = [store(), store(), store()]; + const issued = await Promise.all(replicas.map(replica => replica.issueBrowserEnrollment(approved))); + expect(issued).toEqual([approved, approved, approved]); + const completed = await Promise.all(replicas.map(replica => replica.redeemBrowserEnrollment(redemption(approved), adminToken))); + expect(completed[1]).toEqual(completed[0]); + expect(completed[2]).toEqual(completed[0]); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(1); + expect(await redis.keys('codeapi:bridge:v1:enrollment:*')).toHaveLength(1); + }); + + test('does not depend on binding property order and retries after worker registration', async () => { + const replica = store(); + const approved = approval({ binding: { principal: { id: 'owner-one', type: 'user' }, tenantId: 'tenant-one' } }); + await replica.issueBrowserEnrollment(approved); + const credential = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + const auth = await replica.authorize(authorized(credential)); + const workers = new RedisBridgeStore(redis); + await workers.register({ + protocolVersion: 1, workerId, incarnationId: 'browser-test-incarnation', + identityId: auth.identityId, credentialId: auth.credentialId, binding: approved.binding, + capabilities: { statefulWorkspace: false, runtimes: [] }, + }, auth); + expect(await replica.issueBrowserEnrollment(approved)).toEqual(approved); + expect(await replica.redeemBrowserEnrollment(redemption(approved), adminToken)).toEqual(credential); + }); + + test('conflicting concurrent approvals cannot replace the winner or allocate a second worker', async () => { + const approved = approval(); + const changed = { ...approved, workerId: 'other-worker', publicKey: outsider.publicKey }; + const results = await Promise.allSettled([store().issueBrowserEnrollment(approved), store().issueBrowserEnrollment(changed)]); + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter(result => result.status === 'rejected')).toHaveLength(1); + const winner = results.find(result => result.status === 'fulfilled'); + if (winner?.status !== 'fulfilled') throw new Error('Expected one committed approval'); + const key = winner.value.publicKey === identity.publicKey ? identity.privateKey : outsider.privateKey; + await store().redeemBrowserEnrollment(redemption(winner.value, key), adminToken); + expect(await redis.keys('codeapi:bridge:v1:enrollment:*')).toHaveLength(1); + }); + + test('bounded credential replay cannot remint after its TTL but durable status remains available', async () => { + const replica = store({}, 1); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + await new Promise(resolve => setTimeout(resolve, 1_100)); + await expect(store({}, 1).redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const { signature: _signature, ...statusRequest } = redemption(approved); + expect(await store().browserEnrollmentStatus(statusRequest)).toMatchObject({ status: 'completed', workerId }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + }); + + test('rejects replacement of immutable approval fields and reuse of the allocated worker', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + for (const change of [ + { workerId: 'another-worker' }, { publicKey: outsider.publicKey }, { requestHash: 'f'.repeat(64) }, + { expiresAt: new Date(Date.now() + 120_000).toISOString() }, + { binding: { tenantId: 'tenant-two', principal: { type: 'deployment' as const, id: 'everyone' } } }, + ]) await expect(replica.issueBrowserEnrollment({ ...approved, ...change })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const first = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await replica.authorize(authorized(first))).toMatchObject({ binding: approved.binding }); + }); + + test('rejects wrong key, audience, operation and every contract mismatch', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + await expect(replica.redeemBrowserEnrollment(redemption(approved, outsider.privateKey), adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + for (const change of [ + { serverId: 'https://other-code.example.test' }, { instance: 'https://other-chat.example.test' }, + { attemptId: randomBytes(32).toString('base64url') }, { requestHash: 'f'.repeat(64) }, + { workerId: 'another-worker' }, { expiresAt: new Date(Date.now() + 120_000).toISOString() }, + ]) await expect(replica.redeemBrowserEnrollment(redemption({ ...approved, ...change }), adminToken)).rejects.toBeDefined(); + await expect(replica.redeemBrowserEnrollment({ ...redemption(approved), operation: 'setup.complete' } as unknown as BrowserEnrollmentRedemption, adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + await expect(replica.redeemBrowserEnrollment({ ...redemption(approved), signature: '?' }, adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + }); + + test('never extends the immutable expiry or recreates an expired approval', async () => { + const replica = store(); + for (const expiresAt of [new Date(Date.now() - 1).toISOString(), new Date(Date.now() + 601_000).toISOString()]) { + await expect(replica.issueBrowserEnrollment(approval({ expiresAt }))).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + } + const approved = approval({ expiresAt: new Date(Date.now() + 2_000).toISOString() }); + await replica.issueBrowserEnrollment(approved); + await new Promise(resolve => setTimeout(resolve, 2_100)); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.issueBrowserEnrollment({ ...approved, workerId: 'replacement-worker', expiresAt: new Date(Date.now() + 60_000).toISOString() })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + }); + + test('durable enrollment outlives setup expiry and recovers after restart and access credential loss', async () => { + const replica = store(); + const approved = approval({ expiresAt: new Date(Date.now() + 2_000).toISOString() }); + await replica.issueBrowserEnrollment(approved); + const original = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + const originalAuth = await replica.authorize(authorized(original)); + await new Promise(resolve => setTimeout(resolve, 2_100)); + await redis.del( + `codeapi:bridge:v1:identity:${workerId}`, `codeapi:bridge:v1:stable-identity:${workerId}`, + `codeapi:bridge:v1:credential:${createHash('sha256').update(original.credential).digest('hex')}`, + ); + const restarted = store(); + const { signature: _signature, ...statusRequest } = redemption(approved); + expect(await restarted.browserEnrollmentStatus(statusRequest)).toEqual({ protocolVersion: 1, status: 'completed', workerId }); + await expect(restarted.browserEnrollmentStatus({ ...statusRequest, requestHash: 'f'.repeat(64) })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const start = { + operation: 'credential.challenge' as const, serverId, workerId, + timestamp: new Date().toISOString(), nonce: randomBytes(32).toString('base64url'), + }; + const challenge = await restarted.createRecoveryChallenge(workerId, start, signBridgeRecoveryStart(identity.privateKey, start)); + const recovered = await restarted.recoverCredential(workerId, challenge, signBridgeRecovery(identity.privateKey, challenge)); + expect(await restarted.authorize(authorized(recovered))).toMatchObject({ identityId: originalAuth.identityId, binding: approved.binding }); + await expect(restarted.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await restarted.revoke(workerId); + await expect(restarted.browserEnrollmentStatus(statusRequest)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + }); + + test('revocation fences approval, completion, exact replay and recovery without clearing quarantine', async () => { + const replica = store(); + const approved = approval(); + const quarantine = `codeapi:bridge:v1:worker:${workerId}:workspace:root:quarantined`; + await redis.set(quarantine, 'existing-fence'); + await replica.issueBrowserEnrollment(approved); + await replica.revoke(workerId); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await redis.get(quarantine)).toBe('existing-fence'); + expect(await redis.get(`codeapi:bridge:v1:pairing-generation:${workerId}`)).toBe('1'); + + const other = approval({ workerId: 'second-worker' }); + await replica.issueBrowserEnrollment(other); + const credential = await replica.redeemBrowserEnrollment(redemption(other), adminToken); + await replica.revoke(other.workerId); + await expect(replica.redeemBrowserEnrollment(redemption(other), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await redis.get(`codeapi:bridge:v1:enrollment:${other.workerId}`)).toBeNull(); + expect(credential.workerId).toBe(other.workerId); + }); + + test('manual issue supersedes only the pending fence and cannot be undone by a retry', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + const manual = await replica.issue(workerId, approved.binding); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const issued = await replica.redeem({ workerId, code: manual.code, publicKey: outsider.publicKey }); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(issued.workerId).toBe(workerId); + }); + + test('cannot replace a preexisting manual enrollment or pending pairing', async () => { + const replica = store(); + const manual = await replica.issue(workerId); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await replica.redeem({ workerId, code: manual.code, publicKey: outsider.publicKey }); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.issueBrowserEnrollment(approval({ serverId: 'https://wrong.example.test' }))).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(new RedisBridgePairingStore(redis).issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + }); + + test('per-attempt budgets are shared by replicas and wrong signatures cannot create credentials', async () => { + const approved = approval(); + const first = store({ maxAttemptsPerMinute: 2 }); + const second = store({ maxAttemptsPerMinute: 2 }); + await first.issueBrowserEnrollment(approved); + const forged = redemption(approved, outsider.privateKey); + await expect(first.redeemBrowserEnrollment(forged, adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + await expect(second.redeemBrowserEnrollment(forged, adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + await expect(first.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'RECOVERY_RATE_LIMITED' }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + }); + + test('manual issue between signature verification and completion permanently supersedes the browser attempt', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + const originalEval = redis.eval.bind(redis); + let manualCode = ''; + redis.eval = (async (...args: Parameters): Promise => { + if (!manualCode && String(args[0]).includes('local replay =')) { + manualCode = (await replica.issue(workerId)).code; + } + return originalEval(...args); + }) as Redis['eval']; + try { + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(manualCode.length).toBeGreaterThan(0); + await replica.redeem({ workerId, code: manualCode, publicKey: outsider.publicKey }); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + } finally { + redis.eval = originalEval as Redis['eval']; + } + }); + + test('atomic generation comparison wins a revoke between proof validation and completion', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + const originalEval = redis.eval.bind(redis); + let intercepted = false; + redis.eval = (async (...args: Parameters): Promise => { + if (!intercepted && String(args[0]).includes('local replay =')) { + intercepted = true; + await replica.revoke(workerId); + } + return originalEval(...args); + }) as Redis['eval']; + try { + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(intercepted).toBe(true); + expect(await redis.get(`codeapi:bridge:v1:enrollment:${workerId}`)).toBeNull(); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + } finally { + redis.eval = originalEval as Redis['eval']; + } + }); +}); diff --git a/service/src/bridge/pairing.ts b/service/src/bridge/pairing.ts index b8a32162..ee3f60c6 100644 --- a/service/src/bridge/pairing.ts +++ b/service/src/bridge/pairing.ts @@ -1,4 +1,6 @@ import { + createCipheriv, + createDecipheriv, createHash, createPublicKey, randomBytes, @@ -6,6 +8,20 @@ import { import type Redis from 'ioredis'; +import { + BROWSER_ENROLLMENT_MAX_TTL_MS, + canonicalBrowserEnrollmentRedemption, + isBrowserEnrollmentApproval, + isBrowserEnrollmentRedemption, + isBrowserEnrollmentStatusRequest, + verifyBrowserEnrollmentRedemption, +} from '../../../packages/code/src/browser-enrollment-protocol'; +import type { + BrowserEnrollmentApproval, + BrowserEnrollmentRedemption, + BrowserEnrollmentRedemptionContract, + BrowserEnrollmentStatusRequest, +} from '../../../packages/code/src/browser-enrollment-protocol'; import { verifyBridgeRecovery, verifyBridgeRecoveryStart, @@ -74,6 +90,63 @@ else end return 1 `; +const ISSUE_BROWSER_ENROLLMENT_SCRIPT = ` +local now = redis.call('TIME') +local remaining = tonumber(ARGV[3]) - (tonumber(now[1]) * 1000.0 + math.floor(tonumber(now[2]) / 1000)) +if remaining <= 0 or remaining > tonumber(ARGV[4]) then return 0 end +if (redis.call('GET', KEYS[5]) or '0') ~= '0' then return 0 end +local saved = redis.call('GET', KEYS[1]) +local tombstone = redis.call('GET', KEYS[13]) +if tombstone and (not saved or tombstone ~= ARGV[5]) then return 0 end +if saved then + if saved ~= ARGV[1] or redis.call('GET', KEYS[4]) ~= KEYS[3] or redis.call('GET', KEYS[3]) ~= 'browser-enrollment' then return 0 end + local enrollment = redis.call('GET', KEYS[8]) + if enrollment then + if enrollment ~= redis.call('GET', KEYS[2]) then return 0 end + else + for i = 6, 12 do + if i ~= 8 and redis.call('EXISTS', KEYS[i]) == 1 then return 0 end + end + end + return 1 +end +for i = 4, 12 do + if i ~= 5 and redis.call('EXISTS', KEYS[i]) == 1 then return 0 end +end +redis.call('SET', KEYS[13], ARGV[5]) +redis.call('SET', KEYS[1], ARGV[1], 'PX', remaining) +redis.call('SET', KEYS[2], ARGV[2], 'PX', remaining) +redis.call('SET', KEYS[3], 'browser-enrollment', 'PX', remaining) +redis.call('SET', KEYS[4], KEYS[3], 'PX', remaining) +return 1 +`; +const REDEEM_BROWSER_ENROLLMENT_SCRIPT = ` +local now = redis.call('TIME') +local remaining = tonumber(ARGV[3]) - (tonumber(now[1]) * 1000.0 + math.floor(tonumber(now[2]) / 1000)) +if remaining <= 0 then return nil end +if redis.call('GET', KEYS[1]) ~= ARGV[1] or redis.call('GET', KEYS[2]) ~= ARGV[2] or + redis.call('GET', KEYS[3]) ~= 'browser-enrollment' or redis.call('GET', KEYS[4]) ~= KEYS[3] or + (redis.call('GET', KEYS[5]) or '0') ~= '0' then return nil end +local replay = redis.call('GET', KEYS[11]) +if replay then + if redis.call('GET', KEYS[9]) ~= ARGV[2] or redis.call('GET', KEYS[10]) ~= ARGV[9] then return nil end + return replay +end +for i = 7, 14 do + if i ~= 11 and redis.call('EXISTS', KEYS[i]) == 1 then return nil end +end +redis.call('SET', KEYS[6], ARGV[4], 'EX', ARGV[5]) +redis.call('SET', KEYS[7], ARGV[6], 'EX', ARGV[5]) +redis.call('SET', KEYS[8], ARGV[7], 'EX', ARGV[5]) +if tonumber(ARGV[8]) > 0 then + redis.call('SET', KEYS[9], ARGV[2], 'EX', ARGV[8]) +else + redis.call('SET', KEYS[9], ARGV[2]) +end +redis.call('SET', KEYS[10], ARGV[9]) +redis.call('SET', KEYS[11], ARGV[10], 'PX', math.min(remaining, tonumber(ARGV[5]) * 1000)) +return ARGV[10] +`; const ROTATE_CREDENTIAL_SCRIPT = ` if ARGV[6] ~= '' then if redis.call('GET', KEYS[5]) ~= ARGV[6] or (redis.call('GET', KEYS[6]) or '0') ~= ARGV[7] or redis.call('GET', KEYS[7]) ~= ARGV[8] then @@ -127,7 +200,7 @@ if redis.call('EXISTS', KEYS[6]) == 1 then return -2 end local count = redis.call('INCR', KEYS[2]) if count == 1 then redis.call('EXPIRE', KEYS[2], 60) end if count > tonumber(ARGV[2]) then return -1 end -if redis.call('SET', KEYS[3], ARGV[3], 'EX', ARGV[5], 'NX') ~= 'OK' then return 0 end +if not redis.call('SET', KEYS[3], ARGV[3], 'EX', ARGV[5], 'NX') then return 0 end redis.call('SET', KEYS[6], '1', 'EX', ARGV[7]) return 1 `; @@ -223,6 +296,7 @@ interface StoredEnrollment { pairingGeneration: number; publicKey: string; binding?: BridgeWorkerBinding; + browserEnrollment?: BrowserEnrollmentRedemptionContract; } export interface BridgeRecoveryOptions { @@ -277,6 +351,54 @@ function pairingKey(code: string): string { return `${PREFIX}:pairing:${digest(code)}`; } +function browserEnrollmentKey(instance: string, attemptId: string): string { + return `${PREFIX}:browser-enrollment:${digest(JSON.stringify([instance, attemptId]))}`; +} + +function canonicalApproval(approval: BrowserEnrollmentApproval): string { + return JSON.stringify({ + protocolVersion: 1, + operation: approval.operation, + serverId: approval.serverId, + instance: approval.instance, + attemptId: approval.attemptId, + requestHash: approval.requestHash, + workerId: approval.workerId, + expiresAt: approval.expiresAt, + publicKey: approval.publicKey, + binding: { + tenantId: approval.binding.tenantId, + principal: { type: approval.binding.principal.type, id: approval.binding.principal.id }, + }, + }); +} + +function enrollmentReplayKey(adminToken: string, serverId: string): Buffer { + if (!adminToken) throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment requires an administrator token'); + return createHash('sha256').update('librechat-code:browser-enrollment-replay:v1\0') + .update(serverId).update('\0').update(adminToken).digest(); +} + +function encryptEnrollmentReplay(credential: BridgeWorkerCredential, approval: string, key: Buffer): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + cipher.setAAD(Buffer.from(approval)); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(credential), 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url'); +} + +function decryptEnrollmentReplay(ciphertext: string, approval: string, key: Buffer): BridgeWorkerCredential { + try { + const bytes = Buffer.from(ciphertext, 'base64url'); + const decipher = createDecipheriv('aes-256-gcm', key, bytes.subarray(0, 12)); + decipher.setAAD(Buffer.from(approval)); + decipher.setAuthTag(bytes.subarray(12, 28)); + return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8')) as BridgeWorkerCredential; + } catch { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment replay is unavailable'); + } +} + function credentialDigestKey(credentialDigest: string): string { return `${PREFIX}:credential:${credentialDigest}`; } @@ -408,6 +530,193 @@ export class RedisBridgePairingStore { return this.recovery != null; } + get recoveryServerId(): string | undefined { + return this.recovery?.serverId; + } + + async limitBrowserEnrollmentPeer(peer: string): Promise { + if (this.recovery == null) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is disabled'); + } + const allowed = await this.redis.eval( + LIMIT_RECOVERY_ATTEMPTS_SCRIPT, + 1, + `${PREFIX}:browser-enrollment:peer:${digest(peer)}`, + String(this.recovery.maxUntrustedRequestsPerMinute ?? DEFAULT_UNTRUSTED_RECOVERY_REQUESTS_PER_MINUTE), + ); + if (allowed !== 1) { + throw new BridgePairingError('RECOVERY_RATE_LIMITED', 'Too many browser enrollment requests from this peer'); + } + } + + async issueBrowserEnrollment(approval: BrowserEnrollmentApproval): Promise { + if (!isBrowserEnrollmentApproval(approval) || this.recovery == null || approval.serverId !== this.recovery.serverId || + Date.parse(approval.expiresAt) <= Date.now() || Date.parse(approval.expiresAt) - Date.now() > BROWSER_ENROLLMENT_MAX_TTL_MS) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Invalid browser enrollment approval'); + } + const approvalRaw = canonicalApproval(approval); + const normalized = JSON.parse(approvalRaw) as BrowserEnrollmentApproval; + const enrollment: StoredEnrollment = { + workerId: approval.workerId, + serverId: approval.serverId, + identityId: randomBytes(18).toString('base64url'), + generation: randomBytes(18).toString('base64url'), + pairingGeneration: 0, + browserEnrollment: { + operation: approval.operation, + serverId: approval.serverId, + instance: approval.instance, + attemptId: approval.attemptId, + requestHash: approval.requestHash, + workerId: approval.workerId, + expiresAt: approval.expiresAt, + }, + publicKey: approval.publicKey, + binding: normalized.binding, + }; + const key = browserEnrollmentKey(approval.instance, approval.attemptId); + const workerPrefix = `${PREFIX}:worker:${encodeURIComponent(approval.workerId)}`; + // The manual-pairing index points at a separate fence, never the immutable + // approval. Manual issue/revoke consumes that fence without erasing the tombstone. + const accepted = await this.redis.eval( + ISSUE_BROWSER_ENROLLMENT_SCRIPT, + 13, + key, + `${key}:enrollment`, + `${key}:fence`, + workerPairingIndexKey(approval.workerId), + workerPairingGenerationKey(approval.workerId), + workerIdentityKey(approval.workerId), + workerStableIdentityKey(approval.workerId), + workerEnrollmentKey(approval.workerId), + workerEnrollmentRequiredKey(approval.workerId), + workerPrefix, + `${workerPrefix}:incarnation`, + `${workerPrefix}:registration-generation`, + `${key}:tombstone`, + approvalRaw, + JSON.stringify(enrollment), + String(Date.parse(approval.expiresAt)), + String(BROWSER_ENROLLMENT_MAX_TTL_MS), + digest(approvalRaw), + ); + if (accepted !== 1) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is expired, revoked, superseded, or conflicts with an existing worker'); + } + return normalized; + } + + async browserEnrollmentStatus(proof: BrowserEnrollmentStatusRequest): Promise<{ + protocolVersion: 1; + status: 'completed'; + workerId: string; + }> { + if (!isBrowserEnrollmentStatusRequest(proof) || this.recovery == null || proof.serverId !== this.recovery.serverId) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Invalid browser enrollment status request'); + } + const [raw, generation, requiredGeneration] = await this.redis.mget( + workerEnrollmentKey(proof.workerId), + workerPairingGenerationKey(proof.workerId), + workerEnrollmentRequiredKey(proof.workerId), + ); + const enrollment = this.checkedEnrollment(proof.workerId, raw, generation, requiredGeneration); + if (enrollment.browserEnrollment == null || + canonicalBrowserEnrollmentRedemption(enrollment.browserEnrollment) !== canonicalBrowserEnrollmentRedemption(proof)) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment does not match its durable contract'); + } + return { protocolVersion: 1, status: 'completed', workerId: proof.workerId }; + } + + async redeemBrowserEnrollment( + proof: BrowserEnrollmentRedemption, + adminToken: string, + ): Promise { + if (!isBrowserEnrollmentRedemption(proof) || this.recovery == null || proof.serverId !== this.recovery.serverId) { + throw new BridgePairingError('PROOF_INVALID', 'Invalid browser enrollment proof'); + } + if (Date.parse(proof.expiresAt) <= Date.now()) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment approval has expired'); + } + const replayKey = enrollmentReplayKey(adminToken, proof.serverId); + const key = browserEnrollmentKey(proof.instance, proof.attemptId); + const attempts = await this.redis.eval( + LIMIT_RECOVERY_ATTEMPTS_SCRIPT, + 1, + `${key}:attempts`, + String(this.recovery.maxAttemptsPerMinute ?? DEFAULT_RECOVERY_ATTEMPTS_PER_MINUTE), + ); + if (attempts !== 1) { + throw new BridgePairingError('RECOVERY_RATE_LIMITED', 'Too many browser enrollment attempts'); + } + const [raw, enrollmentRaw] = await this.redis.mget(key, `${key}:enrollment`); + let approval: BrowserEnrollmentApproval; + let enrollment: StoredEnrollment; + try { + approval = JSON.parse(raw ?? 'null') as BrowserEnrollmentApproval; + enrollment = JSON.parse(enrollmentRaw ?? 'null') as StoredEnrollment; + if (!isBrowserEnrollmentApproval(approval) || canonicalBrowserEnrollmentRedemption(approval) !== canonicalBrowserEnrollmentRedemption(proof) || + enrollment?.workerId !== approval.workerId || enrollment.serverId !== approval.serverId || + enrollment.publicKey !== approval.publicKey || enrollment.pairingGeneration !== 0 || + enrollment.browserEnrollment == null || canonicalBrowserEnrollmentRedemption(enrollment.browserEnrollment) !== canonicalBrowserEnrollmentRedemption(proof) || + JSON.stringify(enrollment.binding) !== JSON.stringify(approval.binding) || + !/^[A-Za-z0-9_-]{24}$/.test(enrollment.identityId) || !/^[A-Za-z0-9_-]{24}$/.test(enrollment.generation)) { + throw new Error('Invalid approval'); + } + } catch { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is unavailable or does not match its approval'); + } + if (!verifyBrowserEnrollmentRedemption(approval.publicKey, proof, proof.signature)) { + throw new BridgePairingError('PROOF_INVALID', 'Browser enrollment requires the approved machine key'); + } + const credential: BridgeWorkerCredential = { + workerId: approval.workerId, + credential: randomBytes(32).toString('base64url'), + expiresAt: new Date(Date.now() + this.credentialTtlSeconds * 1000).toISOString(), + }; + const credentialDigest = digest(credential.credential); + const stored: StoredCredential = { + workerId: approval.workerId, + identityId: enrollment.identityId, + publicKey: approval.publicKey, + expiresAt: credential.expiresAt, + binding: approval.binding, + enrollmentGeneration: enrollment.generation, + }; + const workerPrefix = `${PREFIX}:worker:${encodeURIComponent(approval.workerId)}`; + const encrypted = await this.redis.eval( + REDEEM_BROWSER_ENROLLMENT_SCRIPT, + 14, + key, + `${key}:enrollment`, + `${key}:fence`, + workerPairingIndexKey(approval.workerId), + workerPairingGenerationKey(approval.workerId), + credentialDigestKey(credentialDigest), + workerIdentityKey(approval.workerId), + workerStableIdentityKey(approval.workerId), + workerEnrollmentKey(approval.workerId), + workerEnrollmentRequiredKey(approval.workerId), + `${key}:result`, + workerPrefix, + `${workerPrefix}:incarnation`, + `${workerPrefix}:registration-generation`, + raw!, + enrollmentRaw!, + String(Date.parse(approval.expiresAt)), + JSON.stringify(stored), + String(this.credentialTtlSeconds), + credentialDigest, + enrollment.identityId, + String(this.recovery.enrollmentTtlSeconds ?? 0), + enrollment.generation, + encryptEnrollmentReplay(credential, raw!, replayKey), + ); + if (typeof encrypted !== 'string') { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is expired, revoked, or superseded'); + } + return decryptEnrollmentReplay(encrypted, raw!, replayKey); + } + async limitUntrustedRecovery( peer: string, operation: 'challenge' | 'recover', diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index 123f8c75..d035b032 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -6,6 +6,12 @@ import type { BridgeWorkerRegistration } from '../../../packages/code/src/protoc import type { BridgePrincipalType, BridgeWorkerBinding } from './pairing'; import type { CodeBridgeAssignment, CodeBridgeSettlement } from './store'; +import { + BROWSER_ENROLLMENT_PROTOCOL_VERSION, + isBrowserEnrollmentApproval, + isBrowserEnrollmentRedemption, + isBrowserEnrollmentStatusRequest, +} from '../../../packages/code/src/browser-enrollment-protocol'; import { BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, BRIDGE_PROTOCOL_VERSION, @@ -243,6 +249,73 @@ export function createBridgeRouter(options: BridgeRouterOptions): Router { const workerAuth = options.authMode === 'paired' ? pairedWorkerAuth : staticWorkerAuth; + const browserEnrollmentEnabled = options.authMode === 'paired' && + options.pairings.recoveryEnabled && options.allowDynamicWorkers === true && + options.adminToken.length > 0; + + const browserEnrollmentAvailable: RequestHandler = (_req, res, next) => { + if (!browserEnrollmentEnabled) { + res.status(404).json({ error: 'Browser enrollment is disabled' }); + return; + } + next(); + }; + + const sendEnrollmentError = (error: unknown, res: Response): void => { + if (!(error instanceof BridgePairingError)) throw error; + if (error.code === 'RECOVERY_RATE_LIMITED') { + res.set('Retry-After', '60').status(429).json({ error: error.message, code: error.code }); + return; + } + res.status(error.code === 'PROOF_INVALID' ? 401 : 409) + .json({ error: error.message, code: error.code }); + }; + + router.get('/enrollments/capabilities', adminAuth, browserEnrollmentAvailable, (_req, res) => { + res.json({ + browserEnrollmentVersion: BROWSER_ENROLLMENT_PROTOCOL_VERSION, + serverId: options.pairings.recoveryServerId, + }); + }); + + router.post('/enrollments', adminAuth, browserEnrollmentAvailable, asyncRoute(async (req, res) => { + if (!isBrowserEnrollmentApproval(req.body)) { + res.status(400).json({ error: 'Invalid browser enrollment approval' }); + return; + } + try { + res.json(await options.pairings.issueBrowserEnrollment(req.body)); + } catch (error) { + sendEnrollmentError(error, res); + } + })); + + router.post('/enrollments/status', adminAuth, browserEnrollmentAvailable, asyncRoute(async (req, res) => { + if (!isBrowserEnrollmentStatusRequest(req.body)) { + res.status(400).json({ error: 'Invalid browser enrollment status request' }); + return; + } + try { + res.json(await options.pairings.browserEnrollmentStatus(req.body)); + } catch (error) { + sendEnrollmentError(error, res); + } + })); + + router.post('/enrollments/redeem', adminAuth, browserEnrollmentAvailable, asyncRoute(async (req, res) => { + try { + await options.pairings.limitBrowserEnrollmentPeer(req.socket.remoteAddress ?? ''); + if (!isBrowserEnrollmentRedemption(req.body)) { + res.status(400).json({ error: 'Invalid browser enrollment redemption' }); + return; + } + const credential = await options.pairings.redeemBrowserEnrollment(req.body, options.adminToken); + res.json({ protocolVersion: BROWSER_ENROLLMENT_PROTOCOL_VERSION, ...credential }); + } catch (error) { + sendEnrollmentError(error, res); + } + })); + router.post('/pairings', adminAuth, asyncRoute(async (req, res) => { if (options.authMode !== 'paired') { res.status(409).json({ error: 'Paired worker authentication is disabled' }); From 331b9ae2941f36cc35763a29e1020f6261b7b5af Mon Sep 17 00:00:00 2001 From: Lia Date: Wed, 21 Oct 2026 22:36:04 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Protect=20Se?= =?UTF-8?q?tup=20State=20And=20Legacy=20Enrollment=20Fences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/src/browser-setup-cli.ts | 2 + packages/code/src/browser-setup.test.ts | 20 +++++++ packages/code/src/browser-setup.ts | 5 ++ packages/code/src/cli.ts | 2 + packages/code/src/native-sandbox.test.ts | 21 ++++++++ service/rollup.config.js | 1 + .../bridge/browser-enrollment-router.test.ts | 8 ++- service/src/bridge/browser-enrollment.test.ts | 40 ++++++++++++-- service/src/bridge/pairing.ts | 52 ++++++++++++++----- 9 files changed, 134 insertions(+), 17 deletions(-) diff --git a/packages/code/src/browser-setup-cli.ts b/packages/code/src/browser-setup-cli.ts index 909bcbab..250fcdb7 100644 --- a/packages/code/src/browser-setup-cli.ts +++ b/packages/code/src/browser-setup-cli.ts @@ -80,8 +80,10 @@ export async function runBrowserSetupCommand( }, }); process.stdout.write('\nMachine enrolled. Profile saved. It is not connected or ready until the worker starts and readiness is confirmed.\n'); + prompts.signal.throwIfAborted(); const start = options.has('--start') || (prompts.interactive && await prompts.yesNo('Start the worker in the foreground now?')); if (start) { + prompts.signal.throwIfAborted(); process.stdout.write('Starting the saved profile in the foreground. Stop with Ctrl-C.\n'); await new Promise((resolve, reject) => { const child = spawn(process.execPath, [fileURLToPath(new URL('./cli.js', import.meta.url)), 'run', '--profile', profilePath], { stdio: 'inherit' }); diff --git a/packages/code/src/browser-setup.test.ts b/packages/code/src/browser-setup.test.ts index 8de3eca1..41175c60 100644 --- a/packages/code/src/browser-setup.test.ts +++ b/packages/code/src/browser-setup.test.ts @@ -12,6 +12,7 @@ import type { BrowserSetupOptions } from './browser-setup.js'; import { loadBridgeIdentity, saveBridgeIdentity, savePrivateJson } from './storage.js'; import { loadWorkerProfile } from './worker-profile.js'; import { parseSetupArguments } from './setup-cli.js'; +import { runBrowserSetupCommand } from './browser-setup-cli.js'; async function fixture(t: test.TestContext) { const root = await mkdtemp(join(tmpdir(), 'browser-setup-')); @@ -186,6 +187,25 @@ test('cancellation is signed by the persisted machine key', async t => { await cancelBrowserSetup(f.options.profilePath!, undefined, f.fetchImpl); }); +test('cancellation after enrollment saves the profile but prevents requested foreground startup', { timeout: 5000 }, async t => { + const f = await fixture(t); + const controller = new AbortController(); + t.mock.method(globalThis, 'fetch', async (input: Parameters[0], init?: RequestInit) => { + const response = await f.fetchImpl(input, init); + if (String(input).endsWith('/complete')) controller.abort(new Error('owner cancelled startup')); + return response; + }); + await assert.rejects(runBrowserSetupCommand(new Map([ + ['--instance', f.options.instance], ['--machine-name', f.options.machineName], + ['--profile', f.options.profilePath!], ['--default-workspace', true], ['--start', true], + ]), { + interactive: false, signal: controller.signal, + ask: async () => { assert.fail('no prompt expected'); }, + yesNo: async () => { assert.fail('no prompt expected'); }, + }), /owner cancelled startup/); + assert.ok(await loadWorkerProfile(f.options.profilePath!)); +}); + test('browser flags preserve manual automation without accepting mixed credentials', () => { const flags = parseSetupArguments(['--instance', 'https://chat.example', '--non-interactive', '--default-workspace', '--no-browser']); assert.equal(flags.get('--instance'), 'https://chat.example'); diff --git a/packages/code/src/browser-setup.ts b/packages/code/src/browser-setup.ts index 30e2e5eb..b737c148 100644 --- a/packages/code/src/browser-setup.ts +++ b/packages/code/src/browser-setup.ts @@ -90,6 +90,11 @@ export function browserSetupAttemptPath(profilePath: string): string { return `${resolve(profilePath)}.setup.json`; } +export function browserSetupProtectedPaths(profilePath: string): string[] { + const attemptPath = browserSetupAttemptPath(profilePath); + return [attemptPath, `${attemptPath}.lock`]; +} + export async function loadBrowserSetupAttempt(profilePath: string): Promise { let value: unknown; try { value = await readPrivateJson(browserSetupAttemptPath(profilePath), 32 * 1024); } diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index d810097d..0580a85e 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -6,6 +6,7 @@ import { basename, dirname, join, resolve, relative, isAbsolute, sep } from 'nod import { pairBridgeWorker } from './pairing.js'; import { runSetupCommand } from './setup-cli.js'; +import { browserSetupProtectedPaths } from './browser-setup.js'; import { profileRunConfiguration } from './setup.js'; import { assertWorkerProfileIsolation } from './worker-profile.js'; import type { WorkerProfile } from './worker-profile.js'; @@ -1101,6 +1102,7 @@ async function run( protectedPaths: [ identityPath, savedProfile?.path, + ...(savedProfile ? browserSetupProtectedPaths(savedProfile.path) : []), ...(environments.some(environment => environment.definition.setup?.reuse) ? [preparationDirectory] : []), ...snapshotStores.map(store => store.store), ...environments.map(environment => environment.path), diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 90215156..bc6848e2 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -32,6 +32,7 @@ import { NativeSrtWorkspaceCommandSandbox, } from './native-sandbox.js'; import { restoreScratchTraversal } from './native-scratch.js'; +import { browserSetupProtectedPaths } from './browser-setup.js'; import { WorkspaceToolError } from './workspace.js'; import { isWorkspaceToolResult } from './protocol.js'; @@ -1309,6 +1310,26 @@ test('fails closed when SRT dependencies are unavailable', async t => { ); }); +test('protects retained browser setup keys outside home from command reads and writes', async t => { + const root = await mkdtemp(join(tmpdir(), 'librechat-enrollment-protection-')); + t.after(() => rm(root, { recursive: true, force: true })); + const workspace = join(root, 'workspace'); + const state = join(root, 'state'); + await mkdir(workspace); + await mkdir(state); + const paths = browserSetupProtectedPaths(join(state, 'profile.json')); + for (const path of paths) await writeFile(path, 'private setup state', { mode: 0o600 }); + const fake = fakeManager(); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: workspace, protectedPaths: paths, manager: fake.manager }); + t.after(() => sandbox.close()); + await sandbox.prepare(); + for (const path of paths) { + const canonical = await realpath(path); + assert.ok(fake.config?.filesystem.denyRead.includes(canonical), 'setup keys must not be readable by commands'); + assert.ok(fake.config?.filesystem.denyWrite.includes(canonical), 'commands must not replace setup state'); + } +}); + test('refuses workspace roots that expose worker home or control files', async t => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); const controlDirectory = join(root, '.control'); diff --git a/service/rollup.config.js b/service/rollup.config.js index 059c71d1..e2024c6f 100644 --- a/service/rollup.config.js +++ b/service/rollup.config.js @@ -43,6 +43,7 @@ export default { '../shared/telemetry-core.ts', '../packages/code/src/protocol.ts', '../packages/code/src/identity.ts', + '../packages/code/src/browser-enrollment-protocol.ts', ], sourceMap: true, declaration: false, diff --git a/service/src/bridge/browser-enrollment-router.test.ts b/service/src/bridge/browser-enrollment-router.test.ts index b3bd04c3..f0418d24 100644 --- a/service/src/bridge/browser-enrollment-router.test.ts +++ b/service/src/bridge/browser-enrollment-router.test.ts @@ -142,6 +142,10 @@ describe('browser enrollment HTTP boundary', () => { expect(issued.status).toBe(200); expect(await issued.json()).toEqual(approval); expect((await post(`${second}/enrollments`, approval)).status).toBe(200); + const { signature: _pendingSignature, ...pendingRequest } = proof(approval); + const pending = await post(`${second}/enrollments/status`, pendingRequest); + expect(pending.status).toBe(200); + expect(await pending.json()).toEqual({ protocolVersion: 1, status: 'pending', workerId: approval.workerId }); expect((await post(`${second}/enrollments`, { ...approval, publicKey: outsider.publicKey })).status).toBe(409); expect((await post(`${first}/enrollments/redeem`, { ...proof(approval), signature: '?' })).status).toBe(400); expect((await post(`${first}/enrollments/redeem`, { ...proof(approval), operation: 'setup.complete' })).status).toBe(400); @@ -159,7 +163,9 @@ describe('browser enrollment HTTP boundary', () => { expect((await post(`${second}/enrollments/status`, { ...statusRequest, requestHash: 'f'.repeat(64) })).status).toBe(409); expect((await post(`${first}/workers/${approval.workerId}/revoke`, {})).status).toBe(200); expect((await post(`${second}/enrollments/redeem`, proof(approval))).status).toBe(409); - expect((await post(`${second}/enrollments/status`, statusRequest)).status).toBe(409); + const revoked = await post(`${second}/enrollments/status`, statusRequest); + expect(revoked.status).toBe(200); + expect(await revoked.json()).toEqual({ protocolVersion: 1, status: 'revoked', workerId: approval.workerId }); }); test('shares a connection-peer budget across replicas and ignores spoofed forwarded IPs', async () => { diff --git a/service/src/bridge/browser-enrollment.test.ts b/service/src/bridge/browser-enrollment.test.ts index af6f715e..ca2b6844 100644 --- a/service/src/bridge/browser-enrollment.test.ts +++ b/service/src/bridge/browser-enrollment.test.ts @@ -208,7 +208,7 @@ describe('Redis browser enrollment ownership', () => { await workers.register({ protocolVersion: 1, workerId, incarnationId: 'browser-test-incarnation', identityId: auth.identityId, credentialId: auth.credentialId, binding: approved.binding, - capabilities: { statefulWorkspace: false, runtimes: [] }, + capabilities: { statefulWorkspace: false, runtimes: [], sandboxProfile: 'native-srt' }, }, auth); expect(await replica.issueBrowserEnrollment(approved)).toEqual(approved); expect(await replica.redeemBrowserEnrollment(redemption(approved), adminToken)).toEqual(credential); @@ -280,6 +280,8 @@ describe('Redis browser enrollment ownership', () => { await new Promise(resolve => setTimeout(resolve, 2_100)); await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const { signature: _signature, ...statusRequest } = redemption(approved); + expect(await replica.browserEnrollmentStatus(statusRequest)).toMatchObject({ status: 'revoked' }); await expect(replica.issueBrowserEnrollment({ ...approved, workerId: 'replacement-worker', expiresAt: new Date(Date.now() + 60_000).toISOString() })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); }); @@ -290,6 +292,7 @@ describe('Redis browser enrollment ownership', () => { const original = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); const originalAuth = await replica.authorize(authorized(original)); await new Promise(resolve => setTimeout(resolve, 2_100)); + expect(await store().redeemBrowserEnrollment(redemption(approved), adminToken)).toEqual(original); await redis.del( `codeapi:bridge:v1:identity:${workerId}`, `codeapi:bridge:v1:stable-identity:${workerId}`, `codeapi:bridge:v1:credential:${createHash('sha256').update(original.credential).digest('hex')}`, @@ -305,9 +308,9 @@ describe('Redis browser enrollment ownership', () => { const challenge = await restarted.createRecoveryChallenge(workerId, start, signBridgeRecoveryStart(identity.privateKey, start)); const recovered = await restarted.recoverCredential(workerId, challenge, signBridgeRecovery(identity.privateKey, challenge)); expect(await restarted.authorize(authorized(recovered))).toMatchObject({ identityId: originalAuth.identityId, binding: approved.binding }); - await expect(restarted.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); await restarted.revoke(workerId); - await expect(restarted.browserEnrollmentStatus(statusRequest)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await restarted.browserEnrollmentStatus(statusRequest)).toMatchObject({ status: 'revoked', workerId }); + await expect(restarted.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); }); test('revocation fences approval, completion, exact replay and recovery without clearing quarantine', async () => { @@ -332,6 +335,21 @@ describe('Redis browser enrollment ownership', () => { expect(credential.workerId).toBe(other.workerId); }); + test('status distinguishes pending, completed, revoked and unknown exact contracts without issuing credentials', async () => { + const replica = store(); + const approved = approval(); + const { signature: _signature, ...statusRequest } = redemption(approved); + await expect(replica.browserEnrollmentStatus(statusRequest)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await replica.issueBrowserEnrollment(approved); + expect(await store().browserEnrollmentStatus(statusRequest)).toEqual({ protocolVersion: 1, status: 'pending', workerId }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + await expect(replica.browserEnrollmentStatus({ ...statusRequest, workerId: 'different-worker' })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await replica.revoke(workerId); + expect(await store().browserEnrollmentStatus(statusRequest)).toEqual({ protocolVersion: 1, status: 'revoked', workerId }); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + }); + test('manual issue supersedes only the pending fence and cannot be undone by a retry', async () => { const replica = store(); const approved = approval(); @@ -344,6 +362,22 @@ describe('Redis browser enrollment ownership', () => { expect(issued.workerId).toBe(workerId); }); + for (const afterScanWindow of [false, true]) test(`unindexed legacy codes cannot supersede browser approval ${afterScanWindow ? 'after' : 'during'} migration`, async () => { + const replica = store(); + const approved = approval(); + const legacyCode = randomBytes(24).toString('base64url'); + const legacyKey = `codeapi:bridge:v1:pairing:${createHash('sha256').update(legacyCode).digest('hex')}`; + await redis.set(legacyKey, JSON.stringify({ workerId, expiresAt: new Date(Date.now() + 60_000).toISOString(), + binding: { tenantId: 'other-tenant', principal: { type: 'user', id: 'other-owner' } } }), 'EX', 60); + if (afterScanWindow) await redis.set('codeapi:bridge:v1:migration:legacy-pairing-scan-until', String(Date.now() - 1)); + await replica.issueBrowserEnrollment(approved); + expect(await redis.get(`codeapi:bridge:v1:pairing-generation:${workerId}`)).toBe('0'); + if (!afterScanWindow) expect(await redis.get(legacyKey)).toBeNull(); + await expect(replica.redeem({ workerId, code: legacyCode, publicKey: outsider.publicKey })).rejects.toMatchObject({ code: 'PAIRING_INVALID' }); + const credential = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + expect(await replica.authorize(authorized(credential))).toMatchObject({ workerId, binding: approved.binding }); + }); + test('cannot replace a preexisting manual enrollment or pending pairing', async () => { const replica = store(); const manual = await replica.issue(workerId); diff --git a/service/src/bridge/pairing.ts b/service/src/bridge/pairing.ts index ee3f60c6..c8170b69 100644 --- a/service/src/bridge/pairing.ts +++ b/service/src/bridge/pairing.ts @@ -13,6 +13,7 @@ import { canonicalBrowserEnrollmentRedemption, isBrowserEnrollmentApproval, isBrowserEnrollmentRedemption, + isBrowserEnrollmentRedemptionContract, isBrowserEnrollmentStatusRequest, verifyBrowserEnrollmentRedemption, } from '../../../packages/code/src/browser-enrollment-protocol'; @@ -108,11 +109,13 @@ if saved then if i ~= 8 and redis.call('EXISTS', KEYS[i]) == 1 then return 0 end end end + redis.call('SETNX', KEYS[5], '0') return 1 end for i = 4, 12 do if i ~= 5 and redis.call('EXISTS', KEYS[i]) == 1 then return 0 end end +redis.call('SETNX', KEYS[5], '0') redis.call('SET', KEYS[13], ARGV[5]) redis.call('SET', KEYS[1], ARGV[1], 'PX', remaining) redis.call('SET', KEYS[2], ARGV[2], 'PX', remaining) @@ -123,7 +126,6 @@ return 1 const REDEEM_BROWSER_ENROLLMENT_SCRIPT = ` local now = redis.call('TIME') local remaining = tonumber(ARGV[3]) - (tonumber(now[1]) * 1000.0 + math.floor(tonumber(now[2]) / 1000)) -if remaining <= 0 then return nil end if redis.call('GET', KEYS[1]) ~= ARGV[1] or redis.call('GET', KEYS[2]) ~= ARGV[2] or redis.call('GET', KEYS[3]) ~= 'browser-enrollment' or redis.call('GET', KEYS[4]) ~= KEYS[3] or (redis.call('GET', KEYS[5]) or '0') ~= '0' then return nil end @@ -132,6 +134,7 @@ if replay then if redis.call('GET', KEYS[9]) ~= ARGV[2] or redis.call('GET', KEYS[10]) ~= ARGV[9] then return nil end return replay end +if remaining <= 0 or ARGV[11] ~= '1' then return nil end for i = 7, 14 do if i ~= 11 and redis.call('EXISTS', KEYS[i]) == 1 then return nil end end @@ -144,7 +147,8 @@ else redis.call('SET', KEYS[9], ARGV[2]) end redis.call('SET', KEYS[10], ARGV[9]) -redis.call('SET', KEYS[11], ARGV[10], 'PX', math.min(remaining, tonumber(ARGV[5]) * 1000)) +redis.call('SET', KEYS[11], ARGV[10], 'EX', ARGV[5]) +for i = 1, 4 do redis.call('EXPIRE', KEYS[i], ARGV[5]) end return ARGV[10] `; const ROTATE_CREDENTIAL_SCRIPT = ` @@ -554,6 +558,7 @@ export class RedisBridgePairingStore { Date.parse(approval.expiresAt) <= Date.now() || Date.parse(approval.expiresAt) - Date.now() > BROWSER_ENROLLMENT_MAX_TTL_MS) { throw new BridgePairingError('ENROLLMENT_INVALID', 'Invalid browser enrollment approval'); } + await this.removeLegacyPairings(approval.workerId); const approvalRaw = canonicalApproval(approval); const normalized = JSON.parse(approvalRaw) as BrowserEnrollmentApproval; const enrollment: StoredEnrollment = { @@ -598,7 +603,7 @@ export class RedisBridgePairingStore { JSON.stringify(enrollment), String(Date.parse(approval.expiresAt)), String(BROWSER_ENROLLMENT_MAX_TTL_MS), - digest(approvalRaw), + `${digest(approvalRaw)}:${digest(canonicalBrowserEnrollmentRedemption(approval))}`, ); if (accepted !== 1) { throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is expired, revoked, superseded, or conflicts with an existing worker'); @@ -608,23 +613,46 @@ export class RedisBridgePairingStore { async browserEnrollmentStatus(proof: BrowserEnrollmentStatusRequest): Promise<{ protocolVersion: 1; - status: 'completed'; + status: 'pending' | 'completed' | 'revoked'; workerId: string; }> { if (!isBrowserEnrollmentStatusRequest(proof) || this.recovery == null || proof.serverId !== this.recovery.serverId) { throw new BridgePairingError('ENROLLMENT_INVALID', 'Invalid browser enrollment status request'); } - const [raw, generation, requiredGeneration] = await this.redis.mget( + const key = browserEnrollmentKey(proof.instance, proof.attemptId); + const [raw, generation, requiredGeneration, approvalRaw, tombstone, fence, indexed, planned] = await this.redis.mget( workerEnrollmentKey(proof.workerId), workerPairingGenerationKey(proof.workerId), workerEnrollmentRequiredKey(proof.workerId), + key, + `${key}:tombstone`, + `${key}:fence`, + workerPairingIndexKey(proof.workerId), + `${key}:enrollment`, ); - const enrollment = this.checkedEnrollment(proof.workerId, raw, generation, requiredGeneration); - if (enrollment.browserEnrollment == null || - canonicalBrowserEnrollmentRedemption(enrollment.browserEnrollment) !== canonicalBrowserEnrollmentRedemption(proof)) { - throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment does not match its durable contract'); + const contract = canonicalBrowserEnrollmentRedemption(proof); + try { + const enrollment = this.checkedEnrollment(proof.workerId, raw, generation, requiredGeneration); + if (isBrowserEnrollmentRedemptionContract(enrollment.browserEnrollment) && + canonicalBrowserEnrollmentRedemption(enrollment.browserEnrollment) === contract) { + return { protocolVersion: 1, status: 'completed', workerId: proof.workerId }; + } + } catch (error) { + if (!(error instanceof BridgePairingError)) throw error; } - return { protocolVersion: 1, status: 'completed', workerId: proof.workerId }; + if (tombstone?.split(':')[1] !== digest(contract)) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment does not match a known contract'); + } + let pending = false; + try { + const approval = JSON.parse(approvalRaw ?? 'null') as unknown; + pending = (generation ?? '0') === '0' && raw == null && requiredGeneration == null && planned != null && + isBrowserEnrollmentApproval(approval) && canonicalBrowserEnrollmentRedemption(approval) === contract && + Date.parse(approval.expiresAt) > Date.now() && fence === 'browser-enrollment' && indexed === `${key}:fence`; + } catch { + pending = false; + } + return { protocolVersion: 1, status: pending ? 'pending' : 'revoked', workerId: proof.workerId }; } async redeemBrowserEnrollment( @@ -634,9 +662,6 @@ export class RedisBridgePairingStore { if (!isBrowserEnrollmentRedemption(proof) || this.recovery == null || proof.serverId !== this.recovery.serverId) { throw new BridgePairingError('PROOF_INVALID', 'Invalid browser enrollment proof'); } - if (Date.parse(proof.expiresAt) <= Date.now()) { - throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment approval has expired'); - } const replayKey = enrollmentReplayKey(adminToken, proof.serverId); const key = browserEnrollmentKey(proof.instance, proof.attemptId); const attempts = await this.redis.eval( @@ -710,6 +735,7 @@ export class RedisBridgePairingStore { String(this.recovery.enrollmentTtlSeconds ?? 0), enrollment.generation, encryptEnrollmentReplay(credential, raw!, replayKey), + Date.parse(proof.expiresAt) > Date.now() ? '1' : '0', ); if (typeof encrypted !== 'string') { throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is expired, revoked, or superseded'); From 9bb05a185048a358ac9f0c4aa76c66000ef986be Mon Sep 17 00:00:00 2001 From: Lia Date: Wed, 21 Oct 2026 22:48:44 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=94=90=20fix:=20Remove=20Completed=20?= =?UTF-8?q?Setup=20Secrets=20And=20Bind=20Pending=20State=20To=20Identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/src/browser-protection.test.ts | 21 ++++++++++++++++++++ packages/code/src/native-sandbox.test.ts | 3 ++- packages/code/src/pairing.ts | 2 ++ packages/code/src/storage.ts | 7 +++++-- 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 packages/code/src/browser-protection.test.ts diff --git a/packages/code/src/browser-protection.test.ts b/packages/code/src/browser-protection.test.ts new file mode 100644 index 00000000..854dc157 --- /dev/null +++ b/packages/code/src/browser-protection.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; + +import { browserSetupProtectedPaths } from './browser-setup.js'; + +test('browser setup credential files are in the effective SDK read-deny policy outside home', async t => { + const paths = browserSetupProtectedPaths('/srv/code-state/profile.json'); + const previous = SandboxManager.getConfig(); + t.after(async () => { + await SandboxManager.reset(); + if (previous) SandboxManager.updateConfig(previous); + }); + SandboxManager.updateConfig({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowRead: [], allowWrite: [], denyWrite: paths }, + credentials: { files: paths.map(path => ({ path, mode: 'deny' as const })) }, + }); + const effective = SandboxManager.getFsReadConfig(); + for (const path of paths) assert.ok(effective.denyOnly.includes(path)); +}); diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index bc6848e2..c4fbd71f 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1325,7 +1325,8 @@ test('protects retained browser setup keys outside home from command reads and w await sandbox.prepare(); for (const path of paths) { const canonical = await realpath(path); - assert.ok(fake.config?.filesystem.denyRead.includes(canonical), 'setup keys must not be readable by commands'); + assert.ok(fake.config?.credentials?.files?.some(file => file.path === canonical && file.mode === 'deny'), + 'setup keys must be denied through the sandbox credential-file policy'); assert.ok(fake.config?.filesystem.denyWrite.includes(canonical), 'commands must not replace setup state'); } }); diff --git a/packages/code/src/pairing.ts b/packages/code/src/pairing.ts index d1504e82..cef571eb 100644 --- a/packages/code/src/pairing.ts +++ b/packages/code/src/pairing.ts @@ -16,6 +16,8 @@ export interface PairedBridgeWorkerIdentity codeApiUrl: string; publicKey: string; privateKey: string; + /** Private setup journal to protect until completion removes its secret copies. */ + setupAttemptPath?: string; } function normalizedBaseUrl(value: string): string { diff --git a/packages/code/src/storage.ts b/packages/code/src/storage.ts index b7fb094d..578f9fb6 100644 --- a/packages/code/src/storage.ts +++ b/packages/code/src/storage.ts @@ -11,7 +11,7 @@ import { } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import { BRIDGE_PROTOCOL_VERSION, BridgeProtocolError } from './protocol.js'; import { assertPrivateStorageAcl, removePrivateStorageAcl, assertPrivateStorageAncestors, assertPrivateStorageSupported } from './private-storage.js'; @@ -34,7 +34,10 @@ function isPairedIdentity(value: unknown): value is PairedBridgeWorkerIdentity { typeof value.expiresAt === 'string' && Number.isFinite(Date.parse(value.expiresAt)) && typeof value.publicKey === 'string' && - typeof value.privateKey === 'string' + typeof value.privateKey === 'string' && + (value.setupAttemptPath === undefined || + (typeof value.setupAttemptPath === 'string' && isAbsolute(value.setupAttemptPath) && + Buffer.byteLength(value.setupAttemptPath) <= 4096 && !/[\x00-\x1f\x7f]/.test(value.setupAttemptPath))) ); } From 3ef6f89a47335b26291ee7926dd9a798f0c3e58e Mon Sep 17 00:00:00 2001 From: Lia Date: Wed, 21 Oct 2026 22:49:39 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Bind=20Setup?= =?UTF-8?q?=20Recovery=20State=20Across=20Startup=20Modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/src/browser-protection.test.ts | 2 +- packages/code/src/browser-setup.test.ts | 5 +++++ packages/code/src/browser-setup.ts | 23 ++++++++++++++------ packages/code/src/cli.ts | 15 ++++++++++--- packages/code/src/native-sandbox.test.ts | 2 +- 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/code/src/browser-protection.test.ts b/packages/code/src/browser-protection.test.ts index 854dc157..d0767b98 100644 --- a/packages/code/src/browser-protection.test.ts +++ b/packages/code/src/browser-protection.test.ts @@ -5,7 +5,7 @@ import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; import { browserSetupProtectedPaths } from './browser-setup.js'; test('browser setup credential files are in the effective SDK read-deny policy outside home', async t => { - const paths = browserSetupProtectedPaths('/srv/code-state/profile.json'); + const paths = browserSetupProtectedPaths('/srv/code-state/profile.json.setup.json'); const previous = SandboxManager.getConfig(); t.after(async () => { await SandboxManager.reset(); diff --git a/packages/code/src/browser-setup.test.ts b/packages/code/src/browser-setup.test.ts index 41175c60..028a0b76 100644 --- a/packages/code/src/browser-setup.test.ts +++ b/packages/code/src/browser-setup.test.ts @@ -82,6 +82,11 @@ test('headless setup displays URL and code and saves the existing least-privileg assert.equal(identity.credential, f.credential.credential); assert.equal((await stat(browserSetupAttemptPath(saved.profilePath))).mode & 0o777, 0o600); assert.ok(!(await readFile(saved.profilePath, 'utf8')).includes('PRIVATE KEY')); + const receipt = JSON.parse(await readFile(browserSetupAttemptPath(saved.profilePath), 'utf8')); + assert.equal(receipt.privateKey, undefined); + assert.equal(receipt.identity, undefined); + assert.equal(receipt.completed, true); + assert.equal(identity.setupAttemptPath, browserSetupAttemptPath(saved.profilePath)); }); for (const boundary of ['start', 'complete'] as const) test(`lost ${boundary} response resumes the identical key and attempt`, async t => { diff --git a/packages/code/src/browser-setup.ts b/packages/code/src/browser-setup.ts index b737c148..6bf5244d 100644 --- a/packages/code/src/browser-setup.ts +++ b/packages/code/src/browser-setup.ts @@ -43,6 +43,7 @@ export interface BrowserSetupAttempt { profile: WorkerProfile; approval?: BrowserSetupApproval; identity?: PairedBridgeWorkerIdentity; + completed?: true; } export type BrowserSetupApproval = EnrollmentRedemption & { codeApiUrl: string }; @@ -90,9 +91,8 @@ export function browserSetupAttemptPath(profilePath: string): string { return `${resolve(profilePath)}.setup.json`; } -export function browserSetupProtectedPaths(profilePath: string): string[] { - const attemptPath = browserSetupAttemptPath(profilePath); - return [attemptPath, `${attemptPath}.lock`]; +export function browserSetupProtectedPaths(attemptPath: string | undefined): string[] { + return attemptPath ? [attemptPath, `${attemptPath}.lock`] : []; } export async function loadBrowserSetupAttempt(profilePath: string): Promise { @@ -102,10 +102,15 @@ export async function loadBrowserSetupAttempt(profilePath: string): Promise left[key] === right[key]); } @@ -272,6 +277,10 @@ export async function setupBrowserWorker(options: BrowserSetupOptions, dependenc const publish = async () => { if (!await assertEmptyOrMatching(profile.identityPath, attempt!.identity, loadBridgeIdentity, sameEnrolledKey)) await (dependencies.saveIdentity ?? saveBridgeIdentity)(profile.identityPath, attempt!.identity!); + if (!attempt!.completed) { + await savePrivateJson(attemptPath, { version: 1, request: attempt!.request, profile, approval: attempt!.approval, completed: true }); + attempt!.completed = true; + } const profileSaved = await assertEmptyOrMatching(profilePath, profile, loadWorkerProfile); try { if (!profileSaved) await (dependencies.saveProfile ?? saveWorkerProfile)(profilePath, profile); } catch { throw new Error(`Machine enrolled and identity saved, but profile persistence failed. Rerun setup with this same profile to resume: ${profilePath}`); } @@ -287,7 +296,7 @@ export async function setupBrowserWorker(options: BrowserSetupOptions, dependenc signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), }); attempt.identity = { ...credential, codeApiUrl: attempt.approval.codeApiUrl, - publicKey: attempt.request.publicKey, privateKey: attempt.privateKey }; + publicKey: attempt.request.publicKey, privateKey: attempt.privateKey, setupAttemptPath: attemptPath }; await persist(); return await publish(); } @@ -309,7 +318,7 @@ export async function setupBrowserWorker(options: BrowserSetupOptions, dependenc credential.credential.length < 32 || credential.credential.length > 4096 || !Number.isFinite(Date.parse(credential.expiresAt))) throw new Error('LibreChat returned an invalid enrollment credential'); attempt.identity = { protocolVersion: 1, workerId: credential.workerId, credential: credential.credential, expiresAt: credential.expiresAt, - codeApiUrl: attempt.approval.codeApiUrl, publicKey: attempt.request.publicKey, privateKey: attempt.privateKey }; + codeApiUrl: attempt.approval.codeApiUrl, publicKey: attempt.request.publicKey, privateKey: attempt.privateKey, setupAttemptPath: attemptPath }; // Keep replayable local state even if either final file cannot be published. await persist(); return await publish(); diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 0580a85e..83a8c649 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -438,6 +438,7 @@ async function run( const pairedIdentity = identityPath ? await loadBridgeIdentity(identityPath) : undefined; + const setupProtectedPaths = browserSetupProtectedPaths(pairedIdentity?.setupAttemptPath); const workerId = required( 'LIBRECHAT_CODE_WORKER_ID', configuredWorkerId ?? pairedIdentity?.workerId, @@ -851,6 +852,14 @@ async function run( }), ]), ); + if (pairedIdentity?.setupAttemptPath) { + const receipt = await stat(pairedIdentity.setupAttemptPath).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + if (receipt) for (const root of roots) + await assertWorkerProfileIsolation(pairedIdentity.setupAttemptPath, identityPath!, root.root); + } const preparationDirectory = join(dirname(identityPath ?? defaultBridgeIdentityPath(workerId)), 'environment-preparation'); if (environments.some(environment => environment.definition.setup?.reuse)) { const sourceParents = await prepareEnvironmentPreparationDirectory(preparationDirectory); @@ -866,13 +875,13 @@ async function run( definition: { name: 'shared-resource', root: resource.path }, fingerprint: '', }))); await assertEnvironmentResourceIsolation(environments.flatMap(environment => environment.resources ?? []), roots.map(root => root.root), [ - identityPath, preparationDirectory, github.privateKeyPath, + identityPath, ...setupProtectedPaths, preparationDirectory, github.privateKeyPath, ...environments.map(environment => environment.path), ...rootQuarantinePaths.values(), ].filter((path): path is string => path != null)); await assertEnvironmentDefinitionsOutsideRoots(resourceRoots, roots); const snapshotStores = environments.flatMap(environment => environment.snapshotStore ? [environment.snapshotStore] : []); await assertEnvironmentResourceIsolation(snapshotStores.map(store => ({ kind: 'npm-cache' as const, path: store.store, access: 'read-only' as const })), roots.map(root => root.root), [ - identityPath, preparationDirectory, github.privateKeyPath, ...environments.map(environment => environment.path), + identityPath, ...setupProtectedPaths, preparationDirectory, github.privateKeyPath, ...environments.map(environment => environment.path), ...rootQuarantinePaths.values(), ...environments.flatMap(environment => (environment.resources ?? []).map(resource => resource.path)), ].filter((path): path is string => path != null)); await assertEnvironmentDefinitionsOutsideRoots(snapshotStores.map(store => ({ path: store.store, sourceParents: store.controlPaths, @@ -1102,7 +1111,7 @@ async function run( protectedPaths: [ identityPath, savedProfile?.path, - ...(savedProfile ? browserSetupProtectedPaths(savedProfile.path) : []), + ...setupProtectedPaths, ...(environments.some(environment => environment.definition.setup?.reuse) ? [preparationDirectory] : []), ...snapshotStores.map(store => store.store), ...environments.map(environment => environment.path), diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index c4fbd71f..54b7adf3 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1317,7 +1317,7 @@ test('protects retained browser setup keys outside home from command reads and w const state = join(root, 'state'); await mkdir(workspace); await mkdir(state); - const paths = browserSetupProtectedPaths(join(state, 'profile.json')); + const paths = browserSetupProtectedPaths(join(state, 'profile.json.setup.json')); for (const path of paths) await writeFile(path, 'private setup state', { mode: 0o600 }); const fake = fakeManager(); const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: workspace, protectedPaths: paths, manager: fake.manager }); From 4fb3da15abf52e884f1d46eac2d3e072831dfa78 Mon Sep 17 00:00:00 2001 From: Lia Date: Wed, 21 Oct 2026 23:00:11 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=97=9D=EF=B8=8F=20fix:=20Protect=20Th?= =?UTF-8?q?e=20Entire=20Setup=20Journal=20Namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/src/browser-protection.test.ts | 18 ++++-- packages/code/src/browser-setup.test.ts | 63 +++++++++++++++++++- packages/code/src/browser-setup.ts | 19 +++++- packages/code/src/cli.ts | 16 +++-- packages/code/src/native-sandbox.test.ts | 9 ++- 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/packages/code/src/browser-protection.test.ts b/packages/code/src/browser-protection.test.ts index d0767b98..2da4e27d 100644 --- a/packages/code/src/browser-protection.test.ts +++ b/packages/code/src/browser-protection.test.ts @@ -1,11 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { relative, sep } from 'node:path'; import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; -import { browserSetupProtectedPaths } from './browser-setup.js'; +import { browserSetupAttemptPath, browserSetupProtectedPaths } from './browser-setup.js'; -test('browser setup credential files are in the effective SDK read-deny policy outside home', async t => { - const paths = browserSetupProtectedPaths('/srv/code-state/profile.json.setup.json'); +test('browser setup namespace denies journal, lock and crash-left temporaries outside home', async t => { + const attempt = browserSetupAttemptPath('/srv/code-state/profile.json'); + const paths = browserSetupProtectedPaths(attempt); + assert.deepEqual(paths, ['/srv/code-state/profile.json.setup']); const previous = SandboxManager.getConfig(); t.after(async () => { await SandboxManager.reset(); @@ -17,5 +20,12 @@ test('browser setup credential files are in the effective SDK read-deny policy o credentials: { files: paths.map(path => ({ path, mode: 'deny' as const })) }, }); const effective = SandboxManager.getFsReadConfig(); - for (const path of paths) assert.ok(effective.denyOnly.includes(path)); + for (const file of [attempt, `${attempt}.lock`, `${attempt}.0123456789abcdef.tmp`, `${attempt}.future.tmp`]) { + assert.ok(effective.denyOnly.some(directory => { + const child = relative(directory, file); + return child !== '..' && !child.startsWith(`..${sep}`); + }), 'every journal generation must remain beneath an effective directory deny'); + } + assert.deepEqual(browserSetupProtectedPaths(undefined), []); + assert.throws(() => browserSetupProtectedPaths('/srv/code-state/profile.json.setup.json'), /Unsupported setup journal/); }); diff --git a/packages/code/src/browser-setup.test.ts b/packages/code/src/browser-setup.test.ts index 028a0b76..077db673 100644 --- a/packages/code/src/browser-setup.test.ts +++ b/packages/code/src/browser-setup.test.ts @@ -1,13 +1,15 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import test from 'node:test'; import { createBridgeIdentity } from './identity.js'; import { browserSetupRequestHash, verifyBrowserSetupProof, verifyEnrollmentRedemption } from './browser-enrollment-protocol.js'; import type { BrowserSetupRequest, BrowserSetupProof } from './browser-enrollment-protocol.js'; -import { browserSetupAttemptPath, loadBrowserSetupAttempt, setupBrowserWorker, setupInstanceUrl, cancelBrowserSetup } from './browser-setup.js'; +import { browserSetupAttemptPath, browserSetupProtectedPaths, loadBrowserSetupAttempt, setupBrowserWorker, setupInstanceUrl, cancelBrowserSetup } from './browser-setup.js'; import type { BrowserSetupOptions } from './browser-setup.js'; import { loadBridgeIdentity, saveBridgeIdentity, savePrivateJson } from './storage.js'; import { loadWorkerProfile } from './worker-profile.js'; @@ -102,6 +104,61 @@ for (const boundary of ['start', 'complete'] as const) test(`lost ${boundary} re assert.equal(f.counts().startCalls, 2); }); +test('SIGKILL during journal publish preserves the attempt and contains the secret temporary after completion', { timeout: 10_000 }, async t => { + const f = await fixture(t); + f.loseComplete(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }), /lost/); + const before = (await loadBrowserSetupAttempt(f.options.profilePath!))!; + const attempt = browserSetupAttemptPath(f.options.profilePath!); + const child = spawn(process.execPath, ['--input-type=module', '--eval', ` + import fs from 'node:fs/promises'; + import { syncBuiltinESMExports } from 'node:module'; + const rename = fs.rename; + fs.rename = async (from, to) => { + if (to === ${JSON.stringify(attempt)}) { + process.send('journal-written'); + await new Promise(resolve => setTimeout(resolve, 60_000)); + } + return rename(from, to); + }; + syncBuiltinESMExports(); + const { savePrivateJson } = await import(${JSON.stringify(new URL('./storage.js', import.meta.url).href)}); + await savePrivateJson(${JSON.stringify(attempt)}, JSON.parse(await fs.readFile(${JSON.stringify(attempt)}, 'utf8'))); + `], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); + let stderr = ''; + child.stderr!.on('data', chunk => { stderr = (stderr + String(chunk)).slice(-4096); }); + const closed = once(child, 'close'); + t.after(async () => { child.kill('SIGKILL'); await closed; }); + await Promise.race([ + once(child, 'message', { signal: t.signal }).then(([message]) => assert.equal(message, 'journal-written')), + closed.then(() => { throw new Error(`Journal writer exited before the crash boundary: ${stderr}`); }), + ]); + child.kill('SIGKILL'); + assert.deepEqual(await closed, [null, 'SIGKILL']); + const directory = dirname(attempt); + const orphanName = (await readdir(directory)).find(name => /^attempt\.json\.[a-f0-9]{16}\.tmp$/.test(name)); + assert.ok(orphanName, 'real atomic writer left its secret generation when killed before rename'); + const orphan = join(directory, orphanName); + const crashCopy = JSON.parse(await readFile(orphan, 'utf8')); + assert.equal(crashCopy.privateKey, before.privateKey); + assert.deepEqual(browserSetupProtectedPaths(attempt), [directory]); + const saved = await setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }); + const identity = await loadBridgeIdentity(saved.profile.identityPath); + assert.equal(identity.privateKey, before.privateKey); + assert.equal(identity.setupAttemptPath, attempt); + assert.deepEqual((await loadBrowserSetupAttempt(saved.profilePath))?.request, before.request); + assert.equal(JSON.parse(await readFile(attempt, 'utf8')).privateKey, undefined); + assert.ok((await stat(orphan)).isFile(), 'crash leftovers stay inside the denied namespace after final-receipt scrubbing'); +}); + +test('an older draft journal is never silently replaced by a different key or attempt', async t => { + const f = await fixture(t); + const oldPath = `${f.options.profilePath}.setup.json`; + await writeFile(oldPath, 'preserve the existing draft key', { mode: 0o600 }); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: async () => { assert.fail('no remote allocation'); } }), /older draft setup journal/); + assert.equal(await readFile(oldPath, 'utf8'), 'preserve the existing draft key'); +}); + test('local profile failure after enrollment resumes without remote enrollment effects', async t => { const f = await fixture(t); await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0, saveProfile: async () => { throw new Error('disk full'); } }), /enrolled.*profile persistence failed/); diff --git a/packages/code/src/browser-setup.ts b/packages/code/src/browser-setup.ts index 6bf5244d..7bbbe493 100644 --- a/packages/code/src/browser-setup.ts +++ b/packages/code/src/browser-setup.ts @@ -1,6 +1,6 @@ import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto'; import { lstat } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { createBridgeIdentity } from './identity.js'; @@ -21,6 +21,7 @@ import { withProcessLock } from './process-lock.js'; import { assertIdentityPathIsPrivate, loadBridgeIdentity, + ensurePrivateWorkspaceDirectory, readPrivateJson, saveBridgeIdentity, savePrivateJson, @@ -88,14 +89,25 @@ export function setupInstanceUrl(value: string): string { } export function browserSetupAttemptPath(profilePath: string): string { - return `${resolve(profilePath)}.setup.json`; + return join(`${resolve(profilePath)}.setup`, 'attempt.json'); } export function browserSetupProtectedPaths(attemptPath: string | undefined): string[] { - return attemptPath ? [attemptPath, `${attemptPath}.lock`] : []; + if (attemptPath === undefined) return []; + const directory = dirname(attemptPath); + if (basename(attemptPath) !== 'attempt.json' || !directory.endsWith('.setup')) + throw new Error('Unsupported setup journal location; preserve the original identity and journal'); + // Directory protection includes atomic-write temporaries, even after SIGKILL. + return [directory]; } export async function loadBrowserSetupAttempt(profilePath: string): Promise { + // The earlier, unreleased draft stored journals beside profiles. Never rekey them silently. + const previous = await lstat(`${resolve(profilePath)}.setup.json`).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + if (previous) throw new Error('An older draft setup journal exists; preserve its key and attempt instead of starting a new enrollment'); let value: unknown; try { value = await readPrivateJson(browserSetupAttemptPath(profilePath), 32 * 1024); } catch (error) { @@ -234,6 +246,7 @@ export async function setupBrowserWorker(options: BrowserSetupOptions, dependenc const instance = setupInstanceUrl(options.instance); const profilePath = resolve(options.profilePath ?? defaultWorkerProfilePath()); const attemptPath = browserSetupAttemptPath(profilePath); + await ensurePrivateWorkspaceDirectory(dirname(attemptPath)); // This stable inode is deliberately retained. Removing an advisory lock can split its holders. await assertIdentityPathIsPrivate(`${attemptPath}.lock`); return withProcessLock(`${attemptPath}.lock`, async () => { diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 83a8c649..bee3697a 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node import { createHash, createHmac, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; -import { readdir, realpath, stat } from 'node:fs/promises'; +import { lstat, readdir, realpath, stat } from 'node:fs/promises'; import { basename, dirname, join, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; import { runSetupCommand } from './setup-cli.js'; import { browserSetupProtectedPaths } from './browser-setup.js'; +import { assertPrivateStorageAncestors } from './private-storage.js'; import { profileRunConfiguration } from './setup.js'; import { assertWorkerProfileIsolation } from './worker-profile.js'; import type { WorkerProfile } from './worker-profile.js'; @@ -852,13 +853,18 @@ async function run( }), ]), ); - if (pairedIdentity?.setupAttemptPath) { - const receipt = await stat(pairedIdentity.setupAttemptPath).catch((error: NodeJS.ErrnoException) => { + for (const path of setupProtectedPaths) { + const namespace = await lstat(path).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return undefined; throw error; }); - if (receipt) for (const root of roots) - await assertWorkerProfileIsolation(pairedIdentity.setupAttemptPath, identityPath!, root.root); + if (!namespace) continue; + if (!namespace.isDirectory() || namespace.isSymbolicLink()) + throw new Error('Setup journal namespace must be a private directory, not a symlink'); + const sourceParents = await assertPrivateStorageAncestors(path); + await assertEnvironmentDefinitionsOutsideRoots([{ + path, sourceParents, definition: { name: 'setup-state', root: path }, fingerprint: '', + }], roots); } const preparationDirectory = join(dirname(identityPath ?? defaultBridgeIdentityPath(workerId)), 'environment-preparation'); if (environments.some(environment => environment.definition.setup?.reuse)) { diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 54b7adf3..4f0a7094 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -32,7 +32,7 @@ import { NativeSrtWorkspaceCommandSandbox, } from './native-sandbox.js'; import { restoreScratchTraversal } from './native-scratch.js'; -import { browserSetupProtectedPaths } from './browser-setup.js'; +import { browserSetupAttemptPath, browserSetupProtectedPaths } from './browser-setup.js'; import { WorkspaceToolError } from './workspace.js'; import { isWorkspaceToolResult } from './protocol.js'; @@ -1317,8 +1317,11 @@ test('protects retained browser setup keys outside home from command reads and w const state = join(root, 'state'); await mkdir(workspace); await mkdir(state); - const paths = browserSetupProtectedPaths(join(state, 'profile.json.setup.json')); - for (const path of paths) await writeFile(path, 'private setup state', { mode: 0o600 }); + const attempt = browserSetupAttemptPath(join(state, 'profile.json')); + const paths = browserSetupProtectedPaths(attempt); + await mkdir(paths[0], { mode: 0o700 }); + for (const path of [attempt, `${attempt}.lock`, `${attempt}.0123456789abcdef.tmp`]) + await writeFile(path, 'private setup state', { mode: 0o600 }); const fake = fakeManager(); const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: workspace, protectedPaths: paths, manager: fake.manager }); t.after(() => sandbox.close());