From cb9aba69b7fc7f5247b03b804cd8eba17caf97e5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 09:41:48 -0500 Subject: [PATCH] fix(runtime): bind retained GitHub bridge to computer process authority --- .../native-github-access.test.ts | 30 +++++++++++++++---- .../native-runtime/native-github-access.ts | 8 ++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/server/src/services/native-runtime/native-github-access.test.ts b/server/src/services/native-runtime/native-github-access.test.ts index e6c1aaea539..a1edbc00843 100644 --- a/server/src/services/native-runtime/native-github-access.test.ts +++ b/server/src/services/native-runtime/native-github-access.test.ts @@ -13,7 +13,7 @@ const cleanups: (() => Promise)[] = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); async function broker(resolveCredentials = vi.fn(async (binding: ReturnType) => ({ status: "available", env: { GH_TOKEN: `fixture-${binding.runId}` }, -})), remote = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise) { +})), remote: boolean | "computer" = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise) { const root = await mkdtemp(path.join(tmpdir(), "native-github-reuse-")); cleanups.push(() => rm(root, { recursive: true, force: true })); const bin = path.join(root, "real-bin"); @@ -29,20 +29,34 @@ async function broker(resolveCredentials = vi.fn(async (binding: ReturnType[0]) => { + if (!processLive) throw new Error("computer_process_claim_stale"); + return execute(input); + }) }; + const target = remote === "computer" ? { + kind: "remote", transport: "computer", remoteCwd: root, + runner: { execute: async (input: Parameters[0]) => { + if (generation !== 1) throw new Error("computer_attempt_superseded"); + return execute(input); + } }, processRunner, + } as Parameters[0]["target"] : remote ? { + kind: "remote" as const, transport: "sandbox" as const, providerKey: "test", + remoteCwd: root, runner: { execute }, streamRunLogs: false, + } : null; const result = await createNativeGitHubAccess({ scope, target, cwd: root, env: { PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, PAPERCLIP_API_KEY: "old-run-api-key" }, resolveCredentials, onLog }, bridgeUnavailable ? async () => { throw new Error("fixture bridge unavailable"); } : undefined); cleanups.push(result.stop); - return { ...result, root, resolveCredentials }; + return { ...result, root, resolveCredentials, processRunner, advanceGeneration: () => { generation++; }, retireProcess: () => { processLive = false; } }; } function request(broker: NativeGitHubAccess, extra: RequestInit = {}, endpoint = "/runtime-tools/github/credentials") { return fetch(broker.env.PAPERCLIP_GITHUB_BROKER_URL + endpoint, { method: "POST", body: "{}", headers: { authorization: `Bearer ${broker.env.PAPERCLIP_GITHUB_BRIDGE_TOKEN}`, "content-type": "application/json" }, ...extra, }); } -it.each([false, true])("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => { +it.each([false, true, "computer"] as const)("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => { const b = await broker(undefined, remote); const parent = spawn(process.execPath, ["-e", ` const {execFile}=require('node:child_process'); @@ -61,6 +75,7 @@ it.each([false, true])("keeps one live parent and its original launcher environm expect(a.value).toBe("fixture-run-a"); releaseA(); expect((await operation()).value).toBe("anonymous"); + b.advanceGeneration(); const releaseB = b.activate(run("run-b")); releaseA(); // A's delayed cleanup cannot revoke B. const second = await operation(); @@ -70,6 +85,11 @@ it.each([false, true])("keeps one live parent and its original launcher environm expect(await readFile(path.join(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain(b.env.PAPERCLIP_GITHUB_BROKER_TOKEN); releaseB(); await b.stop(); await expect(access(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR)).rejects.toThrow(); + if (remote === "computer") { + expect(b.processRunner.execute).toHaveBeenCalled(); + b.retireProcess(); + await expect(b.processRunner.execute({ command: "true" })).rejects.toThrow("computer_process_claim_stale"); + } }, 45_000); it("rejects other scopes, concurrent bindings, browser requests and forged authority", async () => { const b = await broker(); diff --git a/server/src/services/native-runtime/native-github-access.ts b/server/src/services/native-runtime/native-github-access.ts index a9711329ff3..8872ca1930d 100644 --- a/server/src/services/native-runtime/native-github-access.ts +++ b/server/src/services/native-runtime/native-github-access.ts @@ -25,7 +25,13 @@ export async function createNativeGitHubAccess(input: { onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }, startBridge = startAdapterExecutionTargetPaperclipBridge) { const token = randomBytes(32).toString("hex"); - const location = { runId: `native-session-${randomUUID()}`, target: input.target }; + // The broker belongs to the retained process, while its activate() binding + // grants one run at a time. A computer attempt advances generation on each + // turn; polling must retain the exact process capability across that handoff. + const target = input.target?.kind === "remote" && input.target.transport === "computer" + ? { ...input.target, runner: input.target.processRunner } + : input.target; + const location = { runId: `native-session-${randomUUID()}`, target }; let active: Binding | null = null; let stopped = false; let ready = true;