diff --git a/packages/adapter-utils/src/github-launcher.test.ts b/packages/adapter-utils/src/github-launcher.test.ts index 8ff19e5497..5cb0a5d614 100644 --- a/packages/adapter-utils/src/github-launcher.test.ts +++ b/packages/adapter-utils/src/github-launcher.test.ts @@ -61,7 +61,7 @@ describe("managed GitHub launchers", () => { ...process.env, ...githubBrokerEnvironment({}, { url: `http://127.0.0.1:${port}`, token: "private-capability" }), GH_CONFIG_DIR: configRoot, PATH: `${bin}:${process.env.PATH}`, } }); - }); + }, 15_000); // broker-offline retries the transport twice per command before it falls back it("explains unavailable access while allowing local work without credentials", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-diagnostic-")); @@ -82,6 +82,35 @@ describe("managed GitHub launchers", () => { expect(result.stderr).toContain("More than one managed GitHub identity matches this run"); expect(result.stderr).not.toMatch(/host-token|must-not-be-used|run-capability/); }); + // The first broker request fails, the second succeeds: the managed token must still reach gh. + it.each([ + ["connection drops before the response", (res: import("node:http").ServerResponse) => { res.socket?.destroy(); }], + ["body read fails mid-response", (res: import("node:http").ServerResponse) => { + res.writeHead(200, {"content-type":"application/json"}); res.write('{"status":'); setTimeout(() => res.socket?.destroy(), 20); + }], + ])("retries when the %s and still uses managed credentials", async (_label, fail) => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-retry-")); + cleanups.push(() => rm(root, {recursive:true,force:true})); + const bin = path.join(root,"managed"), realBin = path.join(root,"real"); + await mkdir(bin); await mkdir(realBin); + await writeFile(path.join(bin,"gh"), githubLauncherSource(), {mode:0o700}); + await writeFile(path.join(realBin,"gh"), '#!/usr/bin/env node\nprocess.stdout.write(JSON.stringify({token:process.env.GH_TOKEN ?? null}));', {mode:0o700}); + let requests = 0; + const server = createServer((_req,res) => { + requests++; + if (requests === 1) return fail(res); + res.setHeader("content-type","application/json"); + res.end(JSON.stringify({status:"available",env:{GH_TOKEN:"managed-token"}})); + }); + await new Promise(resolve => server.listen(0,"127.0.0.1",resolve)); + cleanups.push(() => new Promise(resolve => server.close(() => resolve()))); + const {port} = server.address() as {port:number}; + const result = await exec(path.join(bin,"gh"), [], {env:{...process.env,...githubBrokerEnvironment({GH_TOKEN:"host-token"},{url:`http://127.0.0.1:${port}`,token:"run-capability"}),PATH:`${bin}:${realBin}:${process.env.PATH}`}}); + expect(JSON.parse(result.stdout)).toEqual({token:"managed-token"}); + expect(requests).toBe(2); + expect(result.stderr).not.toContain("broker_transport_unavailable"); + expect(result.stderr).not.toMatch(/host-token|run-capability/); + }); it("captures each command's identity and clears host credentials when the next person has none", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-launcher-test-")); cleanups.push(() => rm(root, { recursive: true, force: true })); diff --git a/packages/adapter-utils/src/github-launcher.ts b/packages/adapter-utils/src/github-launcher.ts index 1de7a29101..b44bc89c5e 100644 --- a/packages/adapter-utils/src/github-launcher.ts +++ b/packages/adapter-utils/src/github-launcher.ts @@ -54,21 +54,36 @@ async function main() { let response; if (base && env.PAPERCLIP_GITHUB_BROKER_TOKEN) { const url = base.replace(/\/+$/, '').replace(/\/api$/, '') + '/runtime-tools/github/credentials'; - for (let attempt = 0; attempt < 30; attempt++) { - response = await fetch(url, { - method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000), - headers: { authorization: 'Bearer ' + (env.PAPERCLIP_GITHUB_BRIDGE_TOKEN || env.PAPERCLIP_API_KEY || env.PAPERCLIP_GITHUB_BROKER_TOKEN), - 'x-paperclip-github-capability': env.PAPERCLIP_GITHUB_BROKER_TOKEN, 'content-type': 'application/json' }, - body: '{}', - }); - if (response.status !== 409) break; - await response.arrayBuffer(); - await new Promise(resolve => setTimeout(resolve, 1000)); + // A slow or restarting control plane must not cost the operation its + // managed identity, so a failed request is retried before giving up. + // Busy (409) responses and transport failures keep separate budgets, and + // the body is read inside the retry so a failed read is retried too. + let transportFailures = 0, conflicts = 0, result; + for (;;) { + try { + response = await fetch(url, { + method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000), + headers: { authorization: 'Bearer ' + (env.PAPERCLIP_GITHUB_BRIDGE_TOKEN || env.PAPERCLIP_API_KEY || env.PAPERCLIP_GITHUB_BROKER_TOKEN), + 'x-paperclip-github-capability': env.PAPERCLIP_GITHUB_BROKER_TOKEN, 'content-type': 'application/json' }, + body: '{}', + }); + if (response.status === 409 && conflicts < 29) { + conflicts += 1; + await response.arrayBuffer(); + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + result = response.ok ? await response.json() : null; + break; + } catch (error) { + transportFailures += 1; + if (transportFailures >= 3) throw error; + await new Promise(resolve => setTimeout(resolve, 500 * transportFailures)); + } } if (!response.ok) { diagnostic(response.status === 401 || response.status === 403 ? 'capability_rejected' : 'broker_response_unavailable'); } else { - const result = await response.json(); if (result.status === 'unavailable') { const reason = typeof result.reason === 'string' ? result.reason.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 500)