diff --git a/docs/remote-bridge/worker-runbook.md b/docs/remote-bridge/worker-runbook.md index 7511a265..16c24742 100644 --- a/docs/remote-bridge/worker-runbook.md +++ b/docs/remote-bridge/worker-runbook.md @@ -323,13 +323,21 @@ Configure the worker, preferably in a separate service drop-in: ```ini [Service] Environment=LIBRECHAT_CODE_GITHUB_APP_ID=12345 -Environment=LIBRECHAT_CODE_GITHUB_INSTALLATION_ID=67890 Environment=LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE=/home/librechat-code/.config/librechat-code/github-app.pem ``` -The trusted worker mints short-lived installation tokens. 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 configuration. +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. + +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 +configuration. Git commits receive the App bot's canonical no-reply identity so +GitHub renders the bot profile and avatar. ## 10. Run under systemd @@ -518,7 +526,8 @@ command, cancellation, or settlement whose effects may be incomplete. - [ ] Pairing is principal-bound and the identity file is private. - [ ] Definitions are outside roots and immutable to sandboxed tools. - [ ] Workspace ancestors are not group/other writable. -- [ ] GitHub App is optional, least-privilege, and installed only where needed. +- [ ] GitHub App is optional, least-privilege, and installed on every account + the worker is expected to use. - [ ] Approval policy remains enforced independently of worker capability. - [ ] Service manager uses the intended executable and configuration. - [ ] Worker is online, ready, and advertises the expected workspace. diff --git a/packages/code/README.md b/packages/code/README.md index 08dc54a7..69d85b16 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -283,14 +283,24 @@ repositories the agent may access: ```bash LIBRECHAT_CODE_GITHUB_APP_ID=12345 \ -LIBRECHAT_CODE_GITHUB_INSTALLATION_ID=67890 \ LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE=/secure/librechat-agent.pem \ librechat-code run --worker-dir /path/to/project --allow-workspace-commands ``` The private key must be an owner-only regular file outside the workspace. It is read only by the trusted worker, which mints and refreshes short-lived -installation tokens. A personal access token is supported as a fallback with +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 +`LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` fallback. + +App-authenticated commits use the GitHub App bot's canonical no-reply identity, +so GitHub links them to the bot profile and avatar. A personal access token is +supported as a fallback with `LIBRECHAT_CODE_GITHUB_TOKEN`, but the GitHub App is the safer default because its repository access and permissions can be narrowly installed and revoked. Native Windows credential storage is unavailable until native DACL removal and diff --git a/packages/code/src/cli.test.ts b/packages/code/src/cli.test.ts index 18456072..f39b28d9 100644 --- a/packages/code/src/cli.test.ts +++ b/packages/code/src/cli.test.ts @@ -1,5 +1,9 @@ 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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; @@ -233,6 +237,59 @@ test('CLI validates GitHub App credentials before worker registration', () => { assert.doesNotMatch(result.stderr, /fetch failed/); }); +test('CLI accepts repository-routed GitHub App authentication without a fixed installation', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'cli-github-routing-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const privateKeyPath = join(directory, 'app.pem'); + const preload = join(directory, 'fetch.mjs'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await writeFile( + privateKeyPath, + privateKey.export({ type: 'pkcs8', format: 'pem' }), + { mode: 0o600 }, + ); + 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'); + }; + `, + ); + const result = 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: directory, + LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS: 'true', + LIBRECHAT_CODE_GITHUB_TOKEN: undefined, + LIBRECHAT_CODE_GITHUB_APP_ID: '123', + LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: undefined, + LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE: privateKeyPath, + }, + }, + ); + assert.notEqual(result.status, 0); + assert.doesNotMatch(result.stderr, /GitHub App authentication requires/); + assert.doesNotMatch(result.stderr, /installation ID/i); +}); + test('CLI requires a runtime image for Docker supervision', () => { const result = spawnSync( process.execPath, @@ -449,6 +506,6 @@ test('CLI host-only enterprise configuration sends App JWTs to GHES, never GitHu }, }); assert.equal(result.status, 1, result.stderr); - assert.match(result.stderr, /GITHUB_REQUEST:https:\/\/github\.example\.test\/api\/v3\/app\/installations\/456\/access_tokens/); + assert.match(result.stderr, /GITHUB_REQUEST:https:\/\/github\.example\.test\/api\/v3\/app/); assert.doesNotMatch(result.stderr, /GITHUB_REQUEST:https:\/\/api\.github\.com/); }); diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 524c5853..96757d57 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -46,6 +46,8 @@ import type { LocalWorkspaceConfig } from './workspace.js'; import { GITHUB_ALLOWED_DOMAINS, GitHubAppCredentialProvider, + gitHubRepositoryForAdmittedDirectory, + gitHubRepositoryForDirectory, gitHubCommandCredentialEnvironment, gitHubMaskedCredentialVariables, StaticGitHubCredentialProvider, @@ -149,6 +151,7 @@ function githubCredentials(): { host: string; privateKeyPath?: string; mode?: 'app' | 'token'; + repositoryRouting?: boolean; policyIdentity: string; } { const token = nonEmpty(process.env.LIBRECHAT_CODE_GITHUB_TOKEN); @@ -161,9 +164,9 @@ function githubCredentials(): { ); const appValues = [appId, installationId, privateKeyPath]; const hasApp = appValues.some(Boolean); - if (hasApp && !appValues.every(Boolean)) { + if (hasApp && (!appId || !privateKeyPath)) { throw new Error( - 'GitHub App authentication requires LIBRECHAT_CODE_GITHUB_APP_ID, LIBRECHAT_CODE_GITHUB_INSTALLATION_ID, and LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE', + 'GitHub App authentication requires LIBRECHAT_CODE_GITHUB_APP_ID and LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE; LIBRECHAT_CODE_GITHUB_INSTALLATION_ID is an optional legacy fallback', ); } if (hasApp && token) { @@ -203,6 +206,7 @@ function githubCredentials(): { return { host, mode: 'app', + repositoryRouting: !installationId, policyIdentity: gitHubAuthenticationPolicyIdentity({ mode: 'app', host, @@ -212,7 +216,7 @@ function githubCredentials(): { privateKeyPath, provider: new GitHubAppCredentialProvider({ appId: appId!, - installationId: installationId!, + installationId, privateKeyPath: privateKeyPath!, host, apiUrl, @@ -715,6 +719,19 @@ 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. + const admittedGitHubRepositories = github.provider && github.repositoryRouting + ? new Map( + await Promise.all( + roots.map(async root => [ + root.root, + await gitHubRepositoryForDirectory(root.root, github.host), + ] as const), + ), + ) + : undefined; const localWorkspaceTools = workerDirectory ? await LocalWorkspaceTools.create({ workspaces: roots, @@ -928,18 +945,34 @@ async function run( ...(github.provider ? { maskedEnvironment: { - variables: gitHubMaskedCredentialVariables(github.host), - async resolve(signal?: AbortSignal) { + variables: gitHubMaskedCredentialVariables( + github.host, + ), + async resolve(signal?: AbortSignal, cwd?: string) { + const repository = cwd && admittedGitHubRepositories + ? gitHubRepositoryForAdmittedDirectory( + cwd, + admittedGitHubRepositories, + ) + : undefined; + if (!repository && github.repositoryRouting) { + return {}; + } return gitHubCommandCredentialEnvironment( - await github.provider!.getCredential(signal), + await github.provider!.getCredential(signal, repository), github.host, ); }, - wrapCommand(command: string, platform: NodeJS.Platform) { + wrapCommand( + command: string, + platform: NodeJS.Platform, + environment: Readonly>, + ) { return wrapGitHubCredentialCommand( command, github.host, platform, + environment, ); }, }, @@ -1022,7 +1055,10 @@ async function run( ); } try { - await github.provider?.getCredential(controller.signal); + await github.provider?.validate?.(controller.signal); + if (github.provider && !github.provider.validate) { + await github.provider.getCredential(controller.signal); + } await nativeCommandSandbox?.prepare(); for (const environment of option(args, '--reset-workspace-quarantine') == null ? environments : []) { const setup = environment.definition.setup; diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index 4b028239..352bf1c3 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -1,4 +1,5 @@ import { generateKeyPairSync } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; import { chmod, mkdtemp, @@ -8,12 +9,14 @@ import { writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; import { GITHUB_ALLOWED_DOMAINS, + GITHUB_AUTHOR_EMAIL_ENV_NAME, + GITHUB_AUTHOR_NAME_ENV_NAME, GitHubAppCredentialProvider, StaticGitHubCredentialProvider, gitHubAuthenticationPolicyIdentity, @@ -22,6 +25,8 @@ import { gitHubMaskedCredentialVariables, GITHUB_CREDENTIAL_ENV_NAME, gitHubCredentialEnvironment, + gitHubRepositoryForAdmittedDirectory, + gitHubRepositoryForDirectory, normalizeGitHubHost, wrapGitHubCredentialCommand, } from './github.js'; @@ -99,23 +104,30 @@ test('mints and caches a short-lived GitHub App installation token', async (t) = { mode: 0o600 }, ); await chmod(privateKeyPath, 0o600); - let calls = 0; + const calls: Array<{ url: string; authorization: string | null }> = []; const request = async ( - _input: string | URL | Request, + input: string | URL | Request, init?: RequestInit, ) => { - calls += 1; - assert.match( - String(new Headers(init?.headers).get('authorization')), - /^Bearer eyJ/, - ); - return new Response( - JSON.stringify({ + const url = String(input); + const authorization = new Headers(init?.headers).get('authorization'); + calls.push({ url, authorization }); + if (url.endsWith('/app')) { + assert.match(String(authorization), /^Bearer eyJ/); + return Response.json({ slug: 'lia' }); + } + if (url.endsWith('/app/installations/456/access_tokens')) { + assert.match(String(authorization), /^Bearer eyJ/); + return Response.json({ token: 'ghs_abcdefghijklmnopqrstuvwxyz', expires_at: '2030-01-01T01:00:00Z', - }), - { status: 201 }, - ); + }, { status: 201 }); + } + if (url.endsWith('/users/lia%5Bbot%5D')) { + assert.equal(authorization, 'Bearer ghs_abcdefghijklmnopqrstuvwxyz'); + return Response.json({ id: 1234, login: 'lia[bot]', type: 'Bot' }); + } + return Response.json({}, { status: 404 }); }; const provider = new GitHubAppCredentialProvider({ appId: '123', @@ -133,7 +145,367 @@ test('mints and caches a short-lived GitHub App installation token', async (t) = (await provider.getCredential()).value, 'ghs_abcdefghijklmnopqrstuvwxyz', ); - assert.equal(calls, 1); + assert.equal(calls.filter(call => call.url.endsWith('/app')).length, 1); + assert.equal( + calls.filter(call => call.url.endsWith('/access_tokens')).length, + 1, + ); + assert.equal( + calls.filter(call => call.url.endsWith('/users/lia%5Bbot%5D')).length, + 1, + ); +}); + +test('routes and scopes GitHub App tokens per repository installation', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-routing-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const privateKeyPath = join(directory, 'app.pem'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await writeFile( + privateKeyPath, + privateKey.export({ type: 'pkcs8', format: 'pem' }), + { mode: 0o600 }, + ); + const calls: Array<{ url: string; body?: string }> = []; + const provider = new GitHubAppCredentialProvider({ + appId: '123', + privateKeyPath, + now: () => new Date('2030-01-01T00:00:00Z'), + fetch: (async (input, init) => { + const url = String(input); + calls.push({ url, body: typeof init?.body === 'string' ? init.body : undefined }); + 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', + }); + } + if (url.endsWith('/repos/danny-avila/LibreChat/installation')) { + return Response.json({ id: 111 }); + } + if (url.endsWith('/repos/LibreChat-AI/code-interpreter/installation')) { + return Response.json({ id: 222 }); + } + const installation = /\/app\/installations\/(\d+)\/access_tokens$/.exec(url)?.[1]; + if (installation) { + return Response.json( + { + token: `ghs_${installation}_abcdefghijklmnopqrstuvwxyz`, + expires_at: '2030-01-01T01:00:00Z', + }, + { status: 201 }, + ); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + + await provider.validate(); + const [personal, organization] = await Promise.all([ + provider.getCredential(undefined, 'danny-avila/LibreChat'), + provider.getCredential(undefined, 'LibreChat-AI/code-interpreter'), + ]); + assert.equal( + (await provider.getCredential(undefined, 'danny-avila/LibreChat')).value, + personal.value, + ); + assert.equal(personal.value, 'ghs_111_abcdefghijklmnopqrstuvwxyz'); + assert.equal(organization.value, 'ghs_222_abcdefghijklmnopqrstuvwxyz'); + assert.deepEqual(personal.actor, { + name: 'lia-by-librechat[bot]', + email: + '328778573+lia-by-librechat[bot]@users.noreply.github.com', + }); + assert.equal( + calls.filter(call => call.url.includes('/repos/danny-avila/')).length, + 1, + ); + assert.deepEqual( + calls + .filter(call => call.url.endsWith('/access_tokens')) + .map(call => JSON.parse(call.body ?? '{}')) + .map(body => body.repositories[0]) + .sort(), + [ + 'LibreChat', + 'code-interpreter', + ], + ); +}); + +test('keeps a shared token refresh alive when one waiter is cancelled', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-cancel-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const privateKeyPath = join(directory, 'app.pem'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await writeFile( + privateKeyPath, + privateKey.export({ type: 'pkcs8', format: 'pem' }), + { mode: 0o600 }, + ); + let releaseToken!: (response: Response) => void; + const tokenResponse = new Promise(resolve => { + releaseToken = resolve; + }); + let mintCount = 0; + const provider = new GitHubAppCredentialProvider({ + appId: '123', + privateKeyPath, + now: () => new Date('2030-01-01T00:00:00Z'), + fetch: (async (input) => { + const url = String(input); + if (url.endsWith('/app')) return Response.json({ slug: 'lia' }); + if (url.endsWith('/users/lia%5Bbot%5D')) { + return Response.json({ id: 1234, login: 'lia[bot]', type: 'Bot' }); + } + if (url.endsWith('/repos/acme/project/installation')) { + return Response.json({ id: 111 }); + } + if (url.endsWith('/app/installations/111/access_tokens')) { + mintCount += 1; + return tokenResponse; + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + await provider.validate(); + const firstController = new AbortController(); + const first = provider.getCredential( + firstController.signal, + 'acme/project', + ); + const second = provider.getCredential(undefined, 'acme/project'); + firstController.abort(new Error('first command cancelled')); + await assert.rejects(first, /first command cancelled/); + const third = provider.getCredential(undefined, 'acme/project'); + releaseToken( + Response.json({ + token: 'ghs_shared_abcdefghijklmnopqrstuvwxyz', + expires_at: '2030-01-01T01:00:00Z', + }), + ); + assert.equal( + (await second).value, + 'ghs_shared_abcdefghijklmnopqrstuvwxyz', + ); + assert.equal((await third).value, 'ghs_shared_abcdefghijklmnopqrstuvwxyz'); + assert.equal(mintCount, 1); +}); + +test('refreshes a cached repository installation after App reinstallation', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-reinstall-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const privateKeyPath = join(directory, 'app.pem'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await writeFile( + privateKeyPath, + privateKey.export({ type: 'pkcs8', format: 'pem' }), + { mode: 0o600 }, + ); + let now = new Date('2030-01-01T00:00:00Z'); + let lookupCount = 0; + let oldMintCount = 0; + const provider = new GitHubAppCredentialProvider({ + appId: '123', + privateKeyPath, + now: () => now, + fetch: (async (input) => { + const url = String(input); + if (url.endsWith('/app')) return Response.json({ slug: 'lia' }); + if (url.endsWith('/users/lia%5Bbot%5D')) { + return Response.json({ id: 1234, login: 'lia[bot]', type: 'Bot' }); + } + if (url.endsWith('/repos/acme/project/installation')) { + lookupCount += 1; + return Response.json({ id: lookupCount === 1 ? 111 : 222 }); + } + if (url.endsWith('/app/installations/111/access_tokens')) { + oldMintCount += 1; + return oldMintCount === 1 + ? Response.json({ + token: 'ghs_old_abcdefghijklmnopqrstuvwxyz', + expires_at: '2030-01-01T01:00:00Z', + }) + : Response.json({}, { status: 404 }); + } + if (url.endsWith('/app/installations/222/access_tokens')) { + return Response.json({ + token: 'ghs_new_abcdefghijklmnopqrstuvwxyz', + expires_at: '2030-01-01T02:00:00Z', + }); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + await provider.validate(); + assert.equal( + (await provider.getCredential(undefined, 'acme/project')).value, + 'ghs_old_abcdefghijklmnopqrstuvwxyz', + ); + now = new Date('2030-01-01T00:56:00Z'); + assert.equal( + (await provider.getCredential(undefined, 'acme/project')).value, + 'ghs_new_abcdefghijklmnopqrstuvwxyz', + ); + assert.equal(lookupCount, 2); +}); + +test('validates a configured fixed installation by minting its token', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-fixed-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const privateKeyPath = join(directory, 'app.pem'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await writeFile( + privateKeyPath, + privateKey.export({ type: 'pkcs8', format: 'pem' }), + { mode: 0o600 }, + ); + let minted = false; + const provider = new GitHubAppCredentialProvider({ + appId: '123', + installationId: '456', + privateKeyPath, + now: () => new Date('2030-01-01T00:00:00Z'), + fetch: (async (input) => { + const url = String(input); + if (url.endsWith('/app')) return Response.json({ slug: 'lia' }); + if (url.endsWith('/users/lia%5Bbot%5D')) { + return Response.json({ id: 1234, login: 'lia[bot]', type: 'Bot' }); + } + if (url.endsWith('/app/installations/456/access_tokens')) { + minted = true; + return Response.json({ + token: 'ghs_fixed_abcdefghijklmnopqrstuvwxyz', + expires_at: '2030-01-01T01:00:00Z', + }); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + await provider.validate(); + assert.equal(minted, true); +}); + +test('discovers the GitHub repository from a command working directory', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-repo-')); + t.after(() => rm(directory, { recursive: true, force: true })); + execFileSync('git', ['init', directory]); + execFileSync('git', [ + '-C', + directory, + 'remote', + 'add', + 'origin', + 'git@github.com:LibreChat-AI/code-interpreter.git', + ]); + assert.equal( + await gitHubRepositoryForDirectory(directory), + 'LibreChat-AI/code-interpreter', + ); + assert.equal( + await gitHubRepositoryForDirectory(directory, 'github.example.test'), + undefined, + ); + execFileSync('git', [ + '-C', + directory, + 'remote', + 'set-url', + 'origin', + 'https://github.example.test:8443/acme/project.git', + ]); + assert.equal( + await gitHubRepositoryForDirectory(directory, 'github.example.test'), + 'acme/project', + ); +}); + +test('keeps repository authorization bound to the admitted workspace root', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-binding-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const nested = join(directory, 'packages', 'app'); + await mkdir(nested, { recursive: true }); + execFileSync('git', ['init', directory]); + execFileSync('git', [ + '-C', + directory, + 'remote', + 'add', + 'origin', + 'git@github.com:acme/allowed.git', + ]); + const admitted = new Map([ + [directory, await gitHubRepositoryForDirectory(directory)], + ]); + execFileSync('git', [ + '-C', + directory, + 'remote', + 'set-url', + 'origin', + 'git@github.com:acme/not-authorized.git', + ]); + assert.equal( + gitHubRepositoryForAdmittedDirectory(nested, admitted), + 'acme/allowed', + ); + assert.equal( + gitHubRepositoryForAdmittedDirectory(dirname(directory), admitted), + undefined, + ); +}); + +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 })); + const privateKeyPath = join(directory, 'app.pem'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await writeFile( + privateKeyPath, + privateKey.export({ type: 'pkcs8', format: 'pem' }), + { mode: 0o600 }, + ); + const provider = new GitHubAppCredentialProvider({ + appId: '123', + installationId: '456', + privateKeyPath, + host: 'github.example.test', + now: () => new Date('2030-01-01T00:00:00Z'), + fetch: (async (input) => { + const url = String(input); + if (url.endsWith('/app')) return Response.json({ slug: 'lia' }); + if (url.endsWith('/users/lia%5Bbot%5D')) { + return Response.json({ id: 1234, login: 'lia[bot]', type: 'Bot' }); + } + if (url.endsWith('/app/installations/456/access_tokens')) { + return Response.json({ + token: 'ghs_enterprise_abcdefghijklmnopqrstuvwxyz', + expires_at: '2030-01-01T01:00:00Z', + }); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + await provider.validate(); + const credential = await provider.getCredential(); + assert.deepEqual(credential.actor, { + name: 'lia[bot]', + email: '1234+lia[bot]@users.noreply.github.example.test', + }); + const wrapped = wrapGitHubCredentialCommand( + 'git commit -m test', + 'github.example.test', + 'linux', + gitHubCommandCredentialEnvironment(credential, 'github.example.test'), + ); + assert.match( + wrapped, + /user\.email=1234\+lia\[bot\]@users\.noreply\.github\.example\.test/, + ); }); test('builds process-scoped Git HTTPS authorization without embedding credentials in URLs', async () => { @@ -177,6 +549,76 @@ test('adds a GitHub CLI token only to the command-sandbox credential bundle', as ); }); +test('binds Git commits to the GitHub App bot identity', () => { + const environment = gitHubCommandCredentialEnvironment({ + value: 'ghs_abcdefghijklmnopqrstuvwxyz', + actor: { + name: 'lia-by-librechat[bot]', + email: + '328778573+lia-by-librechat[bot]@users.noreply.github.com', + }, + }); + assert.equal(environment[GITHUB_AUTHOR_NAME_ENV_NAME], 'lia-by-librechat[bot]'); + assert.equal( + environment[GITHUB_AUTHOR_EMAIL_ENV_NAME], + '328778573+lia-by-librechat[bot]@users.noreply.github.com', + ); + const variables = gitHubMaskedCredentialVariables('github.com'); + assert.ok(!variables.some(variable => variable.name === GITHUB_AUTHOR_NAME_ENV_NAME)); + assert.ok(!variables.some(variable => variable.name === GITHUB_AUTHOR_EMAIL_ENV_NAME)); + const wrapped = wrapGitHubCredentialCommand( + 'git commit -m test', + 'github.com', + 'linux', + environment, + ); + assert.match(wrapped, /user\.name=/); + assert.match(wrapped, /user\.email=/); +}); + +test('records the canonical App bot as Git author and committer', async (t) => { + if (process.platform === 'win32') { + t.skip('POSIX command wrapper integration is unavailable on Windows'); + return; + } + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-author-')); + t.after(() => rm(directory, { recursive: true, force: true })); + execFileSync('git', ['init', directory]); + const environment = gitHubCommandCredentialEnvironment({ + value: 'ghs_abcdefghijklmnopqrstuvwxyz', + actor: { + name: 'lia-by-librechat[bot]', + email: + '328778573+lia-by-librechat[bot]@users.noreply.github.com', + }, + }); + const wrapped = wrapGitHubCredentialCommand( + 'git commit --allow-empty -m test', + 'github.com', + process.platform, + environment, + ); + execFileSync('/bin/bash', ['-lc', wrapped], { + cwd: directory, + env: { PATH: process.env.PATH, ...environment }, + }); + assert.equal( + execFileSync( + 'git', + [ + '-C', + directory, + 'show', + '-s', + '--format=%an|%ae|%cn|%ce', + 'HEAD', + ], + { encoding: 'utf8' }, + ).trim(), + 'lia-by-librechat[bot]|328778573+lia-by-librechat[bot]@users.noreply.github.com|lia-by-librechat[bot]|328778573+lia-by-librechat[bot]@users.noreply.github.com', + ); +}); + test('selects the GitHub CLI token variable for public and enterprise hosts', () => { assert.equal(gitHubCliTokenEnvironmentName('github.com'), 'GH_TOKEN'); assert.equal( @@ -217,6 +659,9 @@ test('composes the masked credential with SRT Git configuration inside the sandb 'git push', 'github.com', 'darwin', + { + [GITHUB_CREDENTIAL_ENV_NAME]: 'masked-authorization', + }, ); assert.match(wrapped, /http\.proxyAuthMethod=basic/); assert.match(wrapped, /http\.https:\/\/github\.com\/\.extraheader/); @@ -227,6 +672,16 @@ test('composes the masked credential with SRT Git configuration inside the sandb assert.ok(!wrapped.includes('github_pat_')); }); +test('omits Git authorization when repository routing resolves no credential', () => { + const wrapped = wrapGitHubCredentialCommand( + 'git clone https://github.com/LibreChat-AI/LibreChat.git', + 'github.com', + 'linux', + {}, + ); + assert.doesNotMatch(wrapped, /Authorization: Basic/); +}); + test('targets GitHub CLI at an enterprise host without exposing its token', () => { const wrapped = wrapGitHubCredentialCommand( 'gh pr create', @@ -327,16 +782,26 @@ test('App JWT requests use the resolved public or enterprise endpoint', async (t now: () => new Date('2030-01-01T00:00:00Z'), fetch: async (input, init) => { calls++; - assert.equal(String(input), `${expected}/app/installations/456/access_tokens`); - assert.equal(init?.method, 'POST'); + const url = String(input); assert.equal(init?.redirect, 'error'); - assert.match(new Headers(init?.headers).get('authorization')!, /^Bearer eyJ/); - return new Response(JSON.stringify({ token: 'ghs_abcdefghijklmnopqrstuvwxyz', expires_at: '2030-01-01T01:00:00Z' }), { status: 201 }); + const authorization = new Headers(init?.headers).get('authorization'); + if (url === `${expected}/app`) { + assert.match(authorization!, /^Bearer eyJ/); + return Response.json({ slug: 'lia' }); + } + if (url === `${expected}/app/installations/456/access_tokens`) { + assert.equal(init?.method, 'POST'); + assert.match(authorization!, /^Bearer eyJ/); + return new Response(JSON.stringify({ token: 'ghs_abcdefghijklmnopqrstuvwxyz', expires_at: '2030-01-01T01:00:00Z' }), { status: 201 }); + } + assert.equal(url, `${expected}/users/lia%5Bbot%5D`); + assert.equal(authorization, 'Bearer ghs_abcdefghijklmnopqrstuvwxyz'); + return Response.json({ id: 1234, login: 'lia[bot]', type: 'Bot' }); }, }); await provider.getCredential(); await provider.getCredential(); - assert.equal(calls, 1); + assert.equal(calls, 3); } }); diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index c727e70d..fb6b6fdc 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -1,10 +1,15 @@ 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 { dirname } from 'node:path'; +import { dirname, isAbsolute, relative, sep } from 'node:path'; +import { promisify } from 'node:util'; +import { projectRemote } from './projects.js'; import { assertPrivateStorageAcl, assertPrivateStorageAncestors, assertPrivateStorageSupported } from './private-storage.js'; export const GITHUB_CREDENTIAL_ENV_NAME = 'LIBRECHAT_CODE_GITHUB_AUTHORIZATION'; +export const GITHUB_AUTHOR_NAME_ENV_NAME = 'LIBRECHAT_CODE_GITHUB_AUTHOR_NAME'; +export const GITHUB_AUTHOR_EMAIL_ENV_NAME = 'LIBRECHAT_CODE_GITHUB_AUTHOR_EMAIL'; export const GITHUB_ALLOWED_DOMAINS = [ 'github.com', '*.github.com', @@ -18,15 +23,24 @@ export const GITHUB_ALLOWED_DOMAINS = [ export interface GitHubCredential { value: string; expiresAt?: Date; + actor?: { + name: string; + email: string; + }; } export interface GitHubCredentialProvider { - getCredential(signal?: AbortSignal): Promise; + getCredential( + signal?: AbortSignal, + repository?: string, + ): Promise; + validate?(signal?: AbortSignal): Promise; } export interface GitHubAppCredentialProviderOptions { appId: string; - installationId: string; + /** Legacy fixed installation. Omit to resolve the installation per repository. */ + installationId?: string; privateKeyPath: string; apiUrl?: string; /** Git HTTPS hostname; non-public hosts default to the GHES /api/v3 base. */ @@ -36,6 +50,110 @@ export interface GitHubAppCredentialProviderOptions { platform?: NodeJS.Platform; } +const execFileAsync = promisify(execFile); +const GITHUB_SHARED_REQUEST_TIMEOUT_MS = 30_000; + +async function waitForShared( + promise: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const aborted = () => { + try { + signal.throwIfAborted(); + } catch (error) { + reject(error); + } + }; + signal.addEventListener('abort', aborted, { once: true }); + void promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', aborted); + }); + }); +} + +function repositoryName(value: string): { owner: string; name: string } { + const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(value); + if (!match) throw new Error('GitHub repository must be owner/name'); + return { owner: match[1], name: match[2] }; +} + +/** Resolve only the repository containing the admitted command cwd. */ +export async function gitHubRepositoryForDirectory( + cwd: string, + host = 'github.com', + signal?: AbortSignal, +): Promise { + let remote: string; + try { + const result = await execFileAsync( + 'git', + [ + '--no-optional-locks', + '-C', + cwd, + '-c', + 'core.fsmonitor=false', + 'config', + '--local', + '--no-includes', + '--get', + 'remote.origin.url', + ], + { + env: { + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + GIT_OPTIONAL_LOCKS: '0', + LC_ALL: 'C', + }, + encoding: 'utf8', + maxBuffer: 4096, + timeout: 1500, + signal, + }, + ); + remote = result.stdout.trim(); + } catch { + signal?.throwIfAborted(); + return undefined; + } + const normalized = projectRemote(remote); + if (!normalized) return undefined; + const separator = normalized.indexOf('/'); + const remoteHost = normalized + .slice(0, separator) + .replace(/:[1-9][0-9]*$/, ''); + if (remoteHost !== normalizeGitHubHost(host)) { + return undefined; + } + const repository = normalized.slice(separator + 1); + repositoryName(repository); + return repository; +} + +/** Return the startup-bound repository for the admitted root containing cwd. */ +export function gitHubRepositoryForAdmittedDirectory( + cwd: string, + repositories: ReadonlyMap, +): string | undefined { + for (const [root, repository] of repositories) { + const path = relative(root, cwd); + if ( + path === '' || + (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)) + ) { + return repository; + } + } + return undefined; +} + function base64UrlJson(value: unknown): string { return Buffer.from(JSON.stringify(value)).toString('base64url'); } @@ -96,8 +214,15 @@ function createAppJwt(appId: string, privateKey: string, now: Date): string { } export class GitHubAppCredentialProvider implements GitHubCredentialProvider { - private cached?: GitHubCredential; + private readonly cached = new Map(); + private readonly inFlight = new Map>(); + private readonly installationIds = new Map(); + private appLogin?: string; + private appLoginInFlight?: Promise; + private actor?: GitHubCredential['actor']; + private actorInFlight?: Promise>; private readonly apiUrl: string; + private readonly host: string; constructor(private readonly options: GitHubAppCredentialProviderOptions) { if ((options.platform ?? process.platform) === 'win32') { @@ -106,10 +231,12 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { ); } assertPositiveIdentifier('GitHub App ID', options.appId); - assertPositiveIdentifier( - 'GitHub App installation ID', - options.installationId, - ); + if (options.installationId != null) { + assertPositiveIdentifier( + 'GitHub App installation ID', + options.installationId, + ); + } const host = options.host == null ? undefined : normalizeGitHubHost(options.host); const apiUrl = new URL(options.apiUrl ?? ( host != null && host !== 'github.com' @@ -126,58 +253,282 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { if (host != null && host !== apiHost) { throw new Error('LIBRECHAT_CODE_GITHUB_HOST must match the GitHub App API hostname'); } + this.host = host ?? apiHost; this.apiUrl = apiUrl.href.replace(/\/+$/, ''); } - async getCredential(signal?: AbortSignal): Promise { - const now = (this.options.now ?? (() => new Date()))(); - if ( - this.cached?.expiresAt != null && - this.cached.expiresAt.getTime() - now.getTime() > 5 * 60_000 - ) { - return this.cached; - } + private async appJwt(now: Date): Promise { const privateKey = await readPrivateKey(this.options.privateKeyPath); - const jwt = createAppJwt(this.options.appId, privateKey, now); + return createAppJwt(this.options.appId, privateKey, now); + } + + private async request( + path: string, + jwt: string, + signal?: AbortSignal, + init?: RequestInit, + ): Promise { const request = this.options.fetch ?? globalThis.fetch; - const response = await request( - `${this.apiUrl}/app/installations/${this.options.installationId}/access_tokens`, - { - method: 'POST', - redirect: 'error', - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${jwt}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - signal, + return request(`${this.apiUrl}${path}`, { + redirect: 'error', + ...init, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${jwt}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...init?.headers, }, + signal, + }); + } + + private async resolveAppLogin( + jwt: string, + signal?: AbortSignal, + ): Promise { + if (this.appLogin) return this.appLogin; + if (!this.appLoginInFlight) { + const pending = (async () => { + const sharedSignal = AbortSignal.timeout( + GITHUB_SHARED_REQUEST_TIMEOUT_MS, + ); + const appResponse = await this.request('/app', jwt, sharedSignal); + if (!appResponse.ok) { + throw new Error( + `GitHub App identity request failed with status ${appResponse.status}`, + ); + } + const app = (await appResponse.json()) as { slug?: unknown }; + if ( + typeof app.slug !== 'string' || + !/^[A-Za-z0-9-]+$/.test(app.slug) + ) { + throw new Error('GitHub App identity response is invalid'); + } + return `${app.slug}[bot]`; + })(); + this.appLoginInFlight = pending; + void pending.then( + login => { + this.appLogin = login; + if (this.appLoginInFlight === pending) { + this.appLoginInFlight = undefined; + } + }, + () => { + if (this.appLoginInFlight === pending) { + this.appLoginInFlight = undefined; + } + }, + ); + } + return waitForShared(this.appLoginInFlight, signal); + } + + private async resolveActor( + jwt: string, + installationToken: string, + signal?: AbortSignal, + ): Promise> { + if (this.actor) return this.actor; + if (!this.actorInFlight) { + const pending = (async () => { + const sharedSignal = AbortSignal.timeout( + GITHUB_SHARED_REQUEST_TIMEOUT_MS, + ); + const login = await this.resolveAppLogin(jwt, sharedSignal); + const userResponse = await this.request( + `/users/${encodeURIComponent(login)}`, + installationToken, + sharedSignal, + ); + if (!userResponse.ok) { + throw new Error( + `GitHub App bot identity request failed with status ${userResponse.status}`, + ); + } + const user = (await userResponse.json()) as { + id?: unknown; + login?: unknown; + type?: unknown; + }; + if ( + !Number.isSafeInteger(user.id) || + Number(user.id) <= 0 || + user.login !== login || + user.type !== 'Bot' + ) { + throw new Error('GitHub App bot identity response is invalid'); + } + return { + name: login, + email: `${user.id}+${login}@users.noreply.${this.host}`, + }; + })(); + this.actorInFlight = pending; + void pending.then( + actor => { + this.actor = actor; + if (this.actorInFlight === pending) this.actorInFlight = undefined; + }, + () => { + if (this.actorInFlight === pending) this.actorInFlight = undefined; + }, + ); + } + return waitForShared(this.actorInFlight, signal); + } + + async validate(signal?: AbortSignal): Promise { + const now = (this.options.now ?? (() => new Date()))(); + const jwt = await this.appJwt(now); + await this.resolveAppLogin(jwt, signal); + if (this.options.installationId) { + await this.getCredential(signal); + } + } + + private async resolveInstallationId( + repository: string, + jwt: string, + signal?: AbortSignal, + ): Promise { + if (this.options.installationId) return this.options.installationId; + const cached = this.installationIds.get(repository); + if (cached) return cached; + const { owner, name } = repositoryName(repository); + const response = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/installation`, + jwt, + signal, ); if (!response.ok) { throw new Error( - `GitHub App token request failed with status ${response.status}`, + response.status === 404 + ? `GitHub App is not installed for ${repository}` + : `GitHub App installation lookup failed with status ${response.status}`, ); } - const body = (await response.json()) as { - token?: unknown; - expires_at?: unknown; - }; - if ( - typeof body.token !== 'string' || - body.token.length < 20 || - typeof body.expires_at !== 'string' - ) { - throw new Error('GitHub App token response is invalid'); + const body = (await response.json()) as { id?: unknown }; + if (!Number.isSafeInteger(body.id) || Number(body.id) <= 0) { + throw new Error('GitHub App installation response is invalid'); + } + const installationId = String(body.id); + this.installationIds.set(repository, installationId); + return installationId; + } + + async getCredential( + signal?: AbortSignal, + repository?: string, + ): Promise { + signal?.throwIfAborted(); + if (!this.options.installationId && !repository) { + throw new Error( + 'GitHub App authentication requires a GitHub repository for this command', + ); } - const expiresAt = new Date(body.expires_at); + if (repository) repositoryName(repository); + const now = (this.options.now ?? (() => new Date()))(); + const key = this.options.installationId ?? repository!; + const cached = this.cached.get(key); if ( - !Number.isFinite(expiresAt.getTime()) || - expiresAt.getTime() <= now.getTime() + cached?.expiresAt != null && + cached.expiresAt.getTime() - now.getTime() > 5 * 60_000 ) { - throw new Error('GitHub App token expiry is invalid'); + return cached; } - this.cached = { value: body.token, expiresAt }; - return this.cached; + const existing = this.inFlight.get(key); + if (existing) return waitForShared(existing, signal); + const pending = (async () => { + const sharedSignal = AbortSignal.timeout( + GITHUB_SHARED_REQUEST_TIMEOUT_MS, + ); + const jwt = await this.appJwt(now); + const scopedRepository = repository + ? repositoryName(repository).name + : undefined; + const installationId = await this.resolveInstallationId( + repository ?? '', + jwt, + sharedSignal, + ); + let response = await this.request( + `/app/installations/${installationId}/access_tokens`, + jwt, + sharedSignal, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + ...(this.options.installationId + ? {} + : { body: JSON.stringify({ repositories: [scopedRepository] }) }), + }, + ); + if (!this.options.installationId && response.status === 404) { + this.installationIds.delete(repository!); + const refreshedInstallationId = await this.resolveInstallationId( + repository!, + jwt, + sharedSignal, + ); + response = await this.request( + `/app/installations/${refreshedInstallationId}/access_tokens`, + jwt, + sharedSignal, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ repositories: [scopedRepository] }), + }, + ); + } + if (!response.ok) { + throw new Error( + `GitHub App token request failed with status ${response.status}`, + ); + } + const body = (await response.json()) as { + token?: unknown; + expires_at?: unknown; + }; + if ( + typeof body.token !== 'string' || + body.token.length < 20 || + typeof body.expires_at !== 'string' + ) { + throw new Error('GitHub App token response is invalid'); + } + const expiresAt = new Date(body.expires_at); + if ( + !Number.isFinite(expiresAt.getTime()) || + expiresAt.getTime() <= now.getTime() + ) { + throw new Error('GitHub App token expiry is invalid'); + } + const actor = await this.resolveActor( + jwt, + body.token, + sharedSignal, + ); + const credential = { + value: body.token, + expiresAt, + actor, + }; + this.cached.set(key, credential); + return credential; + })(); + this.inFlight.set(key, pending); + const clearPending = () => { + if (this.inFlight.get(key) === pending) this.inFlight.delete(key); + }; + void pending.then(clearPending, clearPending); + return waitForShared(pending, signal); } } @@ -211,6 +562,12 @@ export function gitHubCommandCredentialEnvironment( return { ...gitHubCredentialEnvironment(credential), [gitHubCliTokenEnvironmentName(host)]: credential.value, + ...(credential.actor + ? { + [GITHUB_AUTHOR_NAME_ENV_NAME]: credential.actor.name, + [GITHUB_AUTHOR_EMAIL_ENV_NAME]: credential.actor.email, + } + : {}), }; } @@ -260,12 +617,10 @@ export function gitHubAuthenticationPolicyIdentity(options: { return `${identity}:fingerprint:${fingerprint}`; } if (options.mode !== 'app') return identity; - if (!options.appId || !options.installationId) { - throw new Error( - 'GitHub App policy identity requires an App and installation ID', - ); + if (!options.appId) { + throw new Error('GitHub App policy identity requires an App ID'); } - return `${identity}:app:${options.appId}:installation:${options.installationId}`; + return `${identity}:app:${options.appId}:installation:${options.installationId ?? 'repository'}`; } export function normalizeGitHubHost(value: string): string { @@ -284,24 +639,48 @@ export function wrapGitHubCredentialCommand( command: string, host = 'github.com', platform: NodeJS.Platform = process.platform, + environment: Readonly> = {}, ): string { const key = `http.https://${host}/.extraheader`; const cliHost = host === 'github.com' ? undefined : host; + const hasCredential = Boolean(environment[GITHUB_CREDENTIAL_ENV_NAME]); + const actorName = environment[GITHUB_AUTHOR_NAME_ENV_NAME]; + const actorEmail = environment[GITHUB_AUTHOR_EMAIL_ENV_NAME]; + const noReplyHost = `users.noreply.${normalizeGitHubHost(host)}` + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const hasActor = + /^[A-Za-z0-9_.-]+\[bot\]$/.test(actorName ?? '') && + new RegExp( + `^[1-9][0-9]+\\+[A-Za-z0-9_.-]+\\[bot\\]@${noReplyHost}$`, + ).test(actorEmail ?? ''); if (platform === 'win32') { return [ 'set "GIT_CONFIG_GLOBAL=NUL"', 'set "GIT_CONFIG_NOSYSTEM=1"', ...(cliHost ? [`set "GH_HOST=${cliHost}"`] : []), - `set "GIT_CONFIG_PARAMETERS='http.proxyAuthMethod=basic' '${key}=Authorization: Basic %${GITHUB_CREDENTIAL_ENV_NAME}%'"`, + ...(hasCredential + ? [`set "GIT_CONFIG_PARAMETERS='http.proxyAuthMethod=basic' '${key}=Authorization: Basic %${GITHUB_CREDENTIAL_ENV_NAME}%'"`] + : ['set "GIT_CONFIG_PARAMETERS="']), + ...(hasActor + ? [`set "GIT_CONFIG_PARAMETERS=%GIT_CONFIG_PARAMETERS% 'user.name=${actorName}' 'user.email=${actorEmail}'"`] + : []), `set "${GITHUB_CREDENTIAL_ENV_NAME}="`, + `set "${GITHUB_AUTHOR_NAME_ENV_NAME}="`, + `set "${GITHUB_AUTHOR_EMAIL_ENV_NAME}="`, command, ].join(' && '); } return [ 'export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1', ...(cliHost ? [`export GH_HOST=${cliHost}`] : []), - `export GIT_CONFIG_PARAMETERS="'http.proxyAuthMethod=basic' '${key}=Authorization: Basic \${${GITHUB_CREDENTIAL_ENV_NAME}}'"`, + ...(hasCredential + ? [`export GIT_CONFIG_PARAMETERS="'http.proxyAuthMethod=basic' '${key}=Authorization: Basic \${${GITHUB_CREDENTIAL_ENV_NAME}}'"`] + : ['export GIT_CONFIG_PARAMETERS=']), + ...(hasActor + ? [`export GIT_CONFIG_PARAMETERS="\${GIT_CONFIG_PARAMETERS} 'user.name=${actorName}' 'user.email=${actorEmail}'"`] + : []), `unset ${GITHUB_CREDENTIAL_ENV_NAME}`, + `unset ${GITHUB_AUTHOR_NAME_ENV_NAME} ${GITHUB_AUTHOR_EMAIL_ENV_NAME}`, command, ].join(';\n'); } diff --git a/packages/code/src/native-process.test.ts b/packages/code/src/native-process.test.ts index 89651845..b3016937 100644 --- a/packages/code/src/native-process.test.ts +++ b/packages/code/src/native-process.test.ts @@ -181,12 +181,14 @@ test('executor forwards the resolved command policy without worker credentials', test('executor hands credentials over IPC only for the current command', async () => { const fake = fixture(); + let credentialCwd: string | undefined; const sandbox = new NativeProcessWorkspaceCommandSandbox( { workspaceRoot: '/workspace', maskedEnvironment: { variables: [{ name: 'TOKEN', injectHosts: ['github.com'] }], - async resolve() { + async resolve(_signal, cwd) { + credentialCwd = cwd; return { TOKEN: 'per-command-secret' }; }, wrapCommand(command) { @@ -208,6 +210,7 @@ test('executor hands credentials over IPC only for the current command', async ( assert.deepEqual(fake.messages[1].credentials, { TOKEN: 'per-command-secret', }); + assert.equal(credentialCwd, '/workspace'); assert.equal(fake.messages[1].wrappedCommand, 'wrapped printf ok'); await sandbox.close(); }); diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index 70cb2bd1..ba411042 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -2,7 +2,7 @@ import { execFile, fork } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { constants as fsConstants } from 'node:fs'; import { access, realpath } from 'node:fs/promises'; -import { isAbsolute, join, relative, sep } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; import { WorkspaceToolError } from './workspace.js'; import { NATIVE_PROGRAMMATIC_COMMAND } from './native-programmatic.js'; @@ -436,11 +436,15 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan if (signal?.aborted) throw new Error('aborted'); programmaticExecutables = await this.resolveProgrammaticExecutables(); if (signal?.aborted) throw new Error('aborted'); - credentials = await this.options.maskedEnvironment?.resolve(signal); + credentials = await this.options.maskedEnvironment?.resolve( + signal, + this.options.workspaceRoot, + ); if (signal?.aborted) throw new Error('aborted'); wrappedCommand = this.options.maskedEnvironment?.wrapCommand?.( NATIVE_PROGRAMMATIC_COMMAND, process.platform, + credentials ?? {}, ); if (signal?.aborted) throw new Error('aborted'); } catch (error) { @@ -529,11 +533,13 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan try { await this.prepare(); if (signal?.aborted) throw new Error('aborted'); - credentials = await this.options.maskedEnvironment?.resolve(signal); + const cwd = resolve(this.options.workspaceRoot, request.cwd ?? '.'); + credentials = await this.options.maskedEnvironment?.resolve(signal, cwd); if (signal?.aborted) throw new Error('aborted'); wrappedCommand = this.options.maskedEnvironment?.wrapCommand?.( request.command, process.platform, + credentials ?? {}, ); if (signal?.aborted) throw new Error('aborted'); } catch (error) { diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index e610a0fc..b1e4c5df 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1031,6 +1031,51 @@ test('masks a host credential for only its injection host and restores the paren }); }); +test('keeps trusted public command context out of credential masking', async t => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const fake = fakeManager(); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + allowedDomains: ['github.com'], + maskedEnvironment: { + variables: [ + { + name: 'LIBRECHAT_CODE_TEST_CREDENTIAL', + injectHosts: ['github.com'], + }, + ], + async resolve() { + return { + LIBRECHAT_CODE_TEST_CREDENTIAL: 'real-secret', + LIBRECHAT_CODE_TEST_PUBLIC_IDENTITY: 'lia[bot]', + }; + }, + wrapCommand(command, _platform, environment) { + assert.equal( + environment.LIBRECHAT_CODE_TEST_PUBLIC_IDENTITY, + 'lia[bot]', + ); + return `export LIBRECHAT_CODE_TEST_PUBLIC_IDENTITY="${environment.LIBRECHAT_CODE_TEST_PUBLIC_IDENTITY}"; ${command}`; + }, + }, + manager: fake.manager, + }); + + const result = await sandbox.execute({ + ...request, + command: + 'printf "%s|%s" "$LIBRECHAT_CODE_TEST_CREDENTIAL" "$LIBRECHAT_CODE_TEST_PUBLIC_IDENTITY"', + }); + + assert.equal(result.stdout, 'Authorization: Bearer srt-sentinel|lia[bot]'); + assert.ok( + !fake.config?.credentials?.envVars?.some( + variable => variable.name === 'LIBRECHAT_CODE_TEST_PUBLIC_IDENTITY', + ), + ); +}); + test('serializes credential handoff across concurrent sandbox instances', async t => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 84602a35..5bf61c50 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -182,8 +182,15 @@ export interface NativeSrtWorkspaceCommandSandboxOptions { injectHosts: string[]; extract?: string; }>; - resolve(signal?: AbortSignal): Promise>; - wrapCommand?(command: string, platform: NodeJS.Platform): string; + resolve( + signal?: AbortSignal, + cwd?: string, + ): Promise>; + wrapCommand?( + command: string, + platform: NodeJS.Platform, + environment: Readonly>, + ): string; }; } @@ -872,18 +879,19 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } const commandId = `librechat-code-${randomUUID()}`; - const sandboxedCommand = this.options.maskedEnvironment?.wrapCommand - ? this.options.maskedEnvironment.wrapCommand( - request.command, - this.platform, - ) - : request.command; let wrapped: Awaited< ReturnType >; try { const credentialEnvironment = - await this.options.maskedEnvironment?.resolve(signal); + await this.options.maskedEnvironment?.resolve(signal, cwd); + const sandboxedCommand = this.options.maskedEnvironment?.wrapCommand + ? this.options.maskedEnvironment.wrapCommand( + request.command, + this.platform, + credentialEnvironment ?? {}, + ) + : request.command; wrapped = await this.withTemporaryHostEnvironment( { ...TRUSTED_GIT_ENVIRONMENT, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index c5353ae2..27220735 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -712,6 +712,8 @@ export interface BridgeWorkerStatusResponse { online: boolean; ready: boolean; leaseExpiresInMs?: number; + /** Server-owned execution ceiling for workspace commands. Omitted by legacy servers. */ + maxCommandTimeoutMs?: number; capabilities?: BridgeWorkerCapabilities; } diff --git a/service/src/bridge/index.ts b/service/src/bridge/index.ts index a1ec5d78..16857fad 100644 --- a/service/src/bridge/index.ts +++ b/service/src/bridge/index.ts @@ -21,4 +21,5 @@ export default createBridgeRouter({ adminToken: env.BRIDGE_TOKEN, configuredWorkerId: env.BRIDGE_WORKER_ID, allowDynamicWorkers: env.BRIDGE_DYNAMIC_WORKERS, + maxCommandTimeoutMs: env.JOB_TIMEOUT, }); diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index af0e765c..63640111 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -10,7 +10,10 @@ import { createBridgeIdentity, signBridgeRequest, } from '../../../packages/code/src/identity'; -import { BRIDGE_PROTOCOL_VERSION } from '../../../packages/code/src/protocol'; +import { + BRIDGE_PROTOCOL_VERSION, + BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, +} from '../../../packages/code/src/protocol'; import { RedisBridgePairingStore } from './pairing'; import { createBridgeRouter } from './router'; import { RedisBridgeStore } from './store'; @@ -115,6 +118,55 @@ describe('paired bridge HTTP API', () => { expect(unauthorized.status).toBe(401); }); + test('advertises the effective server command timeout for command-capable workers', async () => { + const store = new RedisBridgeStore(redis); + const app = express(); + app.use(json()); + app.use( + '/v1/bridge', + createBridgeRouter({ + store, + pairings: new RedisBridgePairingStore(redis), + authMode: 'static', + adminToken: 'strong-administrator-bootstrap-token', + configuredWorkerId: 'command-worker', + maxCommandTimeoutMs: 900_000, + }), + ); + server = createServer(app); + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') { + throw new Error('Expected TCP listener'); + } + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'command-worker', + incarnationId: 'incarnation-00000001', + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'native-srt', + runtimes: [], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['execute_command'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + + const response = await fetch( + `http://127.0.0.1:${address.port}/v1/bridge/workers/command-worker/status`, + { headers: { Authorization: 'Bearer strong-administrator-bootstrap-token' } }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + workerId: 'command-worker', + maxCommandTimeoutMs: BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, + }); + }); + test('rejects a malformed optional binding for a configured worker', async () => { const app = express(); app.use(json()); diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index 369b306c..73870772 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -7,6 +7,7 @@ import type { BridgePrincipalType, BridgeWorkerBinding } from './pairing'; import type { CodeBridgeAssignment, CodeBridgeSettlement } from './store'; import { + BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, BRIDGE_PROTOCOL_VERSION, isValidBridgeWorkerCapabilities, isValidBridgeWorkerId, @@ -36,6 +37,7 @@ export interface BridgeRouterOptions { adminToken: string; configuredWorkerId?: string; allowDynamicWorkers?: boolean; + maxCommandTimeoutMs?: number; } function sameToken(left: string, right: string): boolean { @@ -132,6 +134,16 @@ function isSettlement(value: unknown): value is CodeBridgeSettlement { export function createBridgeRouter(options: BridgeRouterOptions): Router { const router = Router(); if (options.enabled === false) return router; + if ( + options.maxCommandTimeoutMs !== undefined && + (!Number.isSafeInteger(options.maxCommandTimeoutMs) || options.maxCommandTimeoutMs < 1) + ) { + throw new RangeError('Workspace command timeout must be a positive safe integer'); + } + const maxCommandTimeoutMs = + options.maxCommandTimeoutMs == null + ? undefined + : Math.min(options.maxCommandTimeoutMs, BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS); const configuredWorker = (workerId: string): boolean => options.allowDynamicWorkers === true || @@ -324,10 +336,13 @@ export function createBridgeRouter(options: BridgeRouterOptions): Router { return; } const status = await options.store.workerStatus(workerId); + const supportsCommands = + status.capabilities?.workspaceTools?.operations.includes('execute_command') === true; res.json({ protocolVersion: BRIDGE_PROTOCOL_VERSION, workerId, ...status, + ...(supportsCommands && maxCommandTimeoutMs != null ? { maxCommandTimeoutMs } : {}), }); }), );