From e78dfe33b5af63c49860ff59d64b69b08c195833 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 27 Sep 2026 07:58:43 -0400 Subject: [PATCH 1/2] feat(code): allow installation-scoped GitHub tokens on trusted workers --- docs/remote-bridge/worker-runbook.md | 9 +++ packages/code/README.md | 11 +++ packages/code/src/cli.test.ts | 29 ++++++++ packages/code/src/cli.ts | 23 +++++- packages/code/src/github.test.ts | 101 +++++++++++++++++++++++++++ packages/code/src/github.ts | 43 +++++++++--- 6 files changed, 206 insertions(+), 10 deletions(-) diff --git a/docs/remote-bridge/worker-runbook.md b/docs/remote-bridge/worker-runbook.md index 3043c541..493eb000 100644 --- a/docs/remote-bridge/worker-runbook.md +++ b/docs/remote-bridge/worker-runbook.md @@ -338,6 +338,15 @@ filesystem root, but anyone able to alter a checkout's remote can select any repository where the App is installed; keep the App's installation scope narrow. Pass the checkout as the command working directory; changing directories only inside the shell cannot change the token chosen before command launch. +For a trusted VM that needs to switch among repositories in the same installed +account or organization inside one command, set +`LIBRECHAT_CODE_GITHUB_TOKEN_SCOPE=installation`. The resolved installation +token covers only repositories and permissions GitHub granted to that App +installation. It refreshes after two minutes so newly approved permissions +become available without a worker restart. The default is `repository`. +Commands spanning different accounts or organizations must start in a checkout +from the target account or organization; a shell `cd` cannot switch the +installation chosen at command launch. Set `LIBRECHAT_CODE_GITHUB_INSTALLATION_ID` only as a legacy fixed-installation fallback; it cannot be combined with checkout routing. diff --git a/packages/code/README.md b/packages/code/README.md index 5a06b92a..a87c87df 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -308,6 +308,17 @@ installed**. Use this mode only where the machine operator trusts the VM and the App's installation scope; the default `admitted` mode keeps the startup binding. Checkout routing requires an App without a fixed installation ID. +On a trusted VM, `--github-token-scope installation` (or +`LIBRECHAT_CODE_GITHUB_TOKEN_SCOPE=installation`) mints one token for all +repositories GitHub grants to the resolved App installation. This lets a +command started in one checkout push to another repository in the same account +or organization, including through `cd` or `git -C`, and use organization +Projects. GitHub still enforces the installation's selected repositories and +permissions. Tokens are shared by installation, refreshed after two minutes, +and kept out of the sandbox's readable environment. The default remains +`repository`. A command crossing to another account or organization still +needs to start in a checkout belonging to that account or organization. + 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.test.ts b/packages/code/src/cli.test.ts index f95d7eee..f6737d5f 100644 --- a/packages/code/src/cli.test.ts +++ b/packages/code/src/cli.test.ts @@ -358,6 +358,35 @@ test('CLI rejects checkout routing outside a trusted VM or without repository-sc assert.match(invalid.stderr, /must be admitted or checkout/); }); +test('CLI permits installation-scoped GitHub tokens only for a trusted VM with routed App auth', () => { + const cli = fileURLToPath(new URL('./cli.js', import.meta.url)); + const base = { + ...process.env, + LIBRECHAT_CODE_URL: 'http://127.0.0.1:1/v1', + LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret', + LIBRECHAT_CODE_WORKER_ID: 'engineering-vm', + LIBRECHAT_CODE_WORKER_DIR: process.cwd(), + LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS: 'true', + LIBRECHAT_CODE_GITHUB_TOKEN: undefined, + LIBRECHAT_CODE_GITHUB_APP_ID: '123', + LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE: '/does/not/matter', + LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: undefined, + LIBRECHAT_CODE_GITHUB_TOKEN_SCOPE: 'installation', + }; + const restricted = spawnSync(process.execPath, [cli], { encoding: 'utf8', env: base }); + assert.match(restricted.stderr, /Installation-scoped GitHub tokens require the trusted-vm/); + const trusted = spawnSync(process.execPath, [cli], { + encoding: 'utf8', + env: { ...base, LIBRECHAT_CODE_COMMAND_POLICY_PRESET: 'trusted-vm' }, + }); + assert.doesNotMatch(trusted.stderr, /Installation-scoped GitHub tokens require/); + const fixed = spawnSync(process.execPath, [cli], { + encoding: 'utf8', + env: { ...base, LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: '456' }, + }); + assert.match(fixed.stderr, /without a fixed installation ID/); +}); + test('CLI requires a runtime image for Docker supervision', () => { const result = spawnSync( process.execPath, diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 841164fb..6e3a0062 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -157,6 +157,7 @@ function githubCredentials(args: string[]): { mode?: 'app' | 'token'; repositoryRouting?: boolean; checkoutRouting?: boolean; + installationTokenScope?: boolean; policyIdentity: string; } { const token = nonEmpty(process.env.LIBRECHAT_CODE_GITHUB_TOKEN); @@ -191,6 +192,18 @@ function githubCredentials(args: string[]): { 'Checkout GitHub repository routing requires a GitHub App without a fixed installation ID', ); } + const tokenScope = + option(args, '--github-token-scope')?.trim().toLowerCase() ?? + process.env.LIBRECHAT_CODE_GITHUB_TOKEN_SCOPE?.trim().toLowerCase() ?? + 'repository'; + if (tokenScope !== 'repository' && tokenScope !== 'installation') { + throw new Error('GitHub token scope must be repository or installation'); + } + if (tokenScope === 'installation' && (!hasApp || installationId)) { + throw new Error( + 'Installation-scoped GitHub tokens require a GitHub App without a fixed installation ID', + ); + } const configuredHostValue = nonEmpty( process.env.LIBRECHAT_CODE_GITHUB_HOST, ); @@ -225,16 +238,19 @@ function githubCredentials(args: string[]): { mode: 'app', repositoryRouting: !installationId, checkoutRouting: routing === 'checkout', + installationTokenScope: tokenScope === 'installation', policyIdentity: gitHubAuthenticationPolicyIdentity({ mode: 'app', host, appId, installationId, - }) + (routing === 'checkout' ? ':routing:checkout' : ''), + }) + (routing === 'checkout' ? ':routing:checkout' : '') + + (tokenScope === 'installation' ? ':scope:installation' : ''), privateKeyPath, provider: new GitHubAppCredentialProvider({ appId: appId!, installationId, + tokenScope, privateKeyPath: privateKeyPath!, host, apiUrl, @@ -568,6 +584,11 @@ async function run( 'Checkout GitHub repository routing requires the trusted-vm command policy', ); } + if (github.installationTokenScope && commandPolicy.preset !== 'trusted-vm') { + throw new Error( + 'Installation-scoped GitHub tokens require the trusted-vm command policy', + ); + } const githubDomains = github.provider ? github.host === 'github.com' ? [...GITHUB_ALLOWED_DOMAINS] diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index b13d70f7..1e997b6d 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -239,6 +239,107 @@ test('routes and scopes GitHub App tokens per repository installation', async (t ); }); +test('installation scope shares one token across repositories in an organization and refreshes grants', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-installation-')); + 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'); + const minted: Array<{ installation: string; body?: string }> = []; + const provider = new GitHubAppCredentialProvider({ + appId: '123', + tokenScope: 'installation', + privateKeyPath, + now: () => now, + fetch: (async (input, init) => { + 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 (/\/repos\/LibreChat-AI\/(agents|LibreChat)\/installation$/.test(url)) { + return Response.json({ id: 111 }); + } + if (url.endsWith('/repos/ClickHouse/Agents/installation')) { + return Response.json({ id: 222 }); + } + const installation = /\/app\/installations\/(\d+)\/access_tokens$/.exec(url)?.[1]; + if (installation) { + minted.push({ + installation, + body: typeof init?.body === 'string' ? init.body : undefined, + }); + return Response.json({ + token: `ghs_${installation}_${minted.length}_abcdefghijklmnopqrstuvwxyz`, + expires_at: '2030-01-01T01:00:00Z', + }, { status: 201 }); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + const [agents, librechat] = await Promise.all([ + provider.getCredential(undefined, 'LibreChat-AI/agents'), + provider.getCredential(undefined, 'LibreChat-AI/LibreChat'), + ]); + assert.equal(agents.value, librechat.value); + assert.deepEqual(minted, [{ installation: '111', body: undefined }]); + const clickhouse = await provider.getCredential(undefined, 'ClickHouse/Agents'); + assert.notEqual(clickhouse.value, librechat.value); + assert.deepEqual(minted.map(entry => entry.installation), ['111', '222']); + now = new Date('2030-01-01T00:02:01Z'); + const refreshed = await provider.getCredential(undefined, 'LibreChat-AI/LibreChat'); + assert.notEqual(refreshed.value, librechat.value); + assert.deepEqual(minted.map(entry => entry.installation), ['111', '222', '111']); +}); + +test('installation scope follows a repository transfer after the lookup expires', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-transfer-')); + 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; + const provider = new GitHubAppCredentialProvider({ + appId: '123', + tokenScope: 'installation', + 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 }); + } + 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', + }); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + assert.equal((await provider.getCredential(undefined, 'acme/project')).value, + 'ghs_111_abcdefghijklmnopqrstuvwxyz'); + now = new Date('2030-01-01T00:02:01Z'); + assert.equal((await provider.getCredential(undefined, 'acme/project')).value, + 'ghs_222_abcdefghijklmnopqrstuvwxyz'); + assert.equal(lookupCount, 2); +}); + 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 })); diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index 19b8e639..dd8e8ca0 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -41,6 +41,8 @@ export interface GitHubAppCredentialProviderOptions { appId: string; /** Legacy fixed installation. Omit to resolve the installation per repository. */ installationId?: string; + /** Opt in to all repositories granted to the resolved installation. */ + tokenScope?: 'repository' | 'installation'; privateKeyPath: string; apiUrl?: string; /** Git HTTPS hostname; non-public hosts default to the GHES /api/v3 base. */ @@ -52,6 +54,7 @@ export interface GitHubAppCredentialProviderOptions { const execFileAsync = promisify(execFile); const GITHUB_SHARED_REQUEST_TIMEOUT_MS = 30_000; +const GITHUB_INSTALLATION_CACHE_MS = 2 * 60_000; async function waitForShared( promise: Promise, @@ -248,8 +251,10 @@ function createAppJwt(appId: string, privateKey: string, now: Date): string { export class GitHubAppCredentialProvider implements GitHubCredentialProvider { private readonly cached = new Map(); + private readonly cachedAt = new Map(); private readonly inFlight = new Map>(); private readonly installationIds = new Map(); + private readonly installationIdCachedAt = new Map(); private appLogin?: string; private appLoginInFlight?: Promise; private actor?: GitHubCredential['actor']; @@ -428,7 +433,11 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { ): Promise { if (this.options.installationId) return this.options.installationId; const cached = this.installationIds.get(repository); - if (cached) return cached; + const now = (this.options.now ?? (() => new Date()))().getTime(); + if ( + cached && + now - (this.installationIdCachedAt.get(repository) ?? 0) < GITHUB_INSTALLATION_CACHE_MS + ) return cached; const { owner, name } = repositoryName(repository); const response = await this.request( `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/installation`, @@ -448,6 +457,7 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { } const installationId = String(body.id); this.installationIds.set(repository, installationId); + this.installationIdCachedAt.set(repository, now); return installationId; } @@ -463,31 +473,41 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { } if (repository) repositoryName(repository); const now = (this.options.now ?? (() => new Date()))(); - const key = this.options.installationId ?? repository!; + const installationScope = this.options.tokenScope === 'installation'; + const cachedInstallation = repository && this.installationIds.get(repository); + const installationId = installationScope + ? cachedInstallation && + now.getTime() - (this.installationIdCachedAt.get(repository!) ?? 0) < GITHUB_INSTALLATION_CACHE_MS + ? cachedInstallation + : await this.resolveInstallationId(repository ?? '', await this.appJwt(now), signal) + : undefined; + const key = installationId ?? this.options.installationId ?? repository!; const cached = this.cached.get(key); if ( cached?.expiresAt != null && - cached.expiresAt.getTime() - now.getTime() > 5 * 60_000 + cached.expiresAt.getTime() - now.getTime() > 5 * 60_000 && + (!installationScope || now.getTime() - (this.cachedAt.get(key) ?? 0) < GITHUB_INSTALLATION_CACHE_MS) ) { return cached; } const existing = this.inFlight.get(key); if (existing) return waitForShared(existing, signal); const pending = (async () => { + let cacheKey = key; const sharedSignal = AbortSignal.timeout( GITHUB_SHARED_REQUEST_TIMEOUT_MS, ); const jwt = await this.appJwt(now); - const scopedRepository = repository + const scopedRepository = !installationScope && repository ? repositoryName(repository).name : undefined; - const installationId = await this.resolveInstallationId( + const resolvedInstallationId = installationId ?? await this.resolveInstallationId( repository ?? '', jwt, sharedSignal, ); let response = await this.request( - `/app/installations/${installationId}/access_tokens`, + `/app/installations/${resolvedInstallationId}/access_tokens`, jwt, sharedSignal, { @@ -495,18 +515,20 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { headers: { 'Content-Type': 'application/json', }, - ...(this.options.installationId + ...(this.options.installationId || installationScope ? {} : { body: JSON.stringify({ repositories: [scopedRepository] }) }), }, ); if (!this.options.installationId && response.status === 404) { this.installationIds.delete(repository!); + this.installationIdCachedAt.delete(repository!); const refreshedInstallationId = await this.resolveInstallationId( repository!, jwt, sharedSignal, ); + if (installationScope) cacheKey = refreshedInstallationId; response = await this.request( `/app/installations/${refreshedInstallationId}/access_tokens`, jwt, @@ -516,7 +538,9 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ repositories: [scopedRepository] }), + ...(installationScope + ? {} + : { body: JSON.stringify({ repositories: [scopedRepository] }) }), }, ); } @@ -553,7 +577,8 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { expiresAt, actor, }; - this.cached.set(key, credential); + this.cached.set(cacheKey, credential); + this.cachedAt.set(cacheKey, now.getTime()); return credential; })(); this.inFlight.set(key, pending); From e4022d5bb45afb1fe60621e2f2d8cd529cc886c3 Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 27 Sep 2026 12:50:27 +0000 Subject: [PATCH 2/2] fix(code): revalidate GitHub installation token grants --- packages/code/src/github.test.ts | 165 +++++++++++++++++++++++++++++++ packages/code/src/github.ts | 148 ++++++++++++++++++--------- 2 files changed, 269 insertions(+), 44 deletions(-) diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index 1e997b6d..5d401515 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -297,6 +297,171 @@ test('installation scope shares one token across repositories in an organization assert.deepEqual(minted.map(entry => entry.installation), ['111', '222', '111']); }); +test('installation scope shares a bounded lookup when a waiter cancels', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-shared-lookup-')); + 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 releaseLookup!: (response: Response) => void; + const lookupResponse = new Promise(resolve => { releaseLookup = resolve; }); + let lookupStarted!: () => void; + const started = new Promise(resolve => { lookupStarted = resolve; }); + const cancelled = new AbortController(); + let lookups = 0; + let mints = 0; + const provider = new GitHubAppCredentialProvider({ + appId: '123', + tokenScope: 'installation', + privateKeyPath, + now: () => new Date('2030-01-01T00:00:00Z'), + fetch: (async (input, init) => { + const url = String(input); + if (url.endsWith('/repos/acme/project/installation')) { + lookups += 1; + assert.ok(init?.signal); + assert.notEqual(init.signal, cancelled.signal); + lookupStarted(); + return lookupResponse; + } + if (url.endsWith('/app/installations/111/access_tokens')) { + mints += 1; + return Response.json({ + token: 'ghs_shared_abcdefghijklmnopqrstuvwxyz', + expires_at: '2030-01-01T01:00:00Z', + }, { status: 201 }); + } + 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' }); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + const first = provider.getCredential(cancelled.signal, 'acme/project'); + const others = Array.from({ length: 12 }, () => + provider.getCredential(undefined, 'acme/project')); + await started; + cancelled.abort(new Error('first command cancelled')); + await assert.rejects(first, /first command cancelled/); + releaseLookup(Response.json({ id: 111 })); + const credentials = await Promise.all(others); + assert.equal(lookups, 1); + assert.equal(mints, 1); + assert.equal(new Set(credentials.map(credential => credential.value)).size, 1); +}); + +test('installation scope times out a stalled lookup without a caller signal', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-lookup-timeout-')); + 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 timeout = new AbortController(); + t.mock.method(AbortSignal, 'timeout', (ms: number) => { + assert.equal(ms, 30_000); + return timeout.signal; + }); + let lookupStarted!: () => void; + const started = new Promise(resolve => { lookupStarted = resolve; }); + const provider = new GitHubAppCredentialProvider({ + appId: '123', + tokenScope: 'installation', + privateKeyPath, + fetch: (async (input, init) => { + if (String(input).endsWith('/repos/acme/project/installation')) { + assert.equal(init?.signal, timeout.signal); + lookupStarted(); + return new Promise((_resolve, reject) => { + timeout.signal.addEventListener('abort', () => reject(timeout.signal.reason), { once: true }); + }); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + const pending = provider.getCredential(undefined, 'acme/project'); + await started; + timeout.abort(new DOMException('Installation lookup timed out', 'TimeoutError')); + await assert.rejects(pending, { name: 'TimeoutError' }); +}); + +test('installation replacement checks every shared waiter’s repository grant', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-replaced-')); + 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 replaced = false; + let bLookups = 0; + let oldMints = 0; + let releaseOldMint!: () => void; + const oldMintResponse = new Promise(resolve => { releaseOldMint = resolve; }); + let oldMintStarted!: () => void; + const oldMintPending = new Promise(resolve => { oldMintStarted = resolve; }); + const provider = new GitHubAppCredentialProvider({ + appId: '123', + tokenScope: 'installation', + privateKeyPath, + now: () => now, + fetch: (async input => { + const url = String(input); + if (url.endsWith('/repos/acme/a/installation')) { + return Response.json({ id: replaced ? 222 : 111 }); + } + if (url.endsWith('/repos/acme/b/installation')) { + bLookups += 1; + return replaced ? Response.json({}, { status: 404 }) : Response.json({ id: 111 }); + } + if (url.endsWith('/app/installations/111/access_tokens')) { + oldMints += 1; + if (replaced) { + oldMintStarted(); + await oldMintResponse; + return Response.json({}, { status: 404 }); + } + return Response.json({ + token: 'ghs_old_abcdefghijklmnopqrstuvwxyz', + expires_at: '2030-01-01T00:06:00Z', + }, { status: 201 }); + } + if (url.endsWith('/app/installations/222/access_tokens')) { + return Response.json({ + token: 'ghs_new_abcdefghijklmnopqrstuvwxyz', + expires_at: '2030-01-01T01:00:00Z', + }, { status: 201 }); + } + 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' }); + } + return Response.json({}, { status: 404 }); + }) as typeof fetch, + }); + const old = await provider.getCredential(undefined, 'acme/a'); + assert.equal((await provider.getCredential(undefined, 'acme/b')).value, old.value); + replaced = true; + now = new Date('2030-01-01T00:01:01Z'); // Token needs refresh; both mappings are still live. + const forA = provider.getCredential(undefined, 'acme/a'); + await oldMintPending; + const forB = provider.getCredential(undefined, 'acme/b'); + await new Promise(resolve => setImmediate(resolve)); // Join A's stale installation mint. + releaseOldMint(); + const [a, b] = await Promise.allSettled([forA, forB]); + assert.equal(a.status, 'fulfilled'); + if (a.status === 'fulfilled') assert.equal(a.value.value, 'ghs_new_abcdefghijklmnopqrstuvwxyz'); + assert.equal(b.status, 'rejected'); + if (b.status === 'rejected') assert.match(String(b.reason), /not installed for acme\/b/); + assert.equal(oldMints, 2); // One initial mint and just one shared failed refresh. + assert.equal(bLookups, 2); // B must revalidate instead of receiving A's new token. +}); + test('installation scope follows a repository transfer after the lookup expires', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'librechat-code-github-transfer-')); t.after(() => rm(directory, { recursive: true, force: true })); diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index dd8e8ca0..8519938f 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -56,6 +56,12 @@ const execFileAsync = promisify(execFile); const GITHUB_SHARED_REQUEST_TIMEOUT_MS = 30_000; const GITHUB_INSTALLATION_CACHE_MS = 2 * 60_000; +class InstallationChangedError extends Error { + constructor(readonly installationId: string) { + super('GitHub App installation changed while issuing a token'); + } +} + async function waitForShared( promise: Promise, signal?: AbortSignal, @@ -255,6 +261,10 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { private readonly inFlight = new Map>(); private readonly installationIds = new Map(); private readonly installationIdCachedAt = new Map(); + private readonly installationIdInFlight = new Map< + string, + Promise<{ id: string; jwt: string }> + >(); private appLogin?: string; private appLoginInFlight?: Promise; private actor?: GitHubCredential['actor']; @@ -428,37 +438,80 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { private async resolveInstallationId( repository: string, - jwt: string, signal?: AbortSignal, - ): Promise { - if (this.options.installationId) return this.options.installationId; + jwt?: string, + ): Promise<{ id: string; jwt?: string }> { + if (this.options.installationId) { + return { id: this.options.installationId, jwt }; + } const cached = this.installationIds.get(repository); const now = (this.options.now ?? (() => new Date()))().getTime(); if ( cached && now - (this.installationIdCachedAt.get(repository) ?? 0) < GITHUB_INSTALLATION_CACHE_MS - ) 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( - response.status === 404 - ? `GitHub App is not installed for ${repository}` - : `GitHub App installation lookup failed with status ${response.status}`, + ) return { id: cached, jwt }; + const existing = this.installationIdInFlight.get(repository); + if (existing) return waitForShared(existing, signal); + const pending = (async () => { + const sharedSignal = AbortSignal.timeout(GITHUB_SHARED_REQUEST_TIMEOUT_MS); + const lookupJwt = jwt ?? await this.appJwt( + (this.options.now ?? (() => new Date()))(), ); + const { owner, name } = repositoryName(repository); + const response = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/installation`, + lookupJwt, + sharedSignal, + ); + if (!response.ok) { + throw new Error( + 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 { id?: unknown }; + if (!Number.isSafeInteger(body.id) || Number(body.id) <= 0) { + throw new Error('GitHub App installation response is invalid'); + } + const id = String(body.id); + this.installationIds.set(repository, id); + this.installationIdCachedAt.set( + repository, + (this.options.now ?? (() => new Date()))().getTime(), + ); + return { id, jwt: lookupJwt }; + })(); + this.installationIdInFlight.set(repository, pending); + const clearPending = () => { + if (this.installationIdInFlight.get(repository) === pending) { + this.installationIdInFlight.delete(repository); + } + }; + void pending.then(clearPending, clearPending); + return waitForShared(pending, signal); + } + + private async waitForCredential( + pending: Promise, + signal: AbortSignal | undefined, + repository: string | undefined, + retried: boolean, + ): Promise { + try { + return await waitForShared(pending, signal); + } catch (error) { + if (!(error instanceof InstallationChangedError) || retried || !repository) { + throw error; + } + // A shared mint can serve several repositories. Never pass its replacement + // installation's token to a repository whose grant has not been checked. + if (this.installationIds.get(repository) === error.installationId) { + this.installationIds.delete(repository); + this.installationIdCachedAt.delete(repository); + } + return this.getCredentialAttempt(signal, repository, true); } - 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); - this.installationIdCachedAt.set(repository, now); - return installationId; } async getCredential( @@ -472,16 +525,21 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { ); } if (repository) repositoryName(repository); - const now = (this.options.now ?? (() => new Date()))(); + return this.getCredentialAttempt(signal, repository, false); + } + + private async getCredentialAttempt( + signal: AbortSignal | undefined, + repository: string | undefined, + retried: boolean, + ): Promise { + signal?.throwIfAborted(); const installationScope = this.options.tokenScope === 'installation'; - const cachedInstallation = repository && this.installationIds.get(repository); - const installationId = installationScope - ? cachedInstallation && - now.getTime() - (this.installationIdCachedAt.get(repository!) ?? 0) < GITHUB_INSTALLATION_CACHE_MS - ? cachedInstallation - : await this.resolveInstallationId(repository ?? '', await this.appJwt(now), signal) + const installation = installationScope + ? await this.resolveInstallationId(repository ?? '', signal) : undefined; - const key = installationId ?? this.options.installationId ?? repository!; + const now = (this.options.now ?? (() => new Date()))(); + const key = installation?.id ?? this.options.installationId ?? repository!; const cached = this.cached.get(key); if ( cached?.expiresAt != null && @@ -491,21 +549,20 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { return cached; } const existing = this.inFlight.get(key); - if (existing) return waitForShared(existing, signal); + if (existing) return this.waitForCredential(existing, signal, repository, retried); const pending = (async () => { - let cacheKey = key; const sharedSignal = AbortSignal.timeout( GITHUB_SHARED_REQUEST_TIMEOUT_MS, ); - const jwt = await this.appJwt(now); + const jwt = installation?.jwt ?? await this.appJwt(now); const scopedRepository = !installationScope && repository ? repositoryName(repository).name : undefined; - const resolvedInstallationId = installationId ?? await this.resolveInstallationId( + const resolvedInstallationId = installation?.id ?? (await this.resolveInstallationId( repository ?? '', - jwt, sharedSignal, - ); + jwt, + )).id; let response = await this.request( `/app/installations/${resolvedInstallationId}/access_tokens`, jwt, @@ -521,14 +578,14 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { }, ); if (!this.options.installationId && response.status === 404) { + if (installationScope) throw new InstallationChangedError(resolvedInstallationId); this.installationIds.delete(repository!); this.installationIdCachedAt.delete(repository!); - const refreshedInstallationId = await this.resolveInstallationId( + const refreshedInstallationId = (await this.resolveInstallationId( repository!, - jwt, sharedSignal, - ); - if (installationScope) cacheKey = refreshedInstallationId; + jwt, + )).id; response = await this.request( `/app/installations/${refreshedInstallationId}/access_tokens`, jwt, @@ -577,8 +634,11 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { expiresAt, actor, }; - this.cached.set(cacheKey, credential); - this.cachedAt.set(cacheKey, now.getTime()); + this.cached.set(key, credential); + this.cachedAt.set( + key, + (this.options.now ?? (() => new Date()))().getTime(), + ); return credential; })(); this.inFlight.set(key, pending); @@ -586,7 +646,7 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { if (this.inFlight.get(key) === pending) this.inFlight.delete(key); }; void pending.then(clearPending, clearPending); - return waitForShared(pending, signal); + return this.waitForCredential(pending, signal, repository, retried); } }