From acde36598f57d0771f6ed5b566d7a86f6ef2fc42 Mon Sep 17 00:00:00 2001 From: Lia Date: Tue, 20 Oct 2026 09:09:35 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A7=AD=20feat:=20Add=20Guided=20Worke?= =?UTF-8?q?r=20Setup=20And=20Saved=20Profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/src/cli.ts | 36 +- packages/code/src/pairing.test.ts | 58 +++ packages/code/src/pairing.ts | 39 +- packages/code/src/setup-cli.test.ts | 204 +++++++++ packages/code/src/setup-cli.ts | 248 +++++++++++ packages/code/src/setup.test.ts | 334 ++++++++++++++ packages/code/src/setup.ts | 254 +++++++++++ packages/code/src/storage.ts | 39 +- packages/code/src/worker-profile.test.ts | 529 +++++++++++++++++++++++ packages/code/src/worker-profile.ts | 200 +++++++++ 10 files changed, 1928 insertions(+), 13 deletions(-) create mode 100644 packages/code/src/setup-cli.test.ts create mode 100644 packages/code/src/setup-cli.ts create mode 100644 packages/code/src/setup.test.ts create mode 100644 packages/code/src/setup.ts create mode 100644 packages/code/src/worker-profile.test.ts create mode 100644 packages/code/src/worker-profile.ts diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 85ce947e..510d67e4 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -5,6 +5,10 @@ import { 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 { profileRunConfiguration } from './setup.js'; +import { assertWorkerProfileIsolation } from './worker-profile.js'; +import type { WorkerProfile } from './worker-profile.js'; import { discoverProjects } from './projects.js'; import { loadProjectRoots, projectRootArguments } from './project-roots.js'; import { @@ -358,6 +362,7 @@ async function relay(): Promise { async function run( runtimeSessionId?: string, args: string[] = [], + savedProfile?: { path: string; config: WorkerProfile }, ): Promise { const projectArgs = projectRootArguments(args); if ( @@ -422,7 +427,7 @@ async function run( } const configuredWorkerId = process.env.LIBRECHAT_CODE_WORKER_ID?.trim(); const configuredIdentityPath = - process.env.LIBRECHAT_CODE_IDENTITY_FILE?.trim(); + savedProfile?.config.identityPath ?? process.env.LIBRECHAT_CODE_IDENTITY_FILE?.trim(); const configuredToken = process.env.LIBRECHAT_CODE_WORKER_TOKEN?.trim(); const identityPath = configuredIdentityPath ?? @@ -645,6 +650,9 @@ async function run( throw new Error('Invalid workspace registration'); } } + if (savedProfile) { + await assertWorkerProfileIsolation(savedProfile.path, identityPath!, canonicalWorkerDirectory); + } const mutationQuarantinePath = (allowWorkspaceWrites || allowWorkspaceCommands) && canonicalWorkerDirectory @@ -1092,6 +1100,7 @@ async function run( commandPolicy, protectedPaths: [ identityPath, + savedProfile?.path, ...(environments.some(environment => environment.definition.setup?.reuse) ? [preparationDirectory] : []), ...snapshotStores.map(store => store.store), ...environments.map(environment => environment.path), @@ -1766,6 +1775,26 @@ async function clearMutationQuarantine(args: string[]): Promise { async function main(): Promise { const args = process.argv.slice(2); + if (args.length === 1 && (args[0] === '--help' || args[0] === 'help')) { + process.stdout.write(`Usage: librechat-code + + setup Guided pairing 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 + reset-workspace Existing runtime maintenance + clear-workspace-quarantine Existing workspace maintenance + +Run librechat-code setup --help for setup options. +Legacy LIBRECHAT_CODE_* configuration takes precedence over the default profile. +Explicit profiles cannot be mixed with legacy settings or run options. +`); + return; + } + if (args[0] === 'setup') { + await runSetupCommand(args.slice(1)); + return; + } if (args[0] === 'prune-environment-storage') { const paths: string[] = []; for (let i = 1; i < args.length; i++) { @@ -1823,7 +1852,10 @@ async function main(): Promise { if (args[0] && args[0] !== 'run') { throw new Error(`Unknown command: ${args[0]}`); } - await run(undefined, args.slice(1)); + const configuration = await profileRunConfiguration(args.slice(1)); + await run(undefined, configuration.args, configuration.profile && configuration.profilePath + ? { path: configuration.profilePath, config: configuration.profile } + : undefined); } main().catch((error: Error) => { process.stderr.write(`librechat-code: ${error.message}\n`); diff --git a/packages/code/src/pairing.test.ts b/packages/code/src/pairing.test.ts index 2b564394..a7dd6bd0 100644 --- a/packages/code/src/pairing.test.ts +++ b/packages/code/src/pairing.test.ts @@ -34,3 +34,61 @@ test('pairing binds a generated worker key to a single-use code', async () => { assert.match(paired.publicKey, /BEGIN PUBLIC KEY/); assert.match(paired.privateKey, /BEGIN PRIVATE KEY/); }); + +test('pairing propagates cancellation and never follows a code-bearing redirect', async () => { + const controller = new AbortController(); + const paired = await pairBridgeWorker({ + codeApiUrl: 'https://code.example/v1', + workerId: 'vm-1', + code: 'one-time-code', + signal: controller.signal, + fetchImpl: async (_input, init) => { + assert.equal(init?.redirect, 'error'); + assert.equal(init?.signal, controller.signal); + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + credential: 'issued-short-lived-credential-value', + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }); + }, + }); + assert.equal(paired.workerId, 'vm-1'); +}); + +for (const payload of [ + null, + [], + 'not a response', + { protocolVersion: 2 }, + { protocolVersion: 1, workerId: 'another-worker' }, +]) { + test(`pairing rejects malformed response ${JSON.stringify(payload)}`, async () => { + await assert.rejects( + pairBridgeWorker({ + codeApiUrl: 'https://code.example/v1', + workerId: 'vm-1', + code: 'one-time-code', + fetchImpl: async () => Response.json(payload), + }), + /invalid/, + ); + }); +} + +test('non-JSON pairing failures have a bounded diagnosis without echoing response contents', async () => { + await assert.rejects( + pairBridgeWorker({ + codeApiUrl: 'https://code.example/v1', + workerId: 'vm-1', + code: 'one-time-code', + fetchImpl: async () => + new Response('private upstream response', { status: 502 }), + }), + error => { + assert.match(String(error), /invalid pairing response/); + assert.ok(!String(error).includes('private upstream response')); + return true; + }, + ); +}); diff --git a/packages/code/src/pairing.ts b/packages/code/src/pairing.ts index ed33fb18..d1504e82 100644 --- a/packages/code/src/pairing.ts +++ b/packages/code/src/pairing.ts @@ -1,8 +1,5 @@ import { createBridgeIdentity } from './identity.js'; -import { - BRIDGE_PROTOCOL_VERSION, - BridgeProtocolError, -} from './protocol.js'; +import { BRIDGE_PROTOCOL_VERSION, BridgeProtocolError } from './protocol.js'; import type { BridgeWorkerCredentialResponse } from './protocol.js'; @@ -11,6 +8,7 @@ export interface PairBridgeWorkerOptions { workerId: string; code: string; fetchImpl?: typeof fetch; + signal?: AbortSignal; } export interface PairedBridgeWorkerIdentity @@ -24,8 +22,8 @@ function normalizedBaseUrl(value: string): string { return value.replace(/\/+$/, ''); } -function errorMessage(value: object): string | undefined { - if ('error' in value && typeof value.error === 'string') return value.error; +function errorMessage(value: Record): string | undefined { + if (typeof value.error === 'string') return value.error; return undefined; } @@ -38,6 +36,8 @@ export async function pairBridgeWorker( `${codeApiUrl}/bridge/pairings/redeem`, { method: 'POST', + redirect: 'error', + signal: options.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -47,10 +47,29 @@ export async function pairBridgeWorker( }), }, ); - const payload = (await response.json()) as object; + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new BridgeProtocolError( + 'Code API returned an invalid pairing response', + response.status, + ); + } + if ( + typeof payload !== 'object' || + payload == null || + Array.isArray(payload) + ) { + throw new BridgeProtocolError( + 'Code API returned an invalid pairing response', + response.status, + ); + } if (!response.ok) { throw new BridgeProtocolError( - errorMessage(payload) ?? `Bridge pairing failed with HTTP ${response.status}`, + errorMessage(payload as Record) ?? + `Bridge pairing failed with HTTP ${response.status}`, response.status, ); } @@ -62,7 +81,9 @@ export async function pairBridgeWorker( credential.credential.length < 32 || !Number.isFinite(Date.parse(credential.expiresAt)) ) { - throw new BridgeProtocolError('Code API returned an invalid worker credential'); + throw new BridgeProtocolError( + 'Code API returned an invalid worker credential', + ); } return { ...credential, diff --git a/packages/code/src/setup-cli.test.ts b/packages/code/src/setup-cli.test.ts new file mode 100644 index 00000000..7a8cacc4 --- /dev/null +++ b/packages/code/src/setup-cli.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import type { TestContext } from 'node:test'; + +const cli = fileURLToPath(new URL('./cli.js', import.meta.url)); +const code = 'secret-one-time-code-123456789'; + +async function fixture(t: TestContext) { + const home = await mkdtemp(join(tmpdir(), 'setup-cli-home-')); + t.after(() => rm(home, { recursive: true, force: true })); + const env = { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith('LIBRECHAT_CODE_'), + ), + ), + HOME: home, + }; + const profilePath = join( + home, + '.config', + 'librechat', + 'code', + 'profiles', + 'default.json', + ); + return { home, env, profilePath }; +} + +async function execute(args: string[], env: NodeJS.ProcessEnv, stdin = '') { + const child = spawn(process.execPath, [cli, ...args], { env, stdio: 'pipe' }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + stdout += String(chunk); + }); + child.stderr.on('data', chunk => { + stderr += String(chunk); + }); + child.stdin.end(stdin); + const [status] = await once(child, 'exit', { + signal: AbortSignal.timeout(10_000), + }); + return { status, stdout, stderr }; +} + +test('CLI help explains the pairing prerequisite and setup does not silently read piped prompts', async t => { + const { env } = await fixture(t); + const help = spawnSync(process.execPath, [cli, 'setup', '--help'], { + env, + encoding: 'utf8', + }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /Settings > Code Environments/); + assert.match(help.stdout, /does not\nsign in to LibreChat/); + const guided = await execute(['setup'], env, code); + assert.notEqual(guided.status, 0); + assert.match(guided.stderr, /requires a terminal/); + assert.ok(!guided.stdout.includes(code)); + assert.ok(!guided.stderr.includes(code)); +}); + +test( + 'CLI setup followed by plain run registers the saved identity with read-only access', + { timeout: 15_000 }, + async t => { + const { env, profilePath } = await fixture(t); + let paired = false; + let registered!: (body: Record) => void; + const registration = new Promise>(resolve => { + registered = resolve; + }); + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + response.setHeader('Content-Type', 'application/json'); + if (request.url === '/v1/bridge/pairings/redeem') { + assert.equal(body.workerId, 'guided-vm'); + assert.equal(body.code, code); + assert.match(body.publicKey, /BEGIN PUBLIC KEY/); + assert.equal(body.privateKey, undefined); + paired = true; + response.end( + JSON.stringify({ + protocolVersion: 1, + workerId: body.workerId, + credential: 'short-lived-worker-credential-value', + expiresAt: '2099-01-01T00:00:00Z', + }), + ); + return; + } + if (request.url === '/v1/bridge/workers/register') { + assert.equal(paired, true); + assert.match(request.headers.authorization ?? '', /^Bridge /); + assert.ok(request.headers['x-librechat-code-signature']); + registered(body); + response.end( + JSON.stringify({ + protocolVersion: 1, + workerId: body.workerId, + incarnationId: body.incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + }), + ); + return; + } + response.end(JSON.stringify({ protocolVersion: 1, serverElapsedMs: 0 })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const result = await execute( + [ + 'setup', + '--non-interactive', + '--code-stdin', + '--url', + `http://127.0.0.1:${address.port}/v1`, + '--worker-id', + 'guided-vm', + '--default-workspace', + ], + env, + `${code}\n`, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /librechat-code run\n/); + assert.match(result.stdout, /still needs to start/); + assert.ok(!result.stdout.includes(code)); + assert.ok(!result.stderr.includes(code)); + const profile = JSON.parse(await readFile(profilePath, 'utf8')); + assert.equal(profile.allowWorkspaceWrites, false); + assert.equal(profile.allowWorkspaceCommands, false); + const worker = spawn(process.execPath, [cli, 'run'], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + worker.stderr.on('data', chunk => { + stderr += String(chunk); + }); + const exited = once(worker, 'exit'); + t.after(() => worker.kill('SIGKILL')); + const body = await Promise.race([ + registration, + exited.then(() => { + throw new Error(`Worker exited before registration: ${stderr}`); + }), + ]); + worker.kill('SIGTERM'); + await exited; + assert.equal(body.workerId, 'guided-vm'); + const operations = body.capabilities.workspaceTools.operations as string[]; + assert.ok(operations.includes('read_file')); + assert.ok(!operations.includes('write_file')); + assert.ok(!operations.includes('execute_command')); + assert.ok(!JSON.stringify(body).includes(profile.identityPath)); + assert.ok(!JSON.stringify(body).includes(profilePath)); + }, +); + +test('CLI rejects missing automation inputs without consuming stdin or pairing', async t => { + const { env } = await fixture(t); + const result = await execute(['setup', '--non-interactive'], env, code); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /requires --url, --worker-id, --code-stdin/); + assert.ok(!result.stderr.includes(code)); +}); + +test('CLI refuses unsafe URLs before disclosing the code', async t => { + const { env } = await fixture(t); + const result = await execute( + [ + 'setup', + '--non-interactive', + '--code-stdin', + '--url', + 'http://external.example/v1', + '--worker-id', + 'vm', + '--default-workspace', + ], + env, + code, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /requires HTTPS/); + assert.ok(!result.stdout.includes(code)); + assert.ok(!result.stderr.includes(code)); +}); diff --git a/packages/code/src/setup-cli.ts b/packages/code/src/setup-cli.ts new file mode 100644 index 00000000..a2fae1c5 --- /dev/null +++ b/packages/code/src/setup-cli.ts @@ -0,0 +1,248 @@ +import { createInterface } from 'node:readline/promises'; +import { Writable } from 'node:stream'; +import { resolve } from 'node:path'; + +import { + setupCodeApiUrl, + setupWorker, + setupWorkspaceDirectory, +} from './setup.js'; +import { defaultWorkerProfilePath } from './worker-profile.js'; +import { isValidBridgeWorkerId } from './protocol.js'; + +export const SETUP_HELP = `Usage: librechat-code setup [options] + +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 + --worker-id Worker ID supplied by LibreChat + --worker-dir Expose one existing project directory + --default-workspace Create a private workspace when the worker starts + --allow-workspace-writes Explicitly enable file writes (default: off) + --allow-workspace-commands Explicitly enable sandboxed commands (default: off) + --profile Profile destination (default: profiles/default.json) + --identity Separate private identity destination + --non-interactive Require explicit inputs instead of prompting + --code-stdin Read the pairing code from stdin, never an argument + --help Show this help + +Existing profiles and identities are never replaced. Choose another profile +and identity for a second machine. Setup does not install OS dependencies, +start a background service, or change the restricted command policy. +`; + +const VALUE_FLAGS = new Set([ + '--url', + '--worker-id', + '--worker-dir', + '--profile', + '--identity', +]); +const BOOLEAN_FLAGS = new Set([ + '--default-workspace', + '--allow-workspace-writes', + '--allow-workspace-commands', + '--non-interactive', + '--code-stdin', +]); + +export function parseSetupArguments( + args: string[], +): Map { + const options = new Map(); + for (let i = 0; i < args.length; i++) { + const [flag, ...inline] = args[i].split('='); + if ( + (!VALUE_FLAGS.has(flag) && !BOOLEAN_FLAGS.has(flag)) || + options.has(flag) + ) { + throw new Error( + 'Unknown or repeated setup option; use librechat-code setup --help', + ); + } + if (BOOLEAN_FLAGS.has(flag)) { + if (inline.length) + throw new Error( + 'Setup switches do not accept a value; use setup --help', + ); + options.set(flag, true); + } else { + const value = inline.length ? inline.join('=') : args[++i]; + if ( + !value?.trim() || + value.startsWith('--') || + /[\x00-\x1f\x7f]/.test(value) + ) { + throw new Error( + 'A setup option is missing its value; use setup --help', + ); + } + options.set(flag, value); + } + } + if (options.has('--worker-dir') && options.has('--default-workspace')) { + throw new Error('Choose --worker-dir or --default-workspace, not both'); + } + if ( + options.has('--non-interactive') && + (!options.has('--url') || + !options.has('--worker-id') || + !options.has('--code-stdin') || + (!options.has('--worker-dir') && !options.has('--default-workspace'))) + ) { + throw new Error( + 'Non-interactive setup requires --url, --worker-id, --code-stdin, and --worker-dir or --default-workspace', + ); + } + if (options.has('--code-stdin') && !options.has('--non-interactive')) { + throw new Error('--code-stdin requires --non-interactive'); + } + return options; +} + +async function pairingCodeFromStdin(signal: AbortSignal): Promise { + let code = ''; + const abort = () => process.stdin.destroy(new Error('Setup cancelled')); + signal.addEventListener('abort', abort, { once: true }); + try { + signal.throwIfAborted(); + for await (const chunk of process.stdin) { + code += String(chunk); + if (Buffer.byteLength(code) > 1024) + throw new Error('Pairing code input is too large'); + signal.throwIfAborted(); + } + return code.trim(); + } finally { + signal.removeEventListener('abort', abort); + } +} + +export async function runSetupCommand(args: string[]): Promise { + if (args.length === 1 && args[0] === '--help') { + process.stdout.write(SETUP_HELP); + return; + } + const options = parseSetupArguments(args); + const interactive = !options.has('--non-interactive'); + if (interactive && (!process.stdin.isTTY || !process.stdout.isTTY)) { + throw new Error( + 'Guided setup requires a terminal. For automation use --non-interactive and --code-stdin; see setup --help', + ); + } + const controller = new AbortController(); + const abort = () => controller.abort(new Error('Setup cancelled')); + process.once('SIGINT', abort); + let muted = false; + const output = new Writable({ + write(chunk, _encoding, callback) { + if (!muted) process.stdout.write(chunk); + callback(); + }, + }); + const terminal = interactive + ? createInterface({ input: process.stdin, output, terminal: true }) + : undefined; + terminal?.on('SIGINT', abort); + terminal?.once('close', abort); + const ask = async (message: string, secret = false): Promise => { + controller.signal.throwIfAborted(); + process.stdout.write(message); + muted = secret; + try { + return ( + await terminal!.question('', { signal: controller.signal }) + ).trim(); + } finally { + muted = false; + if (secret) process.stdout.write('\n'); + } + }; + const yesNo = async (message: string): Promise => { + for (;;) { + const value = (await ask(`${message} [y/N]: `)).toLowerCase(); + if (value === '' || value === 'n' || value === 'no') return false; + if (value === 'y' || value === 'yes') return true; + process.stdout.write('Enter yes or no.\n'); + } + }; + try { + if (interactive) + process.stdout.write( + 'Connect a machine using the pairing supplied by LibreChat.\nOnly selected folders and permissions will be exposed.\n\n', + ); + const codeApiUrl = setupCodeApiUrl( + String( + options.get('--url') ?? (await ask('Code API URL (including /v1): ')), + ), + ); + const workerId = String( + options.get('--worker-id') ?? (await ask('Worker ID from LibreChat: ')), + ); + if (!isValidBridgeWorkerId(workerId)) + throw new Error('Enter the worker ID supplied by LibreChat'); + let workspaceDirectory = options.get('--worker-dir') as string | undefined; + if ( + interactive && + !workspaceDirectory && + !options.has('--default-workspace') + ) { + workspaceDirectory = + (await ask( + 'Project directory (Enter for a new private workspace): ', + )) || undefined; + } + if (workspaceDirectory != null) + workspaceDirectory = await setupWorkspaceDirectory(workspaceDirectory); + const allowWorkspaceWrites = + options.has('--allow-workspace-writes') || + (interactive && + (await yesNo('Allow file writes within this workspace?'))); + const allowWorkspaceCommands = + options.has('--allow-workspace-commands') || + (interactive && + (await yesNo( + 'Allow sandboxed commands within this workspace? Commands can modify workspace files', + ))); + const profilePath = resolve( + String(options.get('--profile') ?? defaultWorkerProfilePath()), + ); + const code = interactive + ? await ask('One-time pairing code (hidden): ', true) + : await pairingCodeFromStdin(controller.signal); + process.stdout.write( + `\nCode API: ${codeApiUrl}\nWorker: ${workerId}\nWorkspace: ${workspaceDirectory ?? 'new private workspace'}\nFile writes: ${allowWorkspaceWrites ? 'enabled' : 'disabled'}\nCommands: ${allowWorkspaceCommands ? 'enabled, restricted sandbox policy' : 'disabled'}\nProfile: ${profilePath}\n`, + ); + if ( + interactive && + !(await yesNo('Pair this machine and save these choices?')) + ) { + process.stdout.write('Setup cancelled. No pairing was redeemed.\n'); + return; + } + await setupWorker({ + codeApiUrl, + workerId, + code, + profilePath, + identityPath: options.get('--identity') as string | undefined, + workspaceDirectory, + allowWorkspaceWrites, + allowWorkspaceCommands, + signal: controller.signal, + }); + const defaultProfile = profilePath === resolve(defaultWorkerProfilePath()); + const quotedProfile = `'${profilePath.replace(/'/g, "'\\''")}'`; + process.stdout.write( + `\nSetup saved. Start the worker with:\n librechat-code run${defaultProfile ? '' : ` --profile ${quotedProfile}`}\n\nPairing is complete. The worker still needs to start, prepare its sandbox,\nand register before LibreChat can report it ready.\n`, + ); + } finally { + terminal?.off('close', abort); + terminal?.close(); + process.off('SIGINT', abort); + output.destroy(); + } +} diff --git a/packages/code/src/setup.test.ts b/packages/code/src/setup.test.ts new file mode 100644 index 00000000..0241f083 --- /dev/null +++ b/packages/code/src/setup.test.ts @@ -0,0 +1,334 @@ +import assert from 'node:assert/strict'; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { createBridgeIdentity } from './identity.js'; +import { loadBridgeIdentity } from './storage.js'; +import { loadWorkerProfile, saveWorkerProfile } from './worker-profile.js'; +import { + profileRunConfiguration, + setupCodeApiUrl, + setupWorker, +} from './setup.js'; +import { parseSetupArguments } from './setup-cli.js'; +import type { SetupWorkerOptions } from './setup.js'; +import type { PairBridgeWorkerOptions } from './pairing.js'; + +const code = 'one-time-code-value-123456789'; + +async function fixture(t: test.TestContext) { + const root = await mkdtemp(join(tmpdir(), 'worker-setup-')); + t.after(() => rm(root, { recursive: true, force: true })); + const options: SetupWorkerOptions = { + codeApiUrl: 'https://code.example/v1/', + workerId: 'setup-worker', + code, + profilePath: join(root, 'profiles', 'default.json'), + identityPath: join(root, 'identity.json'), + allowWorkspaceWrites: false, + allowWorkspaceCommands: false, + }; + const pair = async (input: PairBridgeWorkerOptions) => ({ + protocolVersion: 1 as const, + workerId: input.workerId, + codeApiUrl: input.codeApiUrl, + ...createBridgeIdentity(), + credential: 'issued-short-lived-credential-value', + expiresAt: '2099-01-01T00:00:00Z', + }); + return { root, options, pair }; +} + +test('setup saves separate identity and least-privilege profile after pairing', async t => { + const { options, pair } = await fixture(t); + let calls = 0; + const saved = await setupWorker(options, { + pair: async input => { + calls++; + assert.equal(input.codeApiUrl, 'https://code.example/v1'); + assert.equal(input.code, code); + assert.ok(input.signal); + return pair(input); + }, + }); + assert.equal(calls, 1); + assert.deepEqual(await loadWorkerProfile(saved.profilePath), saved.profile); + assert.deepEqual(saved.profile.workspace, { type: 'private' }); + assert.equal(saved.profile.allowWorkspaceCommands, false); + assert.equal(saved.profile.allowWorkspaceWrites, false); + assert.equal( + (await loadBridgeIdentity(saved.profile.identityPath)).workerId, + 'setup-worker', + ); + const profileSource = await readFile(saved.profilePath, 'utf8'); + assert.ok(!profileSource.includes(code)); + assert.ok(!profileSource.includes('PRIVATE KEY')); + assert.ok(!profileSource.includes('issued-short-lived')); +}); + +test('setup canonicalizes an explicitly selected project and preflights command dependencies', async t => { + const { root, options, pair } = await fixture(t); + const project = join(root, 'project with spaces'); + await mkdir(project); + let checked = false; + const saved = await setupWorker( + { ...options, workspaceDirectory: project, allowWorkspaceCommands: true }, + { + checkCommands: async () => { + checked = true; + }, + pair: async input => { + assert.equal(checked, true); + return pair(input); + }, + }, + ); + assert.deepEqual(saved.profile.workspace, { + type: 'directory', + path: project, + }); + assert.equal(saved.profile.allowWorkspaceCommands, true); + assert.equal(saved.profile.allowWorkspaceWrites, false); +}); + +test('missing command prerequisites do not spend pairing code or create storage', async t => { + const { root, options, pair } = await fixture(t); + await assert.rejects( + setupWorker( + { ...options, allowWorkspaceCommands: true }, + { + checkCommands: async () => { + throw new Error('missing sandbox dependency'); + }, + pair: async input => { + assert.fail('must not pair'); + return pair(input); + }, + }, + ), + /missing sandbox dependency/, + ); + assert.deepEqual(await readdir(root), []); +}); + +test('denied or expired pairing releases only the new empty reservations', async t => { + const { root, options } = await fixture(t); + await assert.rejects( + setupWorker(options, { + pair: async () => { + throw new Error('Pairing code is invalid or expired'); + }, + }), + /invalid or expired/, + ); + assert.deepEqual(await readdir(root), ['profiles']); + assert.deepEqual(await readdir(join(root, 'profiles')), []); +}); + +test('existing identity is never replaced and code is not redeemed', async t => { + const { options } = await fixture(t); + await writeFile(options.identityPath!, 'keep this identity', { mode: 0o600 }); + await assert.rejects( + setupWorker(options, { + pair: async () => { + assert.fail('must not pair'); + }, + }), + /already exists/, + ); + assert.equal( + await readFile(options.identityPath!, 'utf8'), + 'keep this identity', + ); + assert.equal(await loadWorkerProfile(options.profilePath!), undefined); +}); + +test('profile write failure preserves the paired identity for manual recovery', async t => { + const { options, pair } = await fixture(t); + await assert.rejects( + setupWorker(options, { + pair: async input => { + await rm(options.profilePath!); + await mkdir(options.profilePath!); + return pair(input); + }, + }), + /Machine paired and identity saved.*profile could not be saved/, + ); + assert.equal( + (await loadBridgeIdentity(options.identityPath!)).workerId, + options.workerId, + ); +}); + +test('setup rejects exposed control files even with read-only permissions', async t => { + const { root, options } = await fixture(t); + await assert.rejects( + setupWorker( + { ...options, workspaceDirectory: root }, + { + pair: async () => { + assert.fail('must not pair'); + }, + }, + ), + /outside the workspace/, + ); +}); + +test('setup never admits home or a parent as a project', async t => { + const { options } = await fixture(t); + for (const workspaceDirectory of [homedir(), '/']) { + await assert.rejects( + setupWorker({ ...options, workspaceDirectory }), + /Do not expose your home directory/, + ); + } +}); + +test('setup stops before storage or pairing when cancelled', async t => { + const { root, options } = await fixture(t); + const controller = new AbortController(); + controller.abort(new Error('cancelled by owner')); + await assert.rejects( + setupWorker({ ...options, signal: controller.signal }), + /cancelled by owner/, + ); + assert.deepEqual(await readdir(root), []); +}); + +test('setup accepts HTTPS prefixes and only permits insecure HTTP on loopback', () => { + assert.equal( + setupCodeApiUrl('https://code.example/proxy/v1/'), + 'https://code.example/proxy/v1', + ); + for (const host of ['localhost', '127.0.0.1', '[::1]']) { + assert.equal( + setupCodeApiUrl(`http://${host}:3000/v1`), + `http://${host}:3000/v1`, + ); + } + for (const value of [ + 'http://code.example/v1', + 'ftp://code.example', + 'https://user:secret@code.example/v1', + 'https://code.example/v1?token=secret', + 'https://code.example/v1#fragment', + 'https://code.example/\n', + ]) { + assert.throws(() => setupCodeApiUrl(value), /HTTPS|credentials/); + } +}); + +test('saved profile startup never merges legacy identity or permission settings', async t => { + const { options, pair } = await fixture(t); + const saved = await setupWorker(options, { pair }); + const selected = await profileRunConfiguration( + ['--profile', saved.profilePath], + {}, + ); + assert.equal(selected.profilePath, saved.profilePath); + assert.deepEqual(selected.args, ['--default-workspace']); + assert.equal(selected.profile?.identityPath, options.identityPath); + for (const key of [ + 'LIBRECHAT_CODE_WORKER_TOKEN', + 'LIBRECHAT_CODE_IDENTITY_FILE', + 'LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS', + ]) { + await assert.rejects( + profileRunConfiguration(['--profile', saved.profilePath], { + [key]: 'configured', + }), + /cannot be combined/, + ); + assert.deepEqual( + await profileRunConfiguration(['--worker-dir', '/legacy'], { + [key]: 'configured', + }), + { args: ['--worker-dir', '/legacy'] }, + ); + } + await assert.rejects( + profileRunConfiguration( + ['--profile', saved.profilePath, '--allow-workspace-commands'], + {}, + ), + /cannot be combined with run options/, + ); +}); + +test('explicit profile parse rejects missing, repeated, absent, or corrupt profiles', async t => { + const { options } = await fixture(t); + await assert.rejects( + profileRunConfiguration(['--profile'], {}), + /requires a file/, + ); + await assert.rejects( + profileRunConfiguration(['--profile=a', '--profile=b'], {}), + /only once/, + ); + await assert.rejects( + profileRunConfiguration(['--profile', options.profilePath!], {}), + /not found/, + ); + await mkdir(join(options.profilePath!, '..'), { recursive: true }); + await writeFile(options.profilePath!, '{}', { mode: 0o600 }); + await assert.rejects( + profileRunConfiguration(['--profile', options.profilePath!], {}), + /Invalid worker profile/, + ); +}); + +test('profile file environment variable selects the same profile without changing process environment', async t => { + const { options } = await fixture(t); + const profile = { + version: 1 as const, + identityPath: options.identityPath!, + workspace: { type: 'private' as const }, + allowWorkspaceWrites: true, + allowWorkspaceCommands: false, + }; + await saveWorkerProfile(options.profilePath!, profile); + const environment = { LIBRECHAT_CODE_PROFILE_FILE: options.profilePath! }; + const result = await profileRunConfiguration([], environment); + assert.deepEqual(result.args, [ + '--default-workspace', + '--allow-workspace-writes', + ]); + assert.deepEqual(environment, { + LIBRECHAT_CODE_PROFILE_FILE: options.profilePath!, + }); +}); + +test('setup CLI parsing rejects ambiguity and requires explicit non-interactive access', () => { + for (const args of [ + ['--worker-id'], + ['--url', '--worker-id'], + ['--unknown'], + ['--url=a', '--url=b'], + ['--default-workspace=true'], + ['--worker-dir', '/project', '--default-workspace'], + ['--non-interactive'], + ['--code-stdin'], + ]) { + assert.throws(() => parseSetupArguments(args)); + } + const parsed = parseSetupArguments([ + '--non-interactive', + '--code-stdin', + '--url=https://code.example/v1', + '--worker-id=vm1', + '--default-workspace', + ]); + assert.equal(parsed.get('--allow-workspace-commands'), undefined); + assert.equal(parsed.get('--allow-workspace-writes'), undefined); +}); diff --git a/packages/code/src/setup.ts b/packages/code/src/setup.ts new file mode 100644 index 00000000..46879207 --- /dev/null +++ b/packages/code/src/setup.ts @@ -0,0 +1,254 @@ +import { access, realpath, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +import { pairBridgeWorker } from './pairing.js'; +import { isValidBridgeWorkerId } from './protocol.js'; +import { assertPrivateStorageSupported } from './private-storage.js'; +import { + assertIdentityPathIsPrivate, + defaultBridgeIdentityPath, + saveBridgeIdentity, +} from './storage.js'; +import { + assertWorkerProfileIsolation, + defaultWorkerProfilePath, + loadWorkerProfile, + saveWorkerProfile, + workerProfileArguments, +} from './worker-profile.js'; +import type { WorkerProfile } from './worker-profile.js'; + +export interface SetupWorkerOptions { + codeApiUrl: string; + workerId: string; + code: string; + profilePath?: string; + identityPath?: string; + workspaceDirectory?: string; + allowWorkspaceWrites: boolean; + allowWorkspaceCommands: boolean; + signal?: AbortSignal; +} + +export function setupCodeApiUrl(value: string): string { + if (value.length > 2048 || /[\s\x00-\x1f\x7f]/.test(value)) { + throw new Error( + 'Enter a Code API HTTPS URL without credentials, query, or fragment', + ); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error( + 'Enter a valid Code API HTTPS URL, including its /v1 prefix', + ); + } + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if ( + (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + 'Code API requires HTTPS; HTTP is allowed only on loopback, without credentials, query, or fragment', + ); + } + return url.href.replace(/\/+$/, ''); +} + +export async function checkSetupCommands(): Promise { + const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime'); + if (!SandboxManager.isSupportedPlatform()) { + throw new Error( + 'Native sandbox commands require a supported macOS or Linux host', + ); + } + const dependencies = await SandboxManager.checkDependenciesAsync(); + if (dependencies.errors.length) { + throw new Error( + `Install the native sandbox prerequisites before pairing: ${dependencies.errors.join('; ')}`, + ); + } + await access('/bin/bash', constants.X_OK); +} + +export async function setupWorkspaceDirectory(value: string): Promise { + if (!value.trim() || /[\x00-\x1f\x7f]/.test(value)) { + throw new Error('Choose an existing project directory'); + } + const path = await realpath(resolve(value)); + if (!(await stat(path)).isDirectory()) + throw new Error('Workspace must be a directory'); + const home = await realpath(homedir()); + const homeRelative = relative(path, home); + if ( + homeRelative === '' || + (!isAbsolute(homeRelative) && + homeRelative !== '..' && + !homeRelative.startsWith(`..${sep}`)) + ) { + throw new Error( + 'Do not expose your home directory or a directory containing it; choose a project or a private workspace', + ); + } + return path; +} + +export async function setupWorker( + options: SetupWorkerOptions, + dependencies: { + pair?: typeof pairBridgeWorker; + checkCommands?: typeof checkSetupCommands; + } = {}, +): Promise<{ profilePath: string; profile: WorkerProfile }> { + options.signal?.throwIfAborted(); + assertPrivateStorageSupported(); + const codeApiUrl = setupCodeApiUrl(options.codeApiUrl); + if (!isValidBridgeWorkerId(options.workerId)) + throw new Error('Enter the worker ID supplied by LibreChat'); + if (!/^[A-Za-z0-9_-]{16,256}$/.test(options.code)) + throw new Error('Enter a valid one-time pairing code from LibreChat'); + const profilePath = resolve( + options.profilePath ?? defaultWorkerProfilePath(), + ); + const identityPath = resolve( + options.identityPath ?? defaultBridgeIdentityPath(options.workerId), + ); + if ( + [profilePath, identityPath].some( + path => Buffer.byteLength(path) > 4096 || /[\x00-\x1f\x7f]/.test(path), + ) + ) { + throw new Error( + 'Profile and identity paths must contain no control characters and be at most 4096 bytes', + ); + } + if ( + typeof options.allowWorkspaceWrites !== 'boolean' || + typeof options.allowWorkspaceCommands !== 'boolean' + ) { + throw new Error( + 'Workspace permissions must be explicitly enabled or disabled', + ); + } + const workspaceDirectory = + options.workspaceDirectory == null + ? undefined + : await setupWorkspaceDirectory(options.workspaceDirectory); + const profile: WorkerProfile = { + version: 1, + identityPath, + workspace: + workspaceDirectory == null + ? { type: 'private' } + : { type: 'directory', path: workspaceDirectory }, + allowWorkspaceWrites: options.allowWorkspaceWrites, + allowWorkspaceCommands: options.allowWorkspaceCommands, + }; + if (profile.allowWorkspaceCommands) + await (dependencies.checkCommands ?? checkSetupCommands)(); + options.signal?.throwIfAborted(); + const profileReservation = await assertIdentityPathIsPrivate( + profilePath, + true, + ); + try { + const identityReservation = await assertIdentityPathIsPrivate( + identityPath, + true, + ); + try { + await assertWorkerProfileIsolation( + profilePath, + identityPath, + workspaceDirectory, + ); + const identity = await (dependencies.pair ?? pairBridgeWorker)({ + codeApiUrl, + workerId: options.workerId, + code: options.code, + signal: + options.signal == null + ? AbortSignal.timeout(30_000) + : AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]), + }); + await saveBridgeIdentity(identityPath, identity); + try { + await saveWorkerProfile(profilePath, profile); + } catch { + throw new Error( + `Machine paired and identity saved to ${identityPath}, but the profile could not be saved. Keep this identity and use LIBRECHAT_CODE_IDENTITY_FILE with the existing run command; do not pair again automatically.`, + ); + } + return { profilePath, profile }; + } finally { + await identityReservation.release(); + } + } finally { + await profileReservation.release(); + } +} + +export interface ProfileRunConfiguration { + args: string[]; + profilePath?: string; + profile?: WorkerProfile; +} + +export async function profileRunConfiguration( + args: string[], + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const profileArgs = args.filter( + arg => arg === '--profile' || arg.startsWith('--profile='), + ); + if (profileArgs.length > 1) throw new Error('Supply --profile only once'); + let profilePath = environment.LIBRECHAT_CODE_PROFILE_FILE?.trim(); + if (profileArgs.length) { + const flag = profileArgs[0]; + const value = + flag === '--profile' + ? args[args.indexOf(flag) + 1] + : flag.slice('--profile='.length); + if (!value || value.startsWith('--')) + throw new Error('--profile requires a file'); + profilePath = value; + args = args.filter( + (_, index) => + index !== args.indexOf(flag) && + (flag !== '--profile' || index !== args.indexOf(flag) + 1), + ); + } + const legacyConfiguration = Object.entries(environment).some( + ([key, value]) => + key.startsWith('LIBRECHAT_CODE_') && + key !== 'LIBRECHAT_CODE_PROFILE_FILE' && + value?.trim(), + ); + if (legacyConfiguration) { + if (profilePath) + throw new Error( + 'Saved profiles cannot be combined with LIBRECHAT_CODE_* settings; unset them or use the existing run configuration', + ); + return { args }; + } + const path = resolve(profilePath ?? defaultWorkerProfilePath()); + const profile = await loadWorkerProfile(path); + if (!profile) { + if (profilePath) + throw new Error( + 'Saved profile not found; run librechat-code setup first', + ); + return { args }; + } + if (args.length) + throw new Error( + 'Saved profiles cannot be combined with run options; use a separate profile or the existing environment-variable configuration', + ); + return { profilePath: path, profile, args: workerProfileArguments(profile) }; +} diff --git a/packages/code/src/storage.ts b/packages/code/src/storage.ts index 904a8562..b7fb094d 100644 --- a/packages/code/src/storage.ts +++ b/packages/code/src/storage.ts @@ -223,7 +223,18 @@ async function readGuardedFile( if ((mode & 0o077) !== 0) throw new BridgeProtocolError(exposed(mode.toString(8))); } await assertPrivateStorageAcl(handle, path); - return await handle.readFile('utf8'); + if (!Number.isFinite(maxBytes)) return await handle.readFile('utf8'); + const buffer = Buffer.alloc(maxBytes + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > maxBytes) { + throw new BridgeProtocolError('Invalid private storage file size or type'); + } + return buffer.toString('utf8', 0, length); } finally { await handle.close(); } @@ -360,6 +371,7 @@ async function assertSiblingPublishable(path: string): Promise { export async function assertIdentityPathIsPrivate( path: string, + createOnly = false, ): Promise { assertPrivateStorageSupported(); await assertWriteContainerPrivate(path); @@ -389,6 +401,11 @@ export async function assertIdentityPathIsPrivate( throw error; } if (!isRecord(error) || error.code !== 'EEXIST') throw error; + if (createOnly) { + throw new BridgeProtocolError( + `${path} already exists; choose a different identity/profile path.`, + ); + } /* Already present: judge what is there instead of the placeholder, and * prove the publish itself is possible - an unwritable parent would * otherwise surface as EACCES only after the code was spent. */ @@ -465,7 +482,25 @@ export async function saveEnvironmentPreparationKey(path: string, key: string): await savePrivateJson(path, { version: 1, key }); } -async function savePrivateJson(path: string, value: unknown): Promise { +export async function readPrivateJson(path: string, maxBytes: number): Promise { + assertPrivateStorageSupported(); + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new BridgeProtocolError('Private JSON file limit must be a positive integer'); + } + const content = await readGuardedFile( + path, + (mode) => `Private JSON file ${path} must be owner-only (mode ${mode})`, + maxBytes, + ); + await assertReadPathPrivate(path); + try { + return JSON.parse(content) as unknown; + } catch { + throw new BridgeProtocolError(`Invalid private JSON file: ${path}`); + } +} + +export async function savePrivateJson(path: string, value: unknown): Promise { assertPrivateStorageSupported(); await assertWriteContainerPrivate(path); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); diff --git a/packages/code/src/worker-profile.test.ts b/packages/code/src/worker-profile.test.ts new file mode 100644 index 00000000..585144c0 --- /dev/null +++ b/packages/code/src/worker-profile.test.ts @@ -0,0 +1,529 @@ +import assert from 'node:assert/strict'; +import { + chmod, + link, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { assertIdentityPathIsPrivate } from './storage.js'; +import { + assertWorkerProfileIsolation, + defaultWorkerProfilePath, + loadWorkerProfile, + saveWorkerProfile, + workerProfileArguments, +} from './worker-profile.js'; +import type { WorkerProfile } from './worker-profile.js'; + +function profile(directory: string): WorkerProfile { + return { + version: 1, + identityPath: join(directory, 'identity.json'), + workspace: { type: 'private' }, + allowWorkspaceWrites: false, + allowWorkspaceCommands: false, + }; +} + +test('the default worker profile is separate from identity storage', () => { + assert.equal( + defaultWorkerProfilePath(), + join(homedir(), '.config', 'librechat', 'code', 'profiles', 'default.json'), + ); +}); + +test('worker profiles roundtrip atomically with owner-only file and directory permissions', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profiles', 'default.json'); + const value = profile(directory); + await saveWorkerProfile(path, value); + assert.deepEqual(await loadWorkerProfile(path), value); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), value); + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.equal((await stat(join(directory, 'profiles'))).mode & 0o777, 0o700); + + const updated: WorkerProfile = { + ...value, + workspace: { + type: 'directory', + path: join(directory, 'project with spaces'), + }, + allowWorkspaceWrites: true, + allowWorkspaceCommands: true, + }; + await saveWorkerProfile(path, updated); + assert.deepEqual(await loadWorkerProfile(path), updated); + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.deepEqual(await readdir(join(directory, 'profiles')), [ + 'default.json', + ]); +}); + +test('worker profile arguments enable only explicitly granted permissions', () => { + for (const workspace of [ + { type: 'private' as const }, + { type: 'directory' as const, path: '/srv/project with spaces' }, + ]) { + for (const allowWorkspaceWrites of [false, true]) { + for (const allowWorkspaceCommands of [false, true]) { + const value = { + ...profile('/srv'), + workspace, + allowWorkspaceWrites, + allowWorkspaceCommands, + }; + assert.deepEqual(workerProfileArguments(value), [ + ...(workspace.type === 'private' + ? ['--default-workspace'] + : ['--worker-dir', workspace.path]), + ...(allowWorkspaceWrites ? ['--allow-workspace-writes'] : []), + ...(allowWorkspaceCommands ? ['--allow-workspace-commands'] : []), + ]); + } + } + } +}); + +test('only an absent worker profile returns undefined', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-missing-')); + t.after(() => rm(directory, { recursive: true, force: true })); + assert.equal( + await loadWorkerProfile(join(directory, 'absent.json')), + undefined, + ); + assert.equal( + await loadWorkerProfile(join(directory, 'missing', 'absent.json')), + undefined, + ); + await assert.rejects(loadWorkerProfile(directory), /size or type/); + + const dangling = join(directory, 'dangling.json'); + await symlink(join(directory, 'absent.json'), dangling); + await assert.rejects(loadWorkerProfile(dangling), { code: 'ENOENT' }); + const loop = join(directory, 'loop.json'); + await symlink(loop, loop); + await assert.rejects(loadWorkerProfile(loop), /Too many storage symlinks/); +}); + +test('malformed, empty, and oversized profiles fail without disclosing their contents', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-invalid-json-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + for (const content of ['', ' ', '{"credential":"do-not-disclose",']) { + await writeFile(path, content, { mode: 0o600 }); + await assert.rejects(loadWorkerProfile(path), (error: Error) => { + assert.match(error.message, /Invalid private JSON file/); + assert.doesNotMatch(error.message, /do-not-disclose/); + return true; + }); + } + await writeFile( + path, + `${JSON.stringify(profile(directory))}${' '.repeat(32 * 1024)}`, + { mode: 0o600 }, + ); + await assert.rejects(loadWorkerProfile(path), /size or type/); +}); + +test('profiles reject unknown fields, unsupported versions, invalid paths, and implicit grants', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-fields-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const value = profile(directory); + const cases: [string, unknown][] = [ + ['null', null], + ['array', []], + ['unsupported version', { ...value, version: 2 }], + ['unknown root field', { ...value, credential: 'not-allowed' }], + [ + 'unknown private workspace field', + { ...value, workspace: { type: 'private', path: '/srv' } }, + ], + [ + 'unknown directory workspace field', + { ...value, workspace: { type: 'directory', path: '/srv', extra: true } }, + ], + ['unknown workspace type', { ...value, workspace: { type: 'shared' } }], + ['missing workspace path', { ...value, workspace: { type: 'directory' } }], + ['relative identity path', { ...value, identityPath: 'identity.json' }], + [ + 'relative workspace path', + { ...value, workspace: { type: 'directory', path: './project' } }, + ], + ['empty identity path', { ...value, identityPath: '' }], + ['NUL identity path', { ...value, identityPath: '/srv/identity\0.json' }], + [ + 'NUL workspace path', + { ...value, workspace: { type: 'directory', path: '/srv/project\0' } }, + ], + [ + 'oversized identity path', + { ...value, identityPath: `/${'x'.repeat(4096)}` }, + ], + [ + 'oversized workspace path', + { + ...value, + workspace: { type: 'directory', path: `/${'é'.repeat(2048)}` }, + }, + ], + [ + 'missing write permission', + { + version: 1, + identityPath: value.identityPath, + workspace: value.workspace, + allowWorkspaceCommands: false, + }, + ], + ['string write permission', { ...value, allowWorkspaceWrites: 'true' }], + ['numeric command permission', { ...value, allowWorkspaceCommands: 1 }], + ]; + for (const [name, invalid] of cases) { + await t.test(name, async () => { + const serialized = JSON.stringify(invalid); + await writeFile(path, serialized, { mode: 0o600 }); + await assert.rejects(loadWorkerProfile(path), /worker profile/i); + await assert.rejects( + saveWorkerProfile(path, invalid as WorkerProfile), + /worker profile/i, + ); + assert.equal(await readFile(path, 'utf8'), serialized); + assert.throws( + () => workerProfileArguments(invalid as WorkerProfile), + /worker profile/i, + ); + }); + } + await assert.rejects( + loadWorkerProfile('relative.json'), + /paths must be absolute/, + ); + await assert.rejects( + saveWorkerProfile('relative.json', value), + /paths must be absolute/, + ); +}); + +test('save rejects non-JSON fields and bounds the serialized profile size', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-serialization-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const value = profile(directory); + await assert.rejects( + saveWorkerProfile(path, { ...value, ignored: undefined } as WorkerProfile), + /fields/, + ); + await assert.rejects( + saveWorkerProfile(path, { ...value, [Symbol('ignored')]: true }), + /fields/, + ); + await assert.rejects( + saveWorkerProfile(path, { ...value, toJSON: () => value } as WorkerProfile), + /fields/, + ); + await assert.rejects( + saveWorkerProfile(path, { + ...value, + identityPath: `/${'"'.repeat(4095)}`, + workspace: { type: 'directory', path: `/${'"'.repeat(4095)}` }, + }), + /16 KiB limit/, + ); + await assert.rejects(stat(path), { code: 'ENOENT' }); +}); + +test('private profile reads remain bounded if the file grows after its size check', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-growth-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + await saveWorkerProfile(path, profile(directory)); + const probe = await open(path, 'r'); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + const originalRead = prototype.read; + let grown = false; + t.mock.method( + prototype, + 'read', + async function (this: FileHandle, ...args: unknown[]) { + if (!grown) { + grown = true; + await writeFile( + path, + `${JSON.stringify(profile(directory))}${' '.repeat(32 * 1024)}`, + ); + } + return Reflect.apply(originalRead, this, args); + }, + ); + await assert.rejects(loadWorkerProfile(path), /size or type/); + assert.equal(grown, true); +}); + +test('permission errors are not mistaken for an absent profile', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-permission-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + await saveWorkerProfile(path, profile(directory)); + const probe = await open(path, 'r'); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + t.mock.method(prototype, 'read', async () => { + throw Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + }); + await assert.rejects(loadWorkerProfile(path), { code: 'EACCES' }); +}); + +test('exposed profiles and unsafe storage ancestry are rejected', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-modes-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const value = profile(directory); + await saveWorkerProfile(path, value); + await chmod(path, 0o644); + await assert.rejects(loadWorkerProfile(path), /owner-only.*644/); + await chmod(path, 0o600); + await chmod(directory, 0o777); + await assert.rejects(loadWorkerProfile(path), /writable by other accounts/); + await assert.rejects( + loadWorkerProfile(join(directory, 'missing.json')), + /writable by other accounts/, + ); + await assert.rejects( + saveWorkerProfile(path, value), + /writable by other accounts/, + ); +}); + +test('worker profile storage fails closed on unsupported platforms', async t => { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + t.after(() => Object.defineProperty(process, 'platform', descriptor)); + Object.defineProperty(process, 'platform', { + value: 'freebsd', + configurable: true, + }); + const value = profile('/srv'); + await assert.rejects( + loadWorkerProfile('/srv/missing.json'), + /unavailable on this platform/, + ); + await assert.rejects( + saveWorkerProfile('/srv/profile.json', value), + /unavailable on this platform/, + ); + await assert.rejects( + assertWorkerProfileIsolation('/srv/profile.json', value.identityPath), + /unavailable on this platform/, + ); +}); + +test('isolation accepts separate reserved files outside the workspace', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-isolation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const identity = join(directory, 'identity.json'); + const workspace = join(directory, 'workspace'); + await mkdir(workspace); + const reservedProfile = await assertIdentityPathIsPrivate(path, true); + const reservedIdentity = await assertIdentityPathIsPrivate(identity, true); + await assertWorkerProfileIsolation(path, identity); + await assertWorkerProfileIsolation(path, identity, workspace); + await reservedIdentity.release(); + await reservedProfile.release(); + assert.deepEqual(await readdir(directory), ['workspace']); +}); + +test('isolation rejects lexical, symlink, and hardlink aliases of the identity', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-alias-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const identity = join(directory, 'identity.json'); + await writeFile(identity, '', { mode: 0o600 }); + const symlinkPath = join(directory, 'symlink.json'); + const hardlinkPath = join(directory, 'hardlink.json'); + await symlink(identity, symlinkPath); + await link(identity, hardlinkPath); + for (const path of [ + identity, + `${directory}/./identity.json`, + symlinkPath, + hardlinkPath, + ]) { + await assert.rejects( + assertWorkerProfileIsolation(path, identity), + /separate files, not aliases/, + ); + } +}); + +test('isolation rejects either private file inside a read-only workspace', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-containment-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const workspace = join(directory, 'workspace'); + await mkdir(workspace); + const profilePath = join(directory, 'profile.json'); + const identity = join(directory, 'identity.json'); + const inside = join(workspace, 'storage.json'); + for (const path of [profilePath, identity, inside]) + await writeFile(path, '', { mode: 0o600 }); + await assert.rejects( + assertWorkerProfileIsolation(inside, identity, workspace), + /outside the workspace/, + ); + await assert.rejects( + assertWorkerProfileIsolation(profilePath, inside, workspace), + /outside the workspace/, + ); + const workspaceAlias = join(directory, 'workspace-alias'); + await symlink(workspace, workspaceAlias); + await assert.rejects( + assertWorkerProfileIsolation(profilePath, inside, workspaceAlias), + /outside the workspace/, + ); +}); + +test('isolation checks every intermediate symlink and traversed storage ancestor', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-link-ancestry-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const workspace = join(directory, 'workspace'); + const outside = join(directory, 'workspace-private'); + await mkdir(workspace); + await mkdir(outside); + const profilePath = join(outside, 'profile.json'); + const identity = join(outside, 'identity.json'); + await writeFile(profilePath, '', { mode: 0o600 }); + await writeFile(identity, '', { mode: 0o600 }); + await assertWorkerProfileIsolation(profilePath, identity, workspace); + + const linkInWorkspace = join(workspace, 'storage'); + const indirect = join(directory, 'indirect'); + await symlink(outside, linkInWorkspace); + await symlink(linkInWorkspace, indirect); + const cases = [ + join(linkInWorkspace, 'profile.json'), + join(indirect, 'profile.json'), + `${workspace}/../workspace-private/profile.json`, + `${indirect}/../workspace-private/profile.json`, + ]; + for (const path of cases) { + await assert.rejects( + assertWorkerProfileIsolation(path, identity, workspace), + /outside the workspace/, + ); + } + await assert.rejects( + assertWorkerProfileIsolation( + profilePath, + join(indirect, 'identity.json'), + workspace, + ), + /outside the workspace/, + ); + const fileLink = join(workspace, 'identity-link.json'); + await symlink(identity, fileLink); + await assert.rejects( + assertWorkerProfileIsolation(profilePath, fileLink, workspace), + /outside the workspace/, + ); +}); + +test('isolation fails on missing or non-file storage and missing or non-directory workspaces', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-isolation-invalid-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const identity = join(directory, 'identity.json'); + await writeFile(path, '', { mode: 0o600 }); + await writeFile(identity, '', { mode: 0o600 }); + await assert.rejects( + assertWorkerProfileIsolation(join(directory, 'missing.json'), identity), + { code: 'ENOENT' }, + ); + await assert.rejects( + assertWorkerProfileIsolation(directory, identity), + /regular files/, + ); + await assert.rejects( + assertWorkerProfileIsolation(path, identity, join(directory, 'missing')), + { code: 'ENOENT' }, + ); + await assert.rejects( + assertWorkerProfileIsolation(path, identity, path), + /must be a directory/, + ); +}); + +test('create-only reservations never overwrite existing profiles or identities', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-create-only-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const value = profile(directory); + await saveWorkerProfile(path, value); + const before = await stat(path, { bigint: true }); + await assert.rejects( + assertIdentityPathIsPrivate(path, true), + /already exists; choose a different identity\/profile path/, + ); + assert.equal((await stat(path, { bigint: true })).ino, before.ino); + assert.deepEqual(await loadWorkerProfile(path), value); + const existing = await assertIdentityPathIsPrivate(path); + await existing.release(); + assert.deepEqual(await loadWorkerProfile(path), value); +}); + +test('only one concurrent create-only reservation can claim a path', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-reservations-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const results = await Promise.allSettled([ + assertIdentityPathIsPrivate(path, true), + assertIdentityPathIsPrivate(path, true), + ]); + const successes = results.filter(result => result.status === 'fulfilled'); + const failures = results.filter(result => result.status === 'rejected'); + assert.equal(successes.length, 1); + assert.equal(failures.length, 1); + assert.match(String(failures[0].reason), /already exists/); + assert.equal((await stat(path)).size, 0); + await successes[0].value.release(); + await assert.rejects(stat(path), { code: 'ENOENT' }); +}); + +test('reservation cleanup preserves a profile published over its placeholder', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-reservation-release-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const reservation = await assertIdentityPathIsPrivate(path, true); + const value = profile(directory); + await saveWorkerProfile(path, value); + await reservation.release(); + assert.deepEqual(await loadWorkerProfile(path), value); +}); diff --git a/packages/code/src/worker-profile.ts b/packages/code/src/worker-profile.ts new file mode 100644 index 00000000..a466a5d1 --- /dev/null +++ b/packages/code/src/worker-profile.ts @@ -0,0 +1,200 @@ +import { lstat, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, relative, sep } from 'node:path'; + +import { assertPrivateStorageAncestors } from './private-storage.js'; +import { BridgeProtocolError } from './protocol.js'; +import { readPrivateJson, savePrivateJson } from './storage.js'; + +const MAX_PROFILE_BYTES = 16 * 1024; +const MAX_PATH_BYTES = 4096; + +export interface WorkerProfile { + version: 1; + identityPath: string; + workspace: { type: 'private' } | { type: 'directory'; path: string }; + allowWorkspaceWrites: boolean; + allowWorkspaceCommands: boolean; +} + +function hasExactKeys( + value: unknown, + keys: string[], +): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Reflect.ownKeys(value).length === keys.length && + keys.every(key => Object.hasOwn(value, key)) + ); +} + +function assertProfilePath(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + !isAbsolute(value) || + /[\x00-\x1f\x7f]/.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES + ) { + throw new BridgeProtocolError( + 'Worker profile paths must be absolute, contain no control characters, and be at most 4096 bytes', + ); + } +} + +function parseWorkerProfile(value: unknown): WorkerProfile { + if ( + !hasExactKeys(value, [ + 'version', + 'identityPath', + 'workspace', + 'allowWorkspaceWrites', + 'allowWorkspaceCommands', + ]) || + value.version !== 1 || + typeof value.allowWorkspaceWrites !== 'boolean' || + typeof value.allowWorkspaceCommands !== 'boolean' + ) { + throw new BridgeProtocolError( + 'Invalid worker profile fields or unsupported version', + ); + } + assertProfilePath(value.identityPath); + let workspace: WorkerProfile['workspace']; + if ( + hasExactKeys(value.workspace, ['type']) && + value.workspace.type === 'private' + ) { + workspace = { type: 'private' }; + } else if ( + hasExactKeys(value.workspace, ['type', 'path']) && + value.workspace.type === 'directory' + ) { + assertProfilePath(value.workspace.path); + workspace = { type: 'directory', path: value.workspace.path }; + } else { + throw new BridgeProtocolError('Invalid worker profile workspace'); + } + return { + version: 1, + identityPath: value.identityPath, + workspace, + allowWorkspaceWrites: value.allowWorkspaceWrites, + allowWorkspaceCommands: value.allowWorkspaceCommands, + }; +} + +export function defaultWorkerProfilePath(): string { + return join( + homedir(), + '.config', + 'librechat', + 'code', + 'profiles', + 'default.json', + ); +} + +export async function loadWorkerProfile( + path: string, +): Promise { + assertProfilePath(path); + let value: unknown; + try { + value = await readPrivateJson(path, MAX_PROFILE_BYTES); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + // A dangling leaf symlink is not an absent profile. + const entry = await lstat(path).catch((failure: NodeJS.ErrnoException) => { + if (failure.code === 'ENOENT') return undefined; + throw failure; + }); + if (entry !== undefined) throw error; + return undefined; + } + return parseWorkerProfile(value); +} + +export async function saveWorkerProfile( + path: string, + profile: WorkerProfile, +): Promise { + assertProfilePath(path); + const value = parseWorkerProfile(profile); + if ( + Buffer.byteLength(`${JSON.stringify(value, null, 2)}\n`, 'utf8') > + MAX_PROFILE_BYTES + ) { + throw new BridgeProtocolError('Worker profile exceeds the 16 KiB limit'); + } + await savePrivateJson(path, value); +} + +export function workerProfileArguments(profile: WorkerProfile): string[] { + const value = parseWorkerProfile(profile); + const args = + value.workspace.type === 'private' + ? ['--default-workspace'] + : ['--worker-dir', value.workspace.path]; + if (value.allowWorkspaceWrites) args.push('--allow-workspace-writes'); + if (value.allowWorkspaceCommands) args.push('--allow-workspace-commands'); + return args; +} + +function isWithin(root: string, path: string): boolean { + const suffix = relative(root, path); + return ( + suffix === '' || + (suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)) + ); +} + +export async function assertWorkerProfileIsolation( + profilePath: string, + identityPath: string, + workspaceRoot?: string, +): Promise { + assertProfilePath(profilePath); + assertProfilePath(identityPath); + if (workspaceRoot !== undefined) assertProfilePath(workspaceRoot); + const [profileAncestors, identityAncestors] = await Promise.all([ + assertPrivateStorageAncestors(profilePath), + assertPrivateStorageAncestors(identityPath), + ]); + const [profile, identity, profileMetadata, identityMetadata] = + await Promise.all([ + realpath(profilePath), + realpath(identityPath), + stat(profilePath, { bigint: true }), + stat(identityPath, { bigint: true }), + ]); + if (!profileMetadata.isFile() || !identityMetadata.isFile()) { + throw new BridgeProtocolError( + 'Worker profile and identity paths must be regular files', + ); + } + if ( + profile === identity || + (profileMetadata.dev === identityMetadata.dev && + profileMetadata.ino === identityMetadata.ino) + ) { + throw new BridgeProtocolError( + 'Worker profile and identity must use separate files, not aliases', + ); + } + if (workspaceRoot === undefined) return; + const root = await realpath(workspaceRoot); + if (!(await stat(root)).isDirectory()) { + throw new BridgeProtocolError('Worker workspace root must be a directory'); + } + if ( + [profile, identity, ...profileAncestors, ...identityAncestors].some(path => + isWithin(root, path), + ) + ) { + throw new BridgeProtocolError( + 'Worker profile, identity, and their storage ancestry must stay outside the workspace', + ); + } +} From a514ea740c484971f4aff08ceee27ce65e685b92 Mon Sep 17 00:00:00 2001 From: Lia Date: Tue, 20 Oct 2026 13:51:27 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=92=20fix:=20Keep=20Pairing=20Code?= =?UTF-8?q?s=20Out=20Of=20Terminal=20History?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/src/setup-cli.test.ts | 52 +++++++++++++++++++++++++++++ packages/code/src/setup-cli.ts | 7 +++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/code/src/setup-cli.test.ts b/packages/code/src/setup-cli.test.ts index 7a8cacc4..b339c84a 100644 --- a/packages/code/src/setup-cli.test.ts +++ b/packages/code/src/setup-cli.test.ts @@ -202,3 +202,55 @@ test('CLI refuses unsafe URLs before disclosing the code', async t => { assert.ok(!result.stdout.includes(code)); assert.ok(!result.stderr.includes(code)); }); + +test( + 'terminal history never recalls a hidden pairing code at confirmation', + { timeout: 10_000 }, + async t => { + const { env } = await fixture(t); + // Real readline terminal editing over pipes avoids an OS-specific PTY dependency. + const bootstrap = ` + Object.defineProperty(process.stdin, 'isTTY', { value: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true }); + process.argv = [process.execPath, ${JSON.stringify(cli)}, 'setup', '--url', + 'https://code.example/v1', '--worker-id', 'history-vm', '--default-workspace']; + await import(${JSON.stringify(new URL('./cli.js', import.meta.url).href)}); + `; + const child = spawn( + process.execPath, + ['--input-type=module', '--eval', bootstrap], + { env, stdio: 'pipe' }, + ); + t.after(() => child.kill('SIGKILL')); + let transcript = ''; + let stderr = ''; + let step = 0; + const inputs = [ + { prompt: 'Allow file writes', input: '\n' }, + { prompt: 'Allow sandboxed commands', input: '\n' }, + { prompt: 'One-time pairing code (hidden): ', input: `${code}\n` }, + // Up and Ctrl-P must not reveal the secret before it is redeemed. + { + prompt: 'Pair this machine and save these choices?', + input: '\x1b[A\x10\x15n\n', + }, + ]; + child.stderr.on('data', chunk => { + stderr += String(chunk); + }); + child.stdout.on('data', chunk => { + transcript += String(chunk); + if (step < inputs.length && transcript.includes(inputs[step].prompt)) { + child.stdin.write(inputs[step++].input); + } + }); + const [status] = await once(child, 'exit'); + assert.equal(step, inputs.length, stderr); + assert.equal(status, 0, stderr); + assert.ok( + !transcript.includes(code), + 'hidden pairing code was recalled visibly from history', + ); + assert.match(transcript, /No pairing was redeemed/); + }, +); diff --git a/packages/code/src/setup-cli.ts b/packages/code/src/setup-cli.ts index a2fae1c5..070b3822 100644 --- a/packages/code/src/setup-cli.ts +++ b/packages/code/src/setup-cli.ts @@ -144,7 +144,12 @@ export async function runSetupCommand(args: string[]): Promise { }, }); const terminal = interactive - ? createInterface({ input: process.stdin, output, terminal: true }) + ? createInterface({ + input: process.stdin, + output, + terminal: true, + historySize: 0, + }) : undefined; terminal?.on('SIGINT', abort); terminal?.once('close', abort); From 3fd0d5dffc8f701f6f17694b07691f022174b51d Mon Sep 17 00:00:00 2001 From: Lia Date: Tue, 20 Oct 2026 14:00:31 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=92=20fix:=20Isolate=20Secret=20Pr?= =?UTF-8?q?ompt=20Editing=20State?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/src/setup-cli.test.ts | 100 +++++++++++++++------------- packages/code/src/setup-cli.ts | 28 ++++---- 2 files changed, 69 insertions(+), 59 deletions(-) diff --git a/packages/code/src/setup-cli.test.ts b/packages/code/src/setup-cli.test.ts index b339c84a..1bef6e14 100644 --- a/packages/code/src/setup-cli.test.ts +++ b/packages/code/src/setup-cli.test.ts @@ -203,54 +203,64 @@ test('CLI refuses unsafe URLs before disclosing the code', async t => { assert.ok(!result.stderr.includes(code)); }); -test( - 'terminal history never recalls a hidden pairing code at confirmation', - { timeout: 10_000 }, - async t => { - const { env } = await fixture(t); - // Real readline terminal editing over pipes avoids an OS-specific PTY dependency. - const bootstrap = ` +for (const [name, secretEditing] of [ + ['history', ''], + ['kill beginning', '\x15\x19'], + ['kill end', '\x01\x0b\x19'], + ['kill word', '\x17\x19'], +]) + test( + `terminal ${name} never recalls a hidden pairing code at confirmation`, + { timeout: 10_000 }, + async t => { + const { env } = await fixture(t); + const secret = 'SyntheticPairingSecret0123456789'; + // Real readline terminal editing over pipes avoids an OS-specific PTY dependency. + const bootstrap = ` Object.defineProperty(process.stdin, 'isTTY', { value: true }); Object.defineProperty(process.stdout, 'isTTY', { value: true }); process.argv = [process.execPath, ${JSON.stringify(cli)}, 'setup', '--url', 'https://code.example/v1', '--worker-id', 'history-vm', '--default-workspace']; await import(${JSON.stringify(new URL('./cli.js', import.meta.url).href)}); `; - const child = spawn( - process.execPath, - ['--input-type=module', '--eval', bootstrap], - { env, stdio: 'pipe' }, - ); - t.after(() => child.kill('SIGKILL')); - let transcript = ''; - let stderr = ''; - let step = 0; - const inputs = [ - { prompt: 'Allow file writes', input: '\n' }, - { prompt: 'Allow sandboxed commands', input: '\n' }, - { prompt: 'One-time pairing code (hidden): ', input: `${code}\n` }, - // Up and Ctrl-P must not reveal the secret before it is redeemed. - { - prompt: 'Pair this machine and save these choices?', - input: '\x1b[A\x10\x15n\n', - }, - ]; - child.stderr.on('data', chunk => { - stderr += String(chunk); - }); - child.stdout.on('data', chunk => { - transcript += String(chunk); - if (step < inputs.length && transcript.includes(inputs[step].prompt)) { - child.stdin.write(inputs[step++].input); - } - }); - const [status] = await once(child, 'exit'); - assert.equal(step, inputs.length, stderr); - assert.equal(status, 0, stderr); - assert.ok( - !transcript.includes(code), - 'hidden pairing code was recalled visibly from history', - ); - assert.match(transcript, /No pairing was redeemed/); - }, -); + const child = spawn( + process.execPath, + ['--input-type=module', '--eval', bootstrap], + { env, stdio: 'pipe' }, + ); + t.after(() => child.kill('SIGKILL')); + let transcript = ''; + let stderr = ''; + let step = 0; + const inputs = [ + { prompt: 'Allow file writes', input: '\n' }, + { prompt: 'Allow sandboxed commands', input: '\n' }, + { + prompt: 'One-time pairing code (hidden): ', + input: `${secret}${secretEditing}\n`, + }, + // History and yank must not reveal secret editing state before redemption. + { + prompt: 'Pair this machine and save these choices?', + input: '\x1b[A\x10\x19\x15n\n', + }, + ]; + child.stderr.on('data', chunk => { + stderr += String(chunk); + }); + child.stdout.on('data', chunk => { + transcript += String(chunk); + if (step < inputs.length && transcript.includes(inputs[step].prompt)) { + child.stdin.write(inputs[step++].input); + } + }); + const [status] = await once(child, 'exit'); + assert.equal(step, inputs.length, stderr); + assert.equal(status, 0, stderr); + assert.ok( + !transcript.includes(secret), + 'hidden pairing code was recalled visibly from history', + ); + assert.match(transcript, /No pairing was redeemed/); + }, + ); diff --git a/packages/code/src/setup-cli.ts b/packages/code/src/setup-cli.ts index 070b3822..877a5c17 100644 --- a/packages/code/src/setup-cli.ts +++ b/packages/code/src/setup-cli.ts @@ -143,25 +143,27 @@ export async function runSetupCommand(args: string[]): Promise { callback(); }, }); - const terminal = interactive - ? createInterface({ - input: process.stdin, - output, - terminal: true, - historySize: 0, - }) - : undefined; - terminal?.on('SIGINT', abort); - terminal?.once('close', abort); const ask = async (message: string, secret = false): Promise => { controller.signal.throwIfAborted(); - process.stdout.write(message); muted = secret; + // Readline retains more than history (including a kill ring). Never reuse prompt state. + const terminal = createInterface({ + input: process.stdin, + output, + terminal: true, + historySize: 0, + }); + terminal.on('SIGINT', abort); + terminal.once('close', abort); + process.stdout.write(message); try { return ( - await terminal!.question('', { signal: controller.signal }) + await terminal.question('', { signal: controller.signal }) ).trim(); } finally { + terminal.off('close', abort); + terminal.off('SIGINT', abort); + terminal.close(); muted = false; if (secret) process.stdout.write('\n'); } @@ -245,8 +247,6 @@ export async function runSetupCommand(args: string[]): Promise { `\nSetup saved. Start the worker with:\n librechat-code run${defaultProfile ? '' : ` --profile ${quotedProfile}`}\n\nPairing is complete. The worker still needs to start, prepare its sandbox,\nand register before LibreChat can report it ready.\n`, ); } finally { - terminal?.off('close', abort); - terminal?.close(); process.off('SIGINT', abort); output.destroy(); }