diff --git a/docs/remote-bridge/worker-runbook.md b/docs/remote-bridge/worker-runbook.md index 16c24742..3043c541 100644 --- a/docs/remote-bridge/worker-runbook.md +++ b/docs/remote-bridge/worker-runbook.md @@ -327,12 +327,19 @@ Environment=LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE=/home/librechat-code/.config/ ``` Install the same App separately on every personal account or organization the -worker is allowed to use. The trusted worker resolves the correct installation -from the repository containing each command's working directory, then mints and -caches a repository-scoped token. Cross-repository work therefore does not -require changing an installation ID or restarting the worker. Set -`LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` only as a legacy fixed-installation -fallback. +worker is allowed to use. By default, the worker binds each admitted workspace +root to its repository at startup, then mints and caches repository-scoped +tokens. Different admitted roots can use different installations without +restarting the worker. For a trusted VM with multiple checkouts under one root, +set `LIBRECHAT_CODE_GITHUB_REPOSITORY_ROUTING=checkout` and use the `trusted-vm` +command policy. This opt-in resolves the local `origin` URL of each command's +current checkout, including linked worktrees. It remains inside the admitted +filesystem root, but anyone able to alter a checkout's remote can select any +repository where the App is installed; keep the App's installation scope narrow. +Pass the checkout as the command working directory; changing directories only +inside the shell cannot change the token chosen before command launch. +Set `LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` only as a legacy +fixed-installation fallback; it cannot be combined with checkout routing. Sandboxed commands receive masked Git/`gh` credentials only for the configured GitHub hosts; the token is not written to the repository, remote URL, or Git diff --git a/packages/code/README.md b/packages/code/README.md index e30b80cb..5a06b92a 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -293,9 +293,23 @@ installation tokens. At startup, the worker binds each explicitly admitted workspace root to its Git repository. Commands in those independent roots can use simultaneous installations on personal accounts and organizations without being restarted or reconfigured, while a command cannot gain access by changing -its workspace's remote URL. Tokens are scoped and cached per repository. For -compatibility with deployments -that intentionally bind a worker to one installation, set the optional legacy +its workspace's remote URL. Tokens are scoped and cached per repository. + +For trusted VMs that intentionally work in multiple Git checkouts beneath one +admitted root, opt in to `--github-repository-routing checkout` (or +`LIBRECHAT_CODE_GITHUB_REPOSITORY_ROUTING=checkout`) together with the +`trusted-vm` command policy. Each command then uses the repository identified +by its current checkout's local `origin` URL, including linked worktrees. +The command must set its working directory to that checkout; a shell `cd` +inside a command does not change which credential was selected before launch. +This does not widen the admitted filesystem roots, but a command able to alter +a checkout's remote can obtain a token for **any repository where the App is +installed**. Use this mode only where the machine operator trusts the VM and +the App's installation scope; the default `admitted` mode keeps the startup +binding. Checkout routing requires an App without a fixed installation ID. + +For compatibility with deployments that intentionally bind a worker to one +installation, set the optional legacy `LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` fallback. App-authenticated commits use the GitHub App bot's canonical no-reply identity, @@ -758,8 +772,10 @@ Also archive any adjacent `.source` staging directory. Pre-release version-1 completion records are deliberately preserved but not admitted by this version; they do not contain the required source Git identity binding. -GitHub App routing is inherited from the operator-admitted source repository; -commands cannot select a different installation by rewriting a worktree remote. +By default, GitHub App routing is inherited from the operator-admitted source +repository; commands cannot select a different installation by rewriting a +worktree remote. On trusted VMs, the opt-in checkout routing mode above instead +uses the current worktree's local `origin` URL, within the admitted root. Legacy requests without a conversation identity continue to use the selected source root. Older Code API deployments do not negotiate the capability, so the worker omits it until every request path understands the isolation boundary. diff --git a/packages/code/src/cli.test.ts b/packages/code/src/cli.test.ts index f39b28d9..f95d7eee 100644 --- a/packages/code/src/cli.test.ts +++ b/packages/code/src/cli.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { generateKeyPairSync } from 'node:crypto'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -242,6 +242,8 @@ test('CLI accepts repository-routed GitHub App authentication without a fixed in t.after(() => rm(directory, { recursive: true, force: true })); const privateKeyPath = join(directory, 'app.pem'); const preload = join(directory, 'fetch.mjs'); + const workspace = join(directory, 'workspace'); + await mkdir(workspace); const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); await writeFile( privateKeyPath, @@ -251,13 +253,8 @@ test('CLI accepts repository-routed GitHub App authentication without a fixed in await writeFile( preload, ` - globalThis.fetch = async (input) => { - const url = String(input); - if (url.endsWith('/app')) return Response.json({ slug: 'lia-by-librechat' }); - if (url.endsWith('/users/lia-by-librechat%5Bbot%5D')) { - return Response.json({ id: 328778573, login: 'lia-by-librechat[bot]', type: 'Bot' }); - } - throw new Error('test stopped after GitHub App validation'); + globalThis.fetch = async () => { + throw new Error('test reached GitHub App validation'); }; `, ); @@ -276,7 +273,7 @@ test('CLI accepts repository-routed GitHub App authentication without a fixed in LIBRECHAT_CODE_URL: 'http://127.0.0.1:1/v1', LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret', LIBRECHAT_CODE_WORKER_ID: 'engineering-vm', - LIBRECHAT_CODE_WORKER_DIR: directory, + LIBRECHAT_CODE_WORKER_DIR: workspace, LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS: 'true', LIBRECHAT_CODE_GITHUB_TOKEN: undefined, LIBRECHAT_CODE_GITHUB_APP_ID: '123', @@ -288,6 +285,77 @@ test('CLI accepts repository-routed GitHub App authentication without a fixed in assert.notEqual(result.status, 0); assert.doesNotMatch(result.stderr, /GitHub App authentication requires/); assert.doesNotMatch(result.stderr, /installation ID/i); + assert.match(result.stderr, /test reached GitHub App validation/); + + const checkout = spawnSync( + process.execPath, + ['--import', preload, fileURLToPath(new URL('./cli.js', import.meta.url))], + { + encoding: 'utf8', + timeout: 10_000, + env: { + ...process.env, + LIBRECHAT_CODE_URL: 'http://127.0.0.1:1/v1', + LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret', + LIBRECHAT_CODE_WORKER_ID: 'engineering-vm', + LIBRECHAT_CODE_WORKER_DIR: workspace, + LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS: 'true', + LIBRECHAT_CODE_COMMAND_POLICY_PRESET: 'trusted-vm', + LIBRECHAT_CODE_GITHUB_TOKEN: undefined, + LIBRECHAT_CODE_GITHUB_APP_ID: '123', + LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: undefined, + LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE: privateKeyPath, + LIBRECHAT_CODE_GITHUB_REPOSITORY_ROUTING: 'checkout', + }, + }, + ); + assert.notEqual(checkout.status, 0); + assert.doesNotMatch( + checkout.stderr, + /Checkout GitHub repository routing requires/, + ); + assert.match(checkout.stderr, /test reached GitHub App validation/); +}); + +test('CLI rejects checkout routing outside a trusted VM or without repository-scoped App auth', () => { + const base = { + ...process.env, + LIBRECHAT_CODE_URL: 'http://127.0.0.1:1/v1', + LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret', + LIBRECHAT_CODE_WORKER_ID: 'engineering-vm', + LIBRECHAT_CODE_WORKER_DIR: process.cwd(), + LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS: 'true', + LIBRECHAT_CODE_GITHUB_APP_ID: '123', + LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE: '/does/not/matter', + LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: undefined, + LIBRECHAT_CODE_GITHUB_TOKEN: undefined, + LIBRECHAT_CODE_GITHUB_REPOSITORY_ROUTING: 'checkout', + }; + const cli = fileURLToPath(new URL('./cli.js', import.meta.url)); + const restricted = spawnSync(process.execPath, [cli], { + encoding: 'utf8', + env: base, + }); + assert.notEqual(restricted.status, 0); + assert.match(restricted.stderr, /requires the trusted-vm command policy/); + + const fixed = spawnSync(process.execPath, [cli], { + encoding: 'utf8', + env: { + ...base, + LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: '456', + LIBRECHAT_CODE_COMMAND_POLICY_PRESET: 'trusted-vm', + }, + }); + assert.notEqual(fixed.status, 0); + assert.match(fixed.stderr, /without a fixed installation ID/); + + const invalid = spawnSync(process.execPath, [cli], { + encoding: 'utf8', + env: { ...base, LIBRECHAT_CODE_GITHUB_REPOSITORY_ROUTING: 'unknown' }, + }); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stderr, /must be admitted or checkout/); }); test('CLI requires a runtime image for Docker supervision', () => { diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 26c60e9a..841164fb 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -49,7 +49,7 @@ import type { LocalWorkspaceConfig } from './workspace.js'; import { GITHUB_ALLOWED_DOMAINS, GitHubAppCredentialProvider, - gitHubRepositoryForAdmittedDirectory, + gitHubRepositoryForCommand, gitHubRepositoryForDirectory, gitHubCommandCredentialEnvironment, gitHubMaskedCredentialVariables, @@ -150,12 +150,13 @@ function nonEmpty(value: string | undefined): string | undefined { return value?.trim().length ? value : undefined; } -function githubCredentials(): { +function githubCredentials(args: string[]): { provider?: GitHubCredentialProvider; host: string; privateKeyPath?: string; mode?: 'app' | 'token'; repositoryRouting?: boolean; + checkoutRouting?: boolean; policyIdentity: string; } { const token = nonEmpty(process.env.LIBRECHAT_CODE_GITHUB_TOKEN); @@ -163,6 +164,13 @@ function githubCredentials(): { const installationId = nonEmpty( process.env.LIBRECHAT_CODE_GITHUB_INSTALLATION_ID, ); + const routing = + option(args, '--github-repository-routing')?.trim().toLowerCase() ?? + process.env.LIBRECHAT_CODE_GITHUB_REPOSITORY_ROUTING?.trim().toLowerCase() ?? + 'admitted'; + if (routing !== 'admitted' && routing !== 'checkout') { + throw new Error('GitHub repository routing must be admitted or checkout'); + } const privateKeyPath = nonEmpty( process.env.LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE, ); @@ -178,6 +186,11 @@ function githubCredentials(): { 'Configure either GitHub App authentication or a GitHub token, not both', ); } + if (routing === 'checkout' && (!hasApp || installationId)) { + throw new Error( + 'Checkout GitHub repository routing requires a GitHub App without a fixed installation ID', + ); + } const configuredHostValue = nonEmpty( process.env.LIBRECHAT_CODE_GITHUB_HOST, ); @@ -211,12 +224,13 @@ function githubCredentials(): { host, mode: 'app', repositoryRouting: !installationId, + checkoutRouting: routing === 'checkout', policyIdentity: gitHubAuthenticationPolicyIdentity({ mode: 'app', host, appId, installationId, - }), + }) + (routing === 'checkout' ? ':routing:checkout' : ''), privateKeyPath, provider: new GitHubAppCredentialProvider({ appId: appId!, @@ -530,9 +544,11 @@ async function run( } const github = runtimeSessionId == null - ? githubCredentials() + ? githubCredentials(args) : { host: 'github.com', + repositoryRouting: false, + checkoutRouting: false, policyIdentity: gitHubAuthenticationPolicyIdentity({ host: 'github.com', }), @@ -547,6 +563,11 @@ async function run( 'GitHub authentication currently requires the native-srt command sandbox', ); } + if (github.checkoutRouting && commandPolicy.preset !== 'trusted-vm') { + throw new Error( + 'Checkout GitHub repository routing requires the trusted-vm command policy', + ); + } const githubDomains = github.provider ? github.host === 'github.com' ? [...GITHUB_ALLOWED_DOMAINS] @@ -759,9 +780,8 @@ async function run( }), ]), ); - // Bind credentials to immutable, explicitly admitted roots. The repository - // remote is operator input at startup, never an authorization input that a - // sandboxed command may change for its next invocation. + // Keep an admission boundary even when trusted-VM checkout routing uses a + // nested repository's remote for the current command. const admittedGitHubRepositories = github.provider && github.repositoryRouting ? new Map( await Promise.all( @@ -998,9 +1018,12 @@ async function run( ), async resolve(signal?: AbortSignal, cwd?: string) { const repository = cwd && admittedGitHubRepositories - ? gitHubRepositoryForAdmittedDirectory( + ? await gitHubRepositoryForCommand( cwd, admittedGitHubRepositories, + github.checkoutRouting ? 'checkout' : 'admitted', + github.host, + signal, ) : undefined; if (!repository && github.repositoryRouting) { diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index 352bf1c3..b13d70f7 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -4,6 +4,7 @@ import { chmod, mkdtemp, mkdir, + realpath, rm, symlink, writeFile, @@ -26,6 +27,7 @@ import { GITHUB_CREDENTIAL_ENV_NAME, gitHubCredentialEnvironment, gitHubRepositoryForAdmittedDirectory, + gitHubRepositoryForCommand, gitHubRepositoryForDirectory, normalizeGitHubHost, wrapGitHubCredentialCommand, @@ -459,6 +461,83 @@ test('keeps repository authorization bound to the admitted workspace root', asyn ); }); +test('checkout routing follows nested repositories only inside an admitted root', async (t) => { + const directory = await realpath( + await mkdtemp(join(tmpdir(), 'librechat-code-github-checkout-')), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const nested = join(directory, 'worktrees', 'other'); + await mkdir(nested, { recursive: true }); + execFileSync('git', ['init', directory]); + execFileSync('git', [ + '-C', directory, 'remote', 'add', 'origin', 'git@github.com:acme/outer.git', + ]); + execFileSync('git', ['init', nested]); + execFileSync('git', [ + '-C', nested, 'remote', 'add', 'origin', 'git@github.com:acme/inner.git', + ]); + const admitted = new Map([[directory, 'acme/outer']]); + + assert.equal( + await gitHubRepositoryForCommand(nested, admitted, 'admitted'), + 'acme/outer', + ); + assert.equal( + await gitHubRepositoryForCommand(nested, admitted, 'checkout'), + 'acme/inner', + ); + execFileSync('git', [ + '-C', nested, 'remote', 'set-url', 'origin', 'git@github.com:acme/changed.git', + ]); + assert.equal( + await gitHubRepositoryForCommand(nested, admitted, 'checkout'), + 'acme/changed', + ); + assert.equal( + await gitHubRepositoryForCommand(dirname(directory), admitted, 'checkout'), + undefined, + ); + const outside = await realpath( + await mkdtemp(join(tmpdir(), 'librechat-code-github-outside-')), + ); + t.after(() => rm(outside, { recursive: true, force: true })); + execFileSync('git', ['init', outside]); + execFileSync('git', [ + '-C', outside, 'remote', 'add', 'origin', 'git@github.com:acme/outside.git', + ]); + const escaped = join(directory, 'worktrees', 'escaped'); + await symlink(outside, escaped); + assert.equal( + await gitHubRepositoryForCommand(escaped, admitted, 'checkout'), + undefined, + ); + admitted.set(outside, 'acme/outside'); + assert.equal( + await gitHubRepositoryForCommand(escaped, admitted, 'checkout'), + undefined, + ); + assert.equal( + await gitHubRepositoryForCommand(nested, admitted, 'checkout', 'github.example.test'), + undefined, + ); + + const linked = join(directory, 'worktrees', 'linked'); + execFileSync('git', [ + '-C', nested, '-c', 'user.name=Test', '-c', 'user.email=test@example.com', + 'commit', '--allow-empty', '-m', 'initial', + ]); + execFileSync('git', ['-C', nested, 'worktree', 'add', '--detach', linked]); + assert.equal( + await gitHubRepositoryForCommand(linked, admitted, 'checkout'), + 'acme/changed', + ); + admitted.set(linked, 'acme/linked'); + assert.equal( + await gitHubRepositoryForCommand(linked, admitted, 'admitted'), + 'acme/linked', + ); +}); + test('uses the configured GHES host for the App bot no-reply identity', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'librechat-code-ghes-identity-')); t.after(() => rm(directory, { recursive: true, force: true })); diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index fb6b6fdc..19b8e639 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -1,7 +1,7 @@ import { constants } from 'node:fs'; import { execFile } from 'node:child_process'; import { createHash, createPrivateKey, sign } from 'node:crypto'; -import { open } from 'node:fs/promises'; +import { open, realpath } from 'node:fs/promises'; import { dirname, isAbsolute, relative, sep } from 'node:path'; import { promisify } from 'node:util'; import { projectRemote } from './projects.js'; @@ -137,21 +137,54 @@ export async function gitHubRepositoryForDirectory( return repository; } -/** Return the startup-bound repository for the admitted root containing cwd. */ -export function gitHubRepositoryForAdmittedDirectory( +function admittedRepositoryEntry( cwd: string, repositories: ReadonlyMap, -): string | undefined { - for (const [root, repository] of repositories) { +): readonly [string, string | undefined] | undefined { + let closest: readonly [string, string | undefined] | undefined; + for (const entry of repositories) { + const [root] = entry; const path = relative(root, cwd); if ( path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)) ) { - return repository; + if (!closest || root.length > closest[0].length) closest = entry; } } - return undefined; + return closest; +} + +/** Return the startup-bound repository for the admitted root containing cwd. */ +export function gitHubRepositoryForAdmittedDirectory( + cwd: string, + repositories: ReadonlyMap, +): string | undefined { + return admittedRepositoryEntry(cwd, repositories)?.[1]; +} + +/** Resolve a checkout repository only when its cwd remains inside an admitted root. */ +export async function gitHubRepositoryForCommand( + cwd: string, + repositories: ReadonlyMap, + routing: 'admitted' | 'checkout', + host = 'github.com', + signal?: AbortSignal, +): Promise { + const admitted = admittedRepositoryEntry(cwd, repositories); + if (!admitted) return undefined; + if (routing === 'admitted') return admitted[1]; + let canonicalCwd: string; + try { + canonicalCwd = await realpath(cwd); + } catch { + signal?.throwIfAborted(); + return undefined; + } + if (admittedRepositoryEntry(canonicalCwd, repositories)?.[0] !== admitted[0]) { + return undefined; + } + return gitHubRepositoryForDirectory(canonicalCwd, host, signal); } function base64UrlJson(value: unknown): string {