diff --git a/doc/execution-github-identity.md b/doc/execution-github-identity.md index 53a4cc5d99a..02e2ef50fdd 100644 --- a/doc/execution-github-identity.md +++ b/doc/execution-github-identity.md @@ -54,6 +54,14 @@ runner startup and provider startup. Shell operations acquire credentials for the active run through that broker. They do not require a persistent GitHub CLI login or inherit an ambient host broker capability. +Claude through native ACPX retains the controller-projected broker URL and +session/bridge capabilities alongside its managed launcher paths. Both isolated +and preconfigured homes use this transport for shell `git` and `gh` operations. +These capabilities stay out of persisted session environment data. Raw GitHub +tokens remain confined to each managed command; ambient host capabilities and +task-supplied broker overrides are excluded. A working connector tool alone does +not prove that the terminal received its broker transport. + Without a managed identity, local commits default to `Paperclip `. The launcher writes this token-free default to a private per-operation global Git configuration file, removed on exit. diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts index fa6db41055c..641cd1eb5cb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -14,11 +14,13 @@ describe("ACPX launch environment", () => { expect(() => createSanitizedAcpxSpawnInput({[key]:value, [CONFIGURED_ENVIRONMENT_KEYS]:JSON.stringify([key])}, "claude")).toThrow("configured environment"); } }); - it("keeps explicit Claude managed-command selectors without forwarding GitHub credentials", () => { + it("keeps explicit Claude managed-command transport without forwarding GitHub credentials", () => { const helpers = { PATH: "/managed:/usr/bin", BASH_ENV: "/managed/.bashrc", ZDOTDIR: "/managed", - GH_CONFIG_DIR: "/managed", PAPERCLIP_GITHUB_LAUNCHER_DIR: "/managed" }; + GH_CONFIG_DIR: "/managed", PAPERCLIP_GITHUB_LAUNCHER_DIR: "/managed", + PAPERCLIP_GITHUB_BROKER_URL: "http://127.0.0.1:3210", PAPERCLIP_GITHUB_BROKER_TOKEN: "session-capability", + PAPERCLIP_GITHUB_BRIDGE_TOKEN: "bridge-capability" }; const source = { ...helpers, GH_TOKEN: "host-secret", GITHUB_TOKEN: "host-secret", - PAPERCLIP_GITHUB_BROKER_TOKEN: "host-capability", NODE_OPTIONS: "--require=untrusted" }; + PAPERCLIP_GIT_TOKEN: "host-secret", NODE_OPTIONS: "--require=untrusted" }; expect(createSanitizedAcpxSpawnInput(source, "claude").env).toEqual(helpers); for (const [key, value] of Object.entries(helpers)) vi.stubEnv(key, value); const ambient = createSanitizedAcpxSpawnInput(undefined, "claude").env; @@ -27,6 +29,12 @@ describe("ACPX launch environment", () => { expect(ambient).not.toHaveProperty(key); expect(sibling).not.toHaveProperty(key); } + const { PAPERCLIP_GITHUB_LAUNCHER_DIR: _launcher, ...withoutLauncher } = source; + expect(createSanitizedAcpxSpawnInput(withoutLauncher, "claude").env).toEqual({ PATH: helpers.PATH }); + for (const [key, value] of Object.entries(helpers).filter(([key]) => key.startsWith("PAPERCLIP_GITHUB_"))) { + expect(configuredEnvironmentProjection({ [key]: value })).not.toHaveProperty(key); + expect(() => createSanitizedAcpxSpawnInput({ [key]: value, [CONFIGURED_ENVIRONMENT_KEYS]: JSON.stringify([key]) }, "claude")).toThrow("configured environment"); + } }); it("does not inherit ambient prepared storage", () => { vi.stubEnv("PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME", "/ambient/claude"); diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index ca2bf78e80b..8b5a9a0d68a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -153,7 +153,8 @@ export function createSanitizedAcpxSpawnInput( // The environment owner supplies helper paths, not a replacement provider executable. ...(agent === "claude" && environment !== undefined ? ["MCP_TOOL_TIMEOUT"] : []), ...(agent === "claude" && environment?.PAPERCLIP_GITHUB_LAUNCHER_DIR?.trim() - ? ["BASH_ENV", "ZDOTDIR", "GH_CONFIG_DIR", "PAPERCLIP_GITHUB_LAUNCHER_DIR"] : []), + ? ["BASH_ENV", "ZDOTDIR", "GH_CONFIG_DIR", "PAPERCLIP_GITHUB_LAUNCHER_DIR", + "PAPERCLIP_GITHUB_BROKER_URL", "PAPERCLIP_GITHUB_BROKER_TOKEN", "PAPERCLIP_GITHUB_BRIDGE_TOKEN"] : []), ...(usesPreconfiguredHome(source) ? [...PRECONFIGURED_HOME_KEYS, "PAPERCLIP_RUNNER_HOME_MODE", "ACPX_CLAUDE_INCLUDE_USER_SETTINGS"] : []), ...(agent === "claude" && environment?.PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME ? [...PRECONFIGURED_HOME_KEYS, "PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME"] : []), diff --git a/packages/paperclip-runner/src/drivers/acpx/github-launcher.test.ts b/packages/paperclip-runner/src/drivers/acpx/github-launcher.test.ts new file mode 100644 index 00000000000..64ee858df53 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/github-launcher.test.ts @@ -0,0 +1,100 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, it } from "vitest"; +import { githubLauncherSource } from "../../../../adapter-utils/src/github-launcher.js"; +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; +import { createAcpxRecoveryBinding } from "./recovery-identity.js"; +import { prepareAcpxRuntimeSandbox } from "./runtime-sandbox.js"; + +const execute = promisify(execFile); +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +it.skipIf(process.platform === "win32").each(["isolated", "prepared", "preconfigured"])("authenticates Claude shell commands through its session broker (%s home)", async homeMode => { + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-acpx-github-"))); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const workspace = join(root, "workspace"); + const runtimeDirectory = join(root, "runtime"); + const managed = join(root, "managed"); + const real = join(root, "real"); + const home = join(root, "home"); + await Promise.all([workspace, runtimeDirectory, managed, real, home].map(path => mkdir(path))); + await mkdir(join(home, ".claude")); + const binding = await createAcpxRecoveryBinding({ + runtimeDirectory, + normalizedSessionId: "github-session", + workingDirectory: workspace, + profile: resolveQualifiedAcpxProfile("claude", "claude-sonnet-5"), + requestedModel: "claude-sonnet-5", + permissionMode: "approve-reads", + }); + let identity: "first" | "second" | null = "first"; + let requests = 0; + const server = createServer((req, res) => { + req.resume(); + requests++; + if (req.method !== "POST" || req.url !== "/runtime-tools/github/credentials" + || req.headers.authorization !== "Bearer fixture-bridge" + || req.headers["x-paperclip-github-capability"] !== "fixture-session") { + res.writeHead(403).end(); + return; + } + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(identity + ? { status: "available", env: { GH_TOKEN: `fixture-operation-${identity}` } } + : { status: "unavailable", reason: "Fixture connection revoked" })); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); })); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const path = [managed, real, dirname(process.execPath), "/usr/bin", "/bin"].join(":"); + await writeFile(join(managed, ".bashrc"), `export PATH='${path}'\n`); + const executable = `#!/usr/bin/env node +const token = process.env.GH_TOKEN; +const identity = token === 'fixture-operation-first' ? 'first' : token === 'fixture-operation-second' ? 'second' : 'anonymous'; +console.log(identity); +`; + for (const program of ["git", "gh"]) { + await writeFile(join(managed, program), githubLauncherSource(), { mode: 0o700 }); + await writeFile(join(real, program), executable, { mode: 0o700 }); + } + const environment = { + PATH: path, + BASH_ENV: join(managed, ".bashrc"), + ZDOTDIR: managed, + GH_CONFIG_DIR: managed, + PAPERCLIP_GITHUB_LAUNCHER_DIR: managed, + PAPERCLIP_GITHUB_BROKER_URL: url, + PAPERCLIP_GITHUB_BROKER_TOKEN: "fixture-session", + PAPERCLIP_GITHUB_BRIDGE_TOKEN: "fixture-bridge", + GH_TOKEN: "ambient-must-not-leak", + GITHUB_TOKEN: "ambient-must-not-leak", + ...(homeMode === "preconfigured" ? { HOME: home, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" } : {}), + ...(homeMode === "prepared" ? { PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME: join(home, ".claude") } : {}), + }; + const sandbox = await prepareAcpxRuntimeSandbox({ binding, agent: "claude", environment }); + const commands = "git ls-remote https://github.com/example/fixture; gh api user"; + for (const currentIdentity of ["first", "second", null] as const) { + identity = currentIdentity; + const result = await execute("/bin/bash", ["-c", commands], { cwd: workspace, env: sandbox.launchEnvironment, timeout: 10_000 }); + expect(result.stdout.trim().split("\n")).toEqual([identity ?? "anonymous", identity ?? "anonymous"]); + if (identity) expect(result.stderr).toBe(""); + else expect(result.stderr).toContain("Fixture connection revoked"); + } + expect(requests).toBe(6); + expect(sandbox.launchEnvironment.GH_TOKEN).toBeUndefined(); + expect(sandbox.launchEnvironment.GITHUB_TOKEN).toBeUndefined(); + expect(JSON.stringify(sandbox.persistedEnvironment)).not.toMatch(/fixture-(?:session|bridge|operation)|PAPERCLIP_GITHUB_BROKER|PAPERCLIP_GITHUB_BRIDGE/); + const recovered = await prepareAcpxRuntimeSandbox({ binding, agent: "claude", environment }); + identity = "first"; + expect((await execute("/bin/bash", ["-c", commands], { cwd: workspace, env: recovered.launchEnvironment, timeout: 10_000 })).stdout.trim().split("\n")).toEqual(["first", "first"]); + expect(requests).toBe(8); + expect(await readFile(join(managed, "gh"), "utf8")).not.toContain("fixture-session"); +});