From 9528676ad6c2b6c37afde2ccf9fe0b9dc0cb3022 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 19 Sep 2026 08:47:06 -0400 Subject: [PATCH 1/5] feat: Route GitHub App credentials per repository --- docs/remote-bridge/worker-runbook.md | 19 +- packages/code/README.md | 12 +- packages/code/src/cli.test.ts | 59 +++- packages/code/src/cli.ts | 33 +- packages/code/src/github.test.ts | 127 ++++++++ packages/code/src/github.ts | 366 +++++++++++++++++++---- packages/code/src/native-process.test.ts | 5 +- packages/code/src/native-process.ts | 10 +- packages/code/src/native-sandbox.ts | 7 +- 9 files changed, 565 insertions(+), 73 deletions(-) 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..6acc0de4 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -283,14 +283,22 @@ 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. The worker resolves the App installation from each +command's Git repository, so one worker can use simultaneous installations on +personal accounts and organizations without being restarted or reconfigured. +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..72452e94 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -46,6 +46,7 @@ import type { LocalWorkspaceConfig } from './workspace.js'; import { GITHUB_ALLOWED_DOMAINS, GitHubAppCredentialProvider, + gitHubRepositoryForDirectory, gitHubCommandCredentialEnvironment, gitHubMaskedCredentialVariables, StaticGitHubCredentialProvider, @@ -149,6 +150,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 +163,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 +205,7 @@ function githubCredentials(): { return { host, mode: 'app', + repositoryRouting: !installationId, policyIdentity: gitHubAuthenticationPolicyIdentity({ mode: 'app', host, @@ -212,7 +215,7 @@ function githubCredentials(): { privateKeyPath, provider: new GitHubAppCredentialProvider({ appId: appId!, - installationId: installationId!, + installationId, privateKeyPath: privateKeyPath!, host, apiUrl, @@ -928,10 +931,23 @@ async function run( ...(github.provider ? { maskedEnvironment: { - variables: gitHubMaskedCredentialVariables(github.host), - async resolve(signal?: AbortSignal) { + variables: gitHubMaskedCredentialVariables( + github.host, + github.mode === 'app', + ), + async resolve(signal?: AbortSignal, cwd?: string) { + const repository = cwd + ? await gitHubRepositoryForDirectory( + cwd, + github.host, + signal, + ) + : undefined; + if (!repository && github.repositoryRouting) { + return {}; + } return gitHubCommandCredentialEnvironment( - await github.provider!.getCredential(signal), + await github.provider!.getCredential(signal, repository), github.host, ); }, @@ -1022,7 +1038,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..67dc0baa 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, @@ -14,6 +15,8 @@ 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,7 @@ import { gitHubMaskedCredentialVariables, GITHUB_CREDENTIAL_ENV_NAME, gitHubCredentialEnvironment, + gitHubRepositoryForDirectory, normalizeGitHubHost, wrapGitHubCredentialCommand, } from './github.js'; @@ -136,6 +140,107 @@ test('mints and caches a short-lived GitHub App installation token', async (t) = assert.equal(calls, 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 ?? '{}')), + [ + { repositories: ['LibreChat'] }, + { repositories: ['code-interpreter'] }, + ], + ); +}); + +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, + ); +}); + test('builds process-scoped Git HTTPS authorization without embedding credentials in URLs', async () => { const provider = new StaticGitHubCredentialProvider( 'github_pat_abcdefghijklmnopqrstuvwxyz', @@ -177,6 +282,28 @@ 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', true); + 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'); + assert.match(wrapped, /user\.name=/); + assert.match(wrapped, /user\.email=/); +}); + test('selects the GitHub CLI token variable for public and enterprise hosts', () => { assert.equal(gitHubCliTokenEnvironmentName('github.com'), 'GH_TOKEN'); assert.equal( diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index c727e70d..b73c3d82 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 { 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,68 @@ export interface GitHubAppCredentialProviderOptions { platform?: NodeJS.Platform; } +const execFileAsync = promisify(execFile); + +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('/'); + if (normalized.slice(0, separator) !== normalizeGitHubHost(host)) { + return undefined; + } + const repository = normalized.slice(separator + 1); + repositoryName(repository); + return repository; +} + function base64UrlJson(value: unknown): string { return Buffer.from(JSON.stringify(value)).toString('base64url'); } @@ -96,7 +172,11 @@ 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 actor?: GitHubCredential['actor']; + private actorInFlight?: Promise>; private readonly apiUrl: string; constructor(private readonly options: GitHubAppCredentialProviderOptions) { @@ -106,10 +186,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' @@ -129,55 +211,205 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { 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 resolveActor( + jwt: string, + signal?: AbortSignal, + ): Promise> { + if (this.actor) return this.actor; + this.actorInFlight ??= (async () => { + const appResponse = await this.request('/app', jwt, signal); + 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'); + } + const login = `${app.slug}[bot]`; + const userResponse = await this.request( + `/users/${encodeURIComponent(login)}`, + jwt, + signal, + ); + 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.github.com`, + }; + })(); + try { + this.actor = await this.actorInFlight; + return this.actor; + } finally { + this.actorInFlight = undefined; + } + } + + async validate(signal?: AbortSignal): Promise { + const now = (this.options.now ?? (() => new Date()))(); + const jwt = await this.appJwt(now); + await this.resolveActor(jwt, 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 expiresAt = new Date(body.expires_at); + const installationId = String(body.id); + this.installationIds.set(repository, installationId); + return installationId; + } + + async getCredential( + signal?: AbortSignal, + repository?: string, + ): Promise { + if (!this.options.installationId && !repository) { + throw new Error( + 'GitHub App authentication requires a GitHub repository for this command', + ); + } + 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; + } + const existing = this.inFlight.get(key); + if (existing) return existing; + const pending = (async () => { + const jwt = await this.appJwt(now); + const scopedRepository = repository + ? repositoryName(repository).name + : undefined; + const installationId = await this.resolveInstallationId( + repository ?? '', + jwt, + signal, + ); + const response = await this.request( + `/app/installations/${installationId}/access_tokens`, + jwt, + signal, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + ...(this.options.installationId + ? {} + : { 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 credential = { + value: body.token, + expiresAt, + ...(this.actor ? { actor: this.actor } : {}), + }; + this.cached.set(key, credential); + return credential; + })(); + this.inFlight.set(key, pending); + try { + return await pending; + } finally { + if (this.inFlight.get(key) === pending) this.inFlight.delete(key); } - this.cached = { value: body.token, expiresAt }; - return this.cached; } } @@ -211,6 +443,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, + } + : {}), }; } @@ -222,7 +460,10 @@ export function gitHubApiHost(host: string): string { return host === 'github.com' ? 'api.github.com' : host; } -export function gitHubMaskedCredentialVariables(host: string): Array<{ +export function gitHubMaskedCredentialVariables( + host: string, + includeActor = false, +): Array<{ name: string; injectHosts: string[]; extract: string; @@ -238,6 +479,21 @@ export function gitHubMaskedCredentialVariables(host: string): Array<{ extract: '^(.+)$', injectHosts: [gitHubApiHost(host)], }, + ...(includeActor + ? [ + { + name: GITHUB_AUTHOR_NAME_ENV_NAME, + extract: '^([A-Za-z0-9_.-]+\\[bot\\])$', + injectHosts: [host], + }, + { + name: GITHUB_AUTHOR_EMAIL_ENV_NAME, + extract: + '^([1-9][0-9]+\\+[A-Za-z0-9_.-]+\\[bot\\]@users\\.noreply\\.github\\.com)$', + injectHosts: [host], + }, + ] + : []), ]; } @@ -260,12 +516,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 { @@ -292,16 +546,24 @@ export function wrapGitHubCredentialCommand( '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}%'"`, + 'set "LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG="', + `if defined ${GITHUB_AUTHOR_NAME_ENV_NAME} if defined ${GITHUB_AUTHOR_EMAIL_ENV_NAME} set "LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG= 'user.name=%${GITHUB_AUTHOR_NAME_ENV_NAME}%' 'user.email=%${GITHUB_AUTHOR_EMAIL_ENV_NAME}%'"`, + `set "GIT_CONFIG_PARAMETERS='http.proxyAuthMethod=basic' '${key}=Authorization: Basic %${GITHUB_CREDENTIAL_ENV_NAME}%'%LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG%"`, `set "${GITHUB_CREDENTIAL_ENV_NAME}="`, + `set "${GITHUB_AUTHOR_NAME_ENV_NAME}="`, + `set "${GITHUB_AUTHOR_EMAIL_ENV_NAME}="`, + 'set "LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG="', 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}}'"`, + `LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG=; if [ -n "\${${GITHUB_AUTHOR_NAME_ENV_NAME}:-}" ] && [ -n "\${${GITHUB_AUTHOR_EMAIL_ENV_NAME}:-}" ]; then LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG=" 'user.name=\${${GITHUB_AUTHOR_NAME_ENV_NAME}}' 'user.email=\${${GITHUB_AUTHOR_EMAIL_ENV_NAME}}'"; fi`, + `export GIT_CONFIG_PARAMETERS="'http.proxyAuthMethod=basic' '${key}=Authorization: Basic \${${GITHUB_CREDENTIAL_ENV_NAME}}'\${LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG}"`, `unset ${GITHUB_CREDENTIAL_ENV_NAME}`, + `unset ${GITHUB_AUTHOR_NAME_ENV_NAME} ${GITHUB_AUTHOR_EMAIL_ENV_NAME}`, + 'unset LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG', 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..cb106553 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,7 +436,10 @@ 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, @@ -529,7 +532,8 @@ 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, diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 84602a35..6422d5a7 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -182,7 +182,10 @@ export interface NativeSrtWorkspaceCommandSandboxOptions { injectHosts: string[]; extract?: string; }>; - resolve(signal?: AbortSignal): Promise>; + resolve( + signal?: AbortSignal, + cwd?: string, + ): Promise>; wrapCommand?(command: string, platform: NodeJS.Platform): string; }; } @@ -883,7 +886,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox >; try { const credentialEnvironment = - await this.options.maskedEnvironment?.resolve(signal); + await this.options.maskedEnvironment?.resolve(signal, cwd); wrapped = await this.withTemporaryHostEnvironment( { ...TRUSTED_GIT_ENVIRONMENT, From de86ed002320c651b94a54b5cdde07ff91e71ea0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 19 Sep 2026 08:50:03 -0400 Subject: [PATCH 2/5] test: Make repository routing assertion deterministic --- packages/code/src/github.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index 67dc0baa..1f873e7c 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -211,10 +211,12 @@ test('routes and scopes GitHub App tokens per repository installation', async (t assert.deepEqual( calls .filter(call => call.url.endsWith('/access_tokens')) - .map(call => JSON.parse(call.body ?? '{}')), + .map(call => JSON.parse(call.body ?? '{}')) + .map(body => body.repositories[0]) + .sort(), [ - { repositories: ['LibreChat'] }, - { repositories: ['code-interpreter'] }, + 'LibreChat', + 'code-interpreter', ], ); }); From e4502f38a58f3d4df6d17575cc06dffb41ea664a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 19 Sep 2026 09:05:36 -0400 Subject: [PATCH 3/5] fix: Harden repository credential routing --- packages/code/src/cli.ts | 8 +- packages/code/src/github.test.ts | 222 ++++++++++++++++++++++- packages/code/src/github.ts | 219 +++++++++++++--------- packages/code/src/native-process.ts | 2 + packages/code/src/native-sandbox.test.ts | 45 +++++ packages/code/src/native-sandbox.ts | 19 +- 6 files changed, 415 insertions(+), 100 deletions(-) diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 72452e94..19d59f7a 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -933,7 +933,6 @@ async function run( maskedEnvironment: { variables: gitHubMaskedCredentialVariables( github.host, - github.mode === 'app', ), async resolve(signal?: AbortSignal, cwd?: string) { const repository = cwd @@ -951,11 +950,16 @@ async function run( github.host, ); }, - wrapCommand(command: string, platform: NodeJS.Platform) { + wrapCommand( + command: string, + platform: NodeJS.Platform, + environment: Readonly>, + ) { return wrapGitHubCredentialCommand( command, github.host, platform, + environment, ); }, }, diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index 1f873e7c..a4985d0f 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -221,6 +221,159 @@ test('routes and scopes GitHub App tokens per repository installation', async (t ); }); +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 })); @@ -298,14 +451,62 @@ test('binds Git commits to the GitHub App bot identity', () => { environment[GITHUB_AUTHOR_EMAIL_ENV_NAME], '328778573+lia-by-librechat[bot]@users.noreply.github.com', ); - const variables = gitHubMaskedCredentialVariables('github.com', true); - 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'); + 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( @@ -346,6 +547,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/); @@ -356,6 +560,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', diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index b73c3d82..4146117f 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -52,6 +52,27 @@ export interface GitHubAppCredentialProviderOptions { const execFileAsync = promisify(execFile); +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'); @@ -241,61 +262,70 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { signal?: AbortSignal, ): Promise> { if (this.actor) return this.actor; - this.actorInFlight ??= (async () => { - const appResponse = await this.request('/app', jwt, signal); - if (!appResponse.ok) { - throw new Error( - `GitHub App identity request failed with status ${appResponse.status}`, + if (!this.actorInFlight) { + const pending = (async () => { + const appResponse = await this.request('/app', jwt); + 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'); + } + const login = `${app.slug}[bot]`; + const userResponse = await this.request( + `/users/${encodeURIComponent(login)}`, + jwt, ); - } - 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'); - } - const login = `${app.slug}[bot]`; - const userResponse = await this.request( - `/users/${encodeURIComponent(login)}`, - jwt, - signal, + 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.github.com`, + }; + })(); + this.actorInFlight = pending; + void pending.then( + actor => { + this.actor = actor; + if (this.actorInFlight === pending) this.actorInFlight = undefined; + }, + () => { + if (this.actorInFlight === pending) this.actorInFlight = undefined; + }, ); - 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.github.com`, - }; - })(); - try { - this.actor = await this.actorInFlight; - return this.actor; - } finally { - 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.resolveActor(jwt, signal); + if (this.options.installationId) { + await this.getCredential(signal); + } } private async resolveInstallationId( @@ -332,6 +362,7 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { 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', @@ -348,7 +379,7 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { return cached; } const existing = this.inFlight.get(key); - if (existing) return existing; + if (existing) return waitForShared(existing, signal); const pending = (async () => { const jwt = await this.appJwt(now); const scopedRepository = repository @@ -357,22 +388,40 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { const installationId = await this.resolveInstallationId( repository ?? '', jwt, - signal, ); - const response = await this.request( + let response = await this.request( `/app/installations/${installationId}/access_tokens`, jwt, - signal, + undefined, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - ...(this.options.installationId - ? {} - : { body: JSON.stringify({ repositories: [scopedRepository] }) }), + 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, + ); + response = await this.request( + `/app/installations/${refreshedInstallationId}/access_tokens`, + jwt, + undefined, + { + 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}`, @@ -405,11 +454,11 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { return credential; })(); this.inFlight.set(key, pending); - try { - return await pending; - } finally { + const clearPending = () => { if (this.inFlight.get(key) === pending) this.inFlight.delete(key); - } + }; + void pending.then(clearPending, clearPending); + return waitForShared(pending, signal); } } @@ -460,10 +509,7 @@ export function gitHubApiHost(host: string): string { return host === 'github.com' ? 'api.github.com' : host; } -export function gitHubMaskedCredentialVariables( - host: string, - includeActor = false, -): Array<{ +export function gitHubMaskedCredentialVariables(host: string): Array<{ name: string; injectHosts: string[]; extract: string; @@ -479,21 +525,6 @@ export function gitHubMaskedCredentialVariables( extract: '^(.+)$', injectHosts: [gitHubApiHost(host)], }, - ...(includeActor - ? [ - { - name: GITHUB_AUTHOR_NAME_ENV_NAME, - extract: '^([A-Za-z0-9_.-]+\\[bot\\])$', - injectHosts: [host], - }, - { - name: GITHUB_AUTHOR_EMAIL_ENV_NAME, - extract: - '^([1-9][0-9]+\\+[A-Za-z0-9_.-]+\\[bot\\]@users\\.noreply\\.github\\.com)$', - injectHosts: [host], - }, - ] - : []), ]; } @@ -538,32 +569,46 @@ 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 hasActor = + /^[A-Za-z0-9_.-]+\[bot\]$/.test(actorName ?? '') && + /^[1-9][0-9]+\+[A-Za-z0-9_.-]+\[bot\]@users\.noreply\.github\.com$/.test( + actorEmail ?? '', + ); if (platform === 'win32') { return [ 'set "GIT_CONFIG_GLOBAL=NUL"', 'set "GIT_CONFIG_NOSYSTEM=1"', ...(cliHost ? [`set "GH_HOST=${cliHost}"`] : []), - 'set "LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG="', - `if defined ${GITHUB_AUTHOR_NAME_ENV_NAME} if defined ${GITHUB_AUTHOR_EMAIL_ENV_NAME} set "LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG= 'user.name=%${GITHUB_AUTHOR_NAME_ENV_NAME}%' 'user.email=%${GITHUB_AUTHOR_EMAIL_ENV_NAME}%'"`, - `set "GIT_CONFIG_PARAMETERS='http.proxyAuthMethod=basic' '${key}=Authorization: Basic %${GITHUB_CREDENTIAL_ENV_NAME}%'%LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG%"`, + ...(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}="`, - 'set "LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG="', command, ].join(' && '); } return [ 'export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1', ...(cliHost ? [`export GH_HOST=${cliHost}`] : []), - `LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG=; if [ -n "\${${GITHUB_AUTHOR_NAME_ENV_NAME}:-}" ] && [ -n "\${${GITHUB_AUTHOR_EMAIL_ENV_NAME}:-}" ]; then LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG=" 'user.name=\${${GITHUB_AUTHOR_NAME_ENV_NAME}}' 'user.email=\${${GITHUB_AUTHOR_EMAIL_ENV_NAME}}'"; fi`, - `export GIT_CONFIG_PARAMETERS="'http.proxyAuthMethod=basic' '${key}=Authorization: Basic \${${GITHUB_CREDENTIAL_ENV_NAME}}'\${LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG}"`, + ...(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}`, - 'unset LIBRECHAT_CODE_GITHUB_IDENTITY_CONFIG', command, ].join(';\n'); } diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index cb106553..ba411042 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -444,6 +444,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan wrappedCommand = this.options.maskedEnvironment?.wrapCommand?.( NATIVE_PROGRAMMATIC_COMMAND, process.platform, + credentials ?? {}, ); if (signal?.aborted) throw new Error('aborted'); } catch (error) { @@ -538,6 +539,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan 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 6422d5a7..5bf61c50 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -186,7 +186,11 @@ export interface NativeSrtWorkspaceCommandSandboxOptions { signal?: AbortSignal, cwd?: string, ): Promise>; - wrapCommand?(command: string, platform: NodeJS.Platform): string; + wrapCommand?( + command: string, + platform: NodeJS.Platform, + environment: Readonly>, + ): string; }; } @@ -875,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, 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, From 8fb8c003350af3ae102219b0b26395f39c66e289 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 19 Sep 2026 09:07:51 -0400 Subject: [PATCH 4/5] fix: Bound shared GitHub credential refreshes --- packages/code/src/github.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index 4146117f..800f28e0 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -51,6 +51,7 @@ export interface GitHubAppCredentialProviderOptions { } const execFileAsync = promisify(execFile); +const GITHUB_SHARED_REQUEST_TIMEOUT_MS = 30_000; async function waitForShared( promise: Promise, @@ -264,7 +265,10 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { if (this.actor) return this.actor; if (!this.actorInFlight) { const pending = (async () => { - const appResponse = await this.request('/app', jwt); + 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}`, @@ -281,6 +285,7 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { const userResponse = await this.request( `/users/${encodeURIComponent(login)}`, jwt, + sharedSignal, ); if (!userResponse.ok) { throw new Error( @@ -381,6 +386,9 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { 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 @@ -388,11 +396,12 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { const installationId = await this.resolveInstallationId( repository ?? '', jwt, + sharedSignal, ); let response = await this.request( `/app/installations/${installationId}/access_tokens`, jwt, - undefined, + sharedSignal, { method: 'POST', headers: { @@ -408,11 +417,12 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { const refreshedInstallationId = await this.resolveInstallationId( repository!, jwt, + sharedSignal, ); response = await this.request( `/app/installations/${refreshedInstallationId}/access_tokens`, jwt, - undefined, + sharedSignal, { method: 'POST', headers: { From 9810e2accb37218080429f6d3d7b6f7ab3a595d7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 19 Sep 2026 09:22:49 -0400 Subject: [PATCH 5/5] fix: Bind GitHub credentials to admitted workspaces --- packages/code/README.md | 10 ++-- packages/code/src/cli.ts | 21 +++++-- packages/code/src/github.test.ts | 99 +++++++++++++++++++++++++++++++- packages/code/src/github.ts | 36 ++++++++++-- 4 files changed, 151 insertions(+), 15 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 6acc0de4..69d85b16 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -289,10 +289,12 @@ 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. The worker resolves the App installation from each -command's Git repository, so one worker can use simultaneous installations on -personal accounts and organizations without being restarted or reconfigured. -Tokens are scoped and cached per repository. For compatibility with deployments +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. diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 19d59f7a..96757d57 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -46,6 +46,7 @@ import type { LocalWorkspaceConfig } from './workspace.js'; import { GITHUB_ALLOWED_DOMAINS, GitHubAppCredentialProvider, + gitHubRepositoryForAdmittedDirectory, gitHubRepositoryForDirectory, gitHubCommandCredentialEnvironment, gitHubMaskedCredentialVariables, @@ -718,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, @@ -935,11 +949,10 @@ async function run( github.host, ), async resolve(signal?: AbortSignal, cwd?: string) { - const repository = cwd - ? await gitHubRepositoryForDirectory( + const repository = cwd && admittedGitHubRepositories + ? gitHubRepositoryForAdmittedDirectory( cwd, - github.host, - signal, + admittedGitHubRepositories, ) : undefined; if (!repository && github.repositoryRouting) { diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index a4985d0f..73e02927 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -9,7 +9,7 @@ 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'; @@ -25,6 +25,7 @@ import { gitHubMaskedCredentialVariables, GITHUB_CREDENTIAL_ENV_NAME, gitHubCredentialEnvironment, + gitHubRepositoryForAdmittedDirectory, gitHubRepositoryForDirectory, normalizeGitHubHost, wrapGitHubCredentialCommand, @@ -394,6 +395,102 @@ test('discovers the GitHub repository from a command working directory', async ( 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 () => { diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index 800f28e0..4c79fa37 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -2,7 +2,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 { 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'; @@ -126,7 +126,10 @@ export async function gitHubRepositoryForDirectory( const normalized = projectRemote(remote); if (!normalized) return undefined; const separator = normalized.indexOf('/'); - if (normalized.slice(0, separator) !== normalizeGitHubHost(host)) { + const remoteHost = normalized + .slice(0, separator) + .replace(/:[1-9][0-9]*$/, ''); + if (remoteHost !== normalizeGitHubHost(host)) { return undefined; } const repository = normalized.slice(separator + 1); @@ -134,6 +137,23 @@ export async function gitHubRepositoryForDirectory( 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'); } @@ -200,6 +220,7 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { 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') { @@ -230,6 +251,7 @@ 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(/\/+$/, ''); } @@ -307,7 +329,7 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { } return { name: login, - email: `${user.id}+${login}@users.noreply.github.com`, + email: `${user.id}+${login}@users.noreply.${this.host}`, }; })(); this.actorInFlight = pending; @@ -586,11 +608,13 @@ export function wrapGitHubCredentialCommand( 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 ?? '') && - /^[1-9][0-9]+\+[A-Za-z0-9_.-]+\[bot\]@users\.noreply\.github\.com$/.test( - actorEmail ?? '', - ); + new RegExp( + `^[1-9][0-9]+\\+[A-Za-z0-9_.-]+\\[bot\\]@${noReplyHost}$`, + ).test(actorEmail ?? ''); if (platform === 'win32') { return [ 'set "GIT_CONFIG_GLOBAL=NUL"',