From e2cfdc7e0c7a97349846150be4036b033087fe92 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 29 Oct 2026 10:52:44 -0700 Subject: [PATCH] feat(plugins): expose GitHub credential broker to task providers (#18178) --- doc/execution-github-identity.md | 12 ++++++ doc/plugins/PLUGIN_RUNNER_EXECUTION.md | 37 ++++++++++++++++++- .../execution-environments.md | 8 ++++ maps/feature-map/execution-environments.md | 8 ++++ .../adapter-utils/src/github-launcher.test.ts | 28 ++++++++++++++ packages/adapter-utils/src/github-launcher.ts | 16 +++++++- packages/plugins/sdk/src/environment-tasks.ts | 8 ++++ .../sdk/tests/environment-tasks.test.ts | 14 +++++++ .../heartbeat-github-launchers.test.ts | 14 ++++--- .../services/heartbeat-github-launchers.ts | 5 ++- .../plugin-runner-credentials.test.ts | 7 ++++ .../plugin-runner-credentials.ts | 20 ++++++++-- .../native-runtime/plugin-runner-task.test.ts | 23 ++++++++++-- .../native-runtime/plugin-runner-task.ts | 8 ++++ 14 files changed, 190 insertions(+), 18 deletions(-) diff --git a/doc/execution-github-identity.md b/doc/execution-github-identity.md index 6a7ce2dbe1a..f5daa357289 100644 --- a/doc/execution-github-identity.md +++ b/doc/execution-github-identity.md @@ -221,3 +221,15 @@ must send them only to trusted execution infrastructure over HTTPS. Revocation prevents subsequent credential acquisition; it cannot recall a token already sent to an in-flight operation. Existing tasks and Git credential fallback rules outside this plugin API are unchanged. + +### Plugin-owned task command environments + +Task-only environment plugins negotiate `githubCredentialBrokerVersion: 1` and +receive the current run's broker capability through structured preparation. +Core does not prescribe executable names, files, scripts, or a staging layout. +The plugin owns its command implementation and isolates ambient host identity. +See [the plugin broker contract](plugins/PLUGIN_RUNNER_EXECUTION.md#operation-time-github-credentials). +These task providers use the signed, run-scoped public broker endpoint; they do +not require the controller's remote shell or session callback bridge. Warm task +preparation replaces the capability before the next run's commands. Credentials +are still captured separately for each operation through the same live resolver. diff --git a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md index b22f67176ea..f9304a854ad 100644 --- a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md +++ b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md @@ -188,8 +188,10 @@ accounts continue per-turn until such an identity is available; the saved warm preference does not bypass account ownership checks. Drivers without warm capability and setup probes remain ephemeral. -Plugin task execution does not create a Core-managed GitHub broker. Warm reuse -and restart recovery use the same broker-ownership rule as initial startup. +Plugin task execution uses the public, run-scoped GitHub credential broker +when the provider advertises its versioned contract. Warm preparation updates +the command capability before admitting the new run. Restart recovery retains +the original run binding; new work must not reactivate an older capability. Project authorization is still checked for each new run. A Launcher task owns one Runner process, independently of Core heartbeat runs. @@ -235,3 +237,34 @@ checkpoint under its stable archive key and preserves provider session files. Warm status monitoring relies on PRP after startup and does not poll an idle Launcher. ACPX uses this task-provider contract; other execution transports retain their provider-specific lifecycle policies. + +### Operation-time GitHub credentials + +A task provider can advertise `githubCredentialBrokerVersion: 1` in its +`describe` result. Core then supplies an optional `githubBroker: { url, token }` +in each `prepare` operation. This is a runtime capability, not a GitHub token. +No controller path, executable, script, or command implementation is part of +this contract. Plugin authors own how their command environment uses it. + +At the start of each authenticated operation, send `POST` with body `{}` to +`/runtime-tools/github/credentials`, with +`Authorization: Bearer `. The response is either +`{ status: "available", env: { ... } }` or +`{ status: "unavailable", reason: "..." }`. Core rechecks the live run, accepted +instruction identity, grants, and trust policy on each request. Use the returned +environment only in the command and its descendants. Never retain raw GitHub +credentials in plugin state, launch descriptors, logs, or files. + +A warm task must replace its broker capability before admitting the next run's +commands. An absent broker clears the old capability and allows anonymous/local +operations only. Isolate ambient host credentials and Git/GitHub configuration; +an unavailable broker must never select a host account. Capture once per +operation so steering does not split a command's Git descendants across people. +The runtime capability may be staged privately for command-time access; it must +not enter the returned launch descriptor or entity records. Core's managed +launchers are an optional reference implementation. Compatible native helpers, +credential helpers, or command proxies can implement the same semantics. + +Deploy the updated Core and plugin together. A configured +GitHub connection on an older task provider returns an explicit upgrade error. +Unconfigured providers continue ordinary work without authenticated GitHub access. diff --git a/maps/agent-access-map/execution-environments.md b/maps/agent-access-map/execution-environments.md index 293a7023bcb..ba1bd355a87 100644 --- a/maps/agent-access-map/execution-environments.md +++ b/maps/agent-access-map/execution-environments.md @@ -239,3 +239,11 @@ Managed account transfer requires `projectAuthCheckVersion: 1` and a successful `check_project_auth` result for the exact prepared configuration, provider, and project set. New preparation removes old validation. Core checks the saved validation again in the dispatch update. + +Plugin task providers can advertise `githubCredentialBrokerVersion: 1` and receive +a live-run capability during preparation. This adds no agent management API. +Core resolves accepted identity, grants, company scope and trust at command time. +The plugin owns the command implementation; no particular script is required. +[Broker handoff coverage](../../server/src/services/native-runtime/plugin-runner-task.test.ts) +checks capability changes across warm turns. This is contract coverage, not live +authenticated push acceptance. diff --git a/maps/feature-map/execution-environments.md b/maps/feature-map/execution-environments.md index 15a35332c67..964cb2a2c35 100644 --- a/maps/feature-map/execution-environments.md +++ b/maps/feature-map/execution-environments.md @@ -185,3 +185,11 @@ Managed account transfer requires `projectAuthCheckVersion: 1` and a successful `check_project_auth` result for the exact prepared configuration, provider, and project set. New preparation removes old validation. Core checks the saved validation again in the dispatch update. + +Plugin task providers can advertise `githubCredentialBrokerVersion: 1` and receive +a live-run capability during preparation. This adds no agent management API. +Core resolves accepted identity, grants, company scope and trust at command time. +The plugin owns the command implementation; no particular script is required. +[Broker handoff coverage](../../server/src/services/native-runtime/plugin-runner-task.test.ts) +checks capability changes across warm turns. This is contract coverage, not live +authenticated push acceptance. diff --git a/packages/adapter-utils/src/github-launcher.test.ts b/packages/adapter-utils/src/github-launcher.test.ts index 5dc86798bd8..cb64af9a158 100644 --- a/packages/adapter-utils/src/github-launcher.test.ts +++ b/packages/adapter-utils/src/github-launcher.test.ts @@ -328,3 +328,31 @@ process.stdout.write(JSON.stringify({identity, token:process.env.GH_TOKEN ?? nul expect((await git("-C", "repo", "log", "-1", "--format=%an <%ae>")).stdout).toBe("Managed "); }, 20_000); }); + +it("captures the current task broker file once per invocation and never falls back to inherited authority", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-task-github-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const bin = path.join(root, "managed"), real = path.join(root, "real"); + await mkdir(bin); await mkdir(real); + await writeFile(path.join(bin, "gh"), githubLauncherSource({ brokerFile: true }), { mode: 0o700 }); + await writeFile(path.join(real, "gh"), '#!/usr/bin/env node\nprocess.stdout.write(process.env.GH_TOKEN || "anonymous");\n', { mode: 0o700 }); + const captured: string[] = []; + const server = createServer((req, res) => { + captured.push(req.headers.authorization!); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "available", env: { GH_TOKEN: req.headers.authorization!.slice(7) + "-credential" } })); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise(resolve => server.close(() => resolve()))); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const env = { ...process.env, PATH: `${bin}:${real}:${process.env.PATH}`, GH_TOKEN: "ambient-secret", + PAPERCLIP_GITHUB_BROKER_URL: url, PAPERCLIP_GITHUB_BROKER_TOKEN: "stale-capability", PAPERCLIP_API_KEY: "unrelated-api-key" }; + const run = () => exec(path.join(bin, "gh"), [], { env }); + for (const token of ["first-run", "next-run"]) { + await writeFile(path.join(bin, "broker.json"), JSON.stringify({ url, token })); + expect((await run()).stdout).toBe(token + "-credential"); + } + await rm(path.join(bin, "broker.json")); + expect((await run()).stdout).toBe("anonymous"); + expect(captured).toEqual(["Bearer first-run", "Bearer next-run"]); +}); diff --git a/packages/adapter-utils/src/github-launcher.ts b/packages/adapter-utils/src/github-launcher.ts index 6c750c30447..c9bbd4d1e18 100644 --- a/packages/adapter-utils/src/github-launcher.ts +++ b/packages/adapter-utils/src/github-launcher.ts @@ -1,5 +1,5 @@ /** Standalone source is staged unchanged on local, SSH, and sandbox runtimes. No secrets in files. */ -export function githubLauncherSource(): string { +export function githubLauncherSource(options: { brokerFile?: boolean } = {}): string { return String.raw`#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); @@ -33,6 +33,20 @@ function isLocalGit(args) { } async function main() { let env = { ...process.env }; +${options.brokerFile ? String.raw` + delete env.PAPERCLIP_API_URL; + delete env.PAPERCLIP_API_KEY; + delete env.PAPERCLIP_GITHUB_BRIDGE_TOKEN; + env.PAPERCLIP_GITHUB_BROKER_URL = ''; + env.PAPERCLIP_GITHUB_BROKER_TOKEN = ''; + try { + const broker = JSON.parse(fs.readFileSync(path.join(directory, 'broker.json'), 'utf8')); + if (typeof broker.url === 'string' && typeof broker.token === 'string') { + env.PAPERCLIP_GITHUB_BROKER_URL = broker.url; + env.PAPERCLIP_GITHUB_BROKER_TOKEN = broker.token; + } + } catch {} +` : ''} const diagnostic = (code) => process.stderr.write('Paperclip: GitHub ' + code + '; continuing without managed credentials.\n'); const configRoot = env.GH_CONFIG_DIR || os.tmpdir(); // A missing/unwritable scratch directory must not break local Git. The diff --git a/packages/plugins/sdk/src/environment-tasks.ts b/packages/plugins/sdk/src/environment-tasks.ts index f4f86e26b53..857d2277efb 100644 --- a/packages/plugins/sdk/src/environment-tasks.ts +++ b/packages/plugins/sdk/src/environment-tasks.ts @@ -18,6 +18,12 @@ const secureUrl = z.string().url().refine(value => { }, "Expected a credential-free WSS URL"); const credentialValue = z.string().min(1).max(65_536).refine(value => !value.includes("\0")); +const githubBroker = z.object({ url: z.string().url().max(2000).refine(value => { + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash; + } catch { return false; } +}, "Expected a credential-free HTTP broker URL"), token: credentialValue }).strict(); /** Only provider authentication is transferred. Paths and executable settings remain provider-owned. */ export const environmentTaskCredentialSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("codex-auth-json"), authJson: credentialValue }).strict(), @@ -55,6 +61,7 @@ export const environmentTaskOperationSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("describe") }).strict(), z.object({ kind: z.literal("copilot_metadata"), token: credentialValue, model: z.string().min(1).max(1024).optional() }).strict(), z.object({ kind: z.literal("prepare"), requirements: environmentRunnerLaunchRequirementsSchema, + githubBroker: githubBroker.optional(), recovery: preparedLaunch.optional(), resumeFrom: providerId.optional(), priorIdentity: durableIdentity.optional(), accountIdentity: z.string().min(1).max(1024).optional(), projectIds: z.array(z.string().uuid()).max(64).refine(ids => new Set(ids).size === ids.length, "Duplicate project IDs"), @@ -130,6 +137,7 @@ export const environmentTaskResultSchema = z.discriminatedUnion("kind", [ freshWorkspaceVersion: z.literal(1).optional(), projectAuthCheckVersion: z.literal(1).optional(), workspaceFileReadVersion: z.literal(1).optional(), + githubCredentialBrokerVersion: z.literal(1).optional(), }).strict(), z.object({ kind: z.literal("status"), taskId: providerId, diff --git a/packages/plugins/sdk/tests/environment-tasks.test.ts b/packages/plugins/sdk/tests/environment-tasks.test.ts index 6f9d7aa388b..9d47c7c7ea7 100644 --- a/packages/plugins/sdk/tests/environment-tasks.test.ts +++ b/packages/plugins/sdk/tests/environment-tasks.test.ts @@ -45,3 +45,17 @@ describe("task hosted search restriction", () => { settings: { disableCodexWebSearch: true, hostedSearchBillingMode: "unmetered" } } }).success).toBe(false); }); }); + +describe("implementation-independent GitHub credential broker", () => { + it("accepts capability data without an executable, script, or filesystem path", () => { + const operation = { ...prepare, githubBroker: { url: "https://core.example.test/api", token: "runtime-capability" } }; + expect(environmentTaskOperationSchema.parse(operation)).toEqual(operation); + }); + it.each(["invalid", "file:///tmp/broker", "https://user:password@core.example.test", "https://core.example.test?token=secret"])("rejects broker transport %s", url => { + expect(environmentTaskOperationSchema.safeParse({ ...prepare, githubBroker: { url, token: "runtime-capability" } }).success).toBe(false); + }); + it("rejects executable delivery as part of this contract", () => { + expect(environmentTaskOperationSchema.safeParse({ ...prepare, githubBroker: { + url: "https://core.example.test", token: "runtime-capability", script: "run-this" } }).success).toBe(false); + }); +}); diff --git a/server/src/services/heartbeat-github-launchers.test.ts b/server/src/services/heartbeat-github-launchers.test.ts index fcf402f9a1b..9fdb0619134 100644 --- a/server/src/services/heartbeat-github-launchers.test.ts +++ b/server/src/services/heartbeat-github-launchers.test.ts @@ -11,15 +11,17 @@ import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" }; describe("heartbeat GitHub launcher lifetime", () => { - it("leaves a task provider's runtime untouched", async () => { - const prepare = vi.fn(), mint = vi.fn(); + it.each([true, false])("delegates task staging with configured GitHub: %s", async (githubConfigured) => { + const prepare = vi.fn(), mint = vi.fn(() => "run-capability"); const env = { OPENAI_API_KEY: "selected-credential" }; - const result = await prepareHeartbeatGitHubLaunchers({ native: true, githubConfigured: true, agentId: "agent", + const result = await prepareHeartbeatGitHubLaunchers({ native: true, githubConfigured, agentId: "agent", runId: "run", target: { ...target, runnerTask: async () => ({}) }, cwd: "/core/workspace", env, brokerUrl: "https://paperclip.test", createBrokerToken: mint }, prepare); - expect(result).toEqual({ env, cleanupLocation: null }); + expect(result.cleanupLocation).toBeNull(); + expect(result.env).toMatchObject({ ...env, GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_URL: githubConfigured ? "https://paperclip.test" : "", + PAPERCLIP_GITHUB_BROKER_TOKEN: githubConfigured ? "run-capability" : "" }); expect(prepare).not.toHaveBeenCalled(); - expect(mint).not.toHaveBeenCalled(); + expect(mint).toHaveBeenCalledTimes(githubConfigured ? 1 : 0); }); it.each([target, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => { const prepare = vi.fn(); @@ -98,7 +100,7 @@ it("keeps anonymous wrappers usable after run cleanup and excludes sandbox image await writeFile(path.join(bin, "gh"), `#!${process.execPath} const fs = require('node:fs'); process.stdout.write(JSON.stringify({token:process.env.GH_TOKEN || '', githubToken:process.env.GITHUB_TOKEN || '', ssh:process.env.SSH_AUTH_SOCK, global:process.env.GIT_CONFIG_GLOBAL, globalConfig:fs.readFileSync(process.env.GIT_CONFIG_GLOBAL, 'utf8'), imageConfig:fs.existsSync(process.env.GH_CONFIG_DIR + '/hosts.yml')}));`, { mode: 0o700 }); - const imageEnv = { HOME: root, PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, GH_TOKEN: "ambient-token", GITHUB_TOKEN: "ambient-other-token", SSH_AUTH_SOCK: "/ambient/socket" }; + const imageEnv = { HOME: root, PATH: `${bin}:${path.dirname(process.execPath)}:${process.env.PATH}`, GH_TOKEN: "ambient-token", GITHUB_TOKEN: "ambient-other-token", SSH_AUTH_SOCK: "/ambient/socket" }; const execute: CommandManagedRuntimeRunner["execute"] = async (input) => { const startedAt = new Date().toISOString(); const child = promisify(execFile)(input.command, input.args ?? [], { cwd: input.cwd ?? root, env: { ...imageEnv, ...input.env }, timeout: 15_000 }); diff --git a/server/src/services/heartbeat-github-launchers.ts b/server/src/services/heartbeat-github-launchers.ts index df24082f126..6f2e927e2f0 100644 --- a/server/src/services/heartbeat-github-launchers.ts +++ b/server/src/services/heartbeat-github-launchers.ts @@ -17,7 +17,10 @@ export async function prepareHeartbeatGitHubLaunchers( ) { if (input.target?.kind === "remote" && input.target.transport === "sandbox" && input.target.runnerTask) { // Provider-owned tasks decide their own runtime layout and command environment. - return { env: input.env, cleanupLocation: null }; + return { env: githubBrokerEnvironment(input.env, { + url: input.githubConfigured ? input.brokerUrl : "", + token: input.githubConfigured ? input.createBrokerToken() : "", + }), cleanupLocation: null }; } // Native configured access is owned by the provider session supervisor. // Defer staging until it has acquired that session; never mutate a live diff --git a/server/src/services/native-runtime/plugin-runner-credentials.test.ts b/server/src/services/native-runtime/plugin-runner-credentials.test.ts index 4ef41182f43..3c6d20d820b 100644 --- a/server/src/services/native-runtime/plugin-runner-credentials.test.ts +++ b/server/src/services/native-runtime/plugin-runner-credentials.test.ts @@ -39,3 +39,10 @@ describe("plugin Runner selected credentials", () => { await expect(preparePluginRunnerCredentials({ provider: "opencode", identity: "revision", environment: {} })).rejects.toThrow("credential provider"); }); }); + +it("transfers only broker authority even when the provider owns its AI account", async () => { + const result = await preparePluginRunnerCredentials({provider:"codex",home:"/must/not/read",environment:{ + PAPERCLIP_GITHUB_BROKER_URL:"https://core.example.test",PAPERCLIP_GITHUB_BROKER_TOKEN:"runtime-capability", + GH_TOKEN:"ambient-secret",PAPERCLIP_API_KEY:"unrelated-key",HOME:"/controller/private"}}); + expect(result).toEqual({githubBroker:{url:"https://core.example.test",token:"runtime-capability"}}); +}); diff --git a/server/src/services/native-runtime/plugin-runner-credentials.ts b/server/src/services/native-runtime/plugin-runner-credentials.ts index 75a61898bc8..08d2cce011e 100644 --- a/server/src/services/native-runtime/plugin-runner-credentials.ts +++ b/server/src/services/native-runtime/plugin-runner-credentials.ts @@ -4,6 +4,14 @@ import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; import { environmentTaskCredentialSchema, type PluginEnvironmentTaskCredential } from "@paperclipai/plugin-sdk"; import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; +interface PreparedPluginRunnerCredentials { + githubBroker?: { url: string; token: string }; + accountIdentity?: string; + credential?: PluginEnvironmentTaskCredential; + needsWriteback?: boolean; + onCredentials?: (snapshot: PluginEnvironmentTaskCredential) => Promise; +} + /** Transfer only the selected account. Provider configuration stays at the dedicated provider boundary. */ export async function preparePluginRunnerCredentials(input: { provider: string; @@ -11,8 +19,12 @@ export async function preparePluginRunnerCredentials(input: { accountIdentity?: string; home?: string; environment: NodeJS.ProcessEnv; -}) { - if (!input.identity) return {}; +}): Promise { + const github = input.environment.PAPERCLIP_GITHUB_BROKER_TOKEN ? { githubBroker: { + url: input.environment.PAPERCLIP_GITHUB_BROKER_URL ?? "", + token: input.environment.PAPERCLIP_GITHUB_BROKER_TOKEN, + } } : {}; + if (!input.identity) return github; const accountIdentity = input.accountIdentity ?? input.identity; if (input.provider === "codex") { if (!input.home) throw new Error("Plugin Runner selected credential home unavailable"); @@ -21,7 +33,7 @@ export async function preparePluginRunnerCredentials(input: { const parsed = JSON.parse(authJson); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Plugin Runner selected credential invalid"); const credential = environmentTaskCredentialSchema.parse({ type: "codex-auth-json", authJson }); - return { accountIdentity, credential, needsWriteback: !!parsed.tokens, + return { ...github, accountIdentity, credential, needsWriteback: !!parsed.tokens, onCredentials: async (snapshot: PluginEnvironmentTaskCredential) => { if (snapshot.type !== "codex-auth-json") throw new Error("Plugin Runner credential provider mismatch"); await copyBackCodexAuth({ hostAuthPath, readSandboxAuth: async () => Buffer.from(snapshot.authJson), log: () => {} }); @@ -34,7 +46,7 @@ export async function preparePluginRunnerCredentials(input: { } const environment = Object.fromEntries(["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] .flatMap(key => input.environment[key] ? [[key, input.environment[key]]] : [])); - return { accountIdentity, credential: environmentTaskCredentialSchema.parse({ type: "claude-env", environment }) }; + return { ...github, accountIdentity, credential: environmentTaskCredentialSchema.parse({ type: "claude-env", environment }) }; } throw new Error("Plugin Runner does not support the selected credential provider"); } diff --git a/server/src/services/native-runtime/plugin-runner-task.test.ts b/server/src/services/native-runtime/plugin-runner-task.test.ts index 1def16275c6..b419030d8d1 100644 --- a/server/src/services/native-runtime/plugin-runner-task.test.ts +++ b/server/src/services/native-runtime/plugin-runner-task.test.ts @@ -32,6 +32,8 @@ async function prepare(owner: ReturnType, cu function provider() { let stopped = false; const task = vi.fn(async (operation: any): Promise => { + if (operation.kind === "describe") return { kind: "description", taskId: "task", githubCredentialBrokerVersion: 1, + runner: {version: "fixture", digest: `sha256:${"a".repeat(64)}`} }; if (operation.kind === "check_project_auth") return { kind: "project_auth", taskId: "task", allowed: true, provider: operation.provider, configurationId: operation.configurationId }; if (operation.kind === "prepare") return { kind: "prepared", taskId: "task", launch: { @@ -415,7 +417,7 @@ it.each(["codex", "claude"])("reuses a warm %s process across run authorities wi ingress.connect.mockReturnValue({ready:Promise.resolve(),close:async()=>{}}); const retained = vi.fn(async()=>{}); const input = {task,projectIds:[],harness,projectAuthCheckVersion:1 as const,warmTaskVersion:1 as const, - accountIdentity:"account",credentialIdentity:"revision",pollMs:1,onSessionRetained:retained}; + accountIdentity:"account",credentialIdentity:"revision",pollMs:1,onSessionRetained:retained, githubBroker:{url:"https://core.example.test",token:"first-run-capability"}}; const owner = createPluginRunnerTaskOwner(input); await prepare(owner, launchRequirements(harness)); const registration = await owner.registration({} as never,"run"); @@ -429,12 +431,13 @@ it.each(["codex", "claude"])("reuses a warm %s process across run authorities wi const result=await originalTask(op) as Record; return op.kind === "prepare" ? {...result,projectAuthCheckAtSubmit:true} : result; }); - owner.rebind({...input,task:nextTask}); + owner.rebind({...input,task:nextTask,githubBroker:{url:"https://core.example.test",token:"next-run-capability"}}); const nextRegistration=await owner.registration({} as never,"next-run"); const nextIdentity={...requirements.identity,runId:"next-run",turnId:"next-turn",itemId:"next-item"}; await owner.driver.prepareLaunch!({...launchRequirements(harness),identity:nextIdentity}); - expect(nextTask.mock.calls[0][0]).toMatchObject({kind:"prepare",priorIdentity:requirements.identity,accountIdentity:"account"}); - expect(nextTask.mock.calls.map(([op])=>op.kind)).toEqual(["prepare","check_project_auth"]); + expect(nextTask.mock.calls[1][0]).toMatchObject({kind:"prepare",priorIdentity:requirements.identity,accountIdentity:"account",githubBroker:{url:"https://core.example.test",token:"next-run-capability"}}); + expect(task.mock.calls.find(([op])=>op.kind === "prepare")?.[0]).toMatchObject({githubBroker:input.githubBroker}); + expect(nextTask.mock.calls.map(([op])=>op.kind)).toEqual(["describe","prepare","check_project_auth"]); await registration.release(); nextRegistration.activate(); await nextRegistration.ready(); expect(nextTask.mock.calls.some(([op])=>op.kind==="submit" || op.kind==="stop" || op.kind==="complete")).toBe(false); await nextRegistration.release(); await nextRegistration.checkpoint("settled"); await handle.completion; @@ -486,3 +489,15 @@ it.each(["codex", "claude"])("refuses warm %s account, harness, project, or cred const registration=await owner.registration({} as never,"run"); await registration.checkpoint("unsettled"); await process.completion; }); + + it("rejects a missing broker capability before handing authority to a plugin", async () => { + const {task} = provider(); + const implementation = task.getMockImplementation()!; + task.mockImplementation(async operation => operation.kind === "describe" + ? {kind:"description",taskId:"task",runner:{version:"fixture",digest:`sha256:${"a".repeat(64)}`}} + : implementation(operation)); + const owner = createPluginRunnerTaskOwner({task,projectIds:[],harness:"codex", + githubBroker:{url:"https://core.example.test",token:"runtime-capability"}}); + await expect(prepare(owner)).rejects.toThrow("credential broker v1"); + expect(task.mock.calls.map(([op])=>op.kind)).toEqual(["describe"]); +}); diff --git a/server/src/services/native-runtime/plugin-runner-task.ts b/server/src/services/native-runtime/plugin-runner-task.ts index f143d557a12..7b6b2410771 100644 --- a/server/src/services/native-runtime/plugin-runner-task.ts +++ b/server/src/services/native-runtime/plugin-runner-task.ts @@ -41,6 +41,7 @@ export function createPluginRunnerTaskOwner(input: { onDispatched?: () => Promise | void; pollMs?: number; warmTaskVersion?: 1; + githubBroker?: { url: string; token: string }; adoptIdentity?: DurableRecoveryIdentity; }) { const pollMs = input.pollMs ?? 500; @@ -97,7 +98,14 @@ export function createPluginRunnerTaskOwner(input: { await authorizeRunnerAdoption(recovery.configurationId, requirements.identity); return recovery; } + if (input.githubBroker) { + const description = await call({ kind: "describe" }); + if (description.kind !== "description" || description.githubCredentialBrokerVersion !== 1) { + throw new Error("Update the environment plugin to support the GitHub credential broker v1"); + } + } const result = await call(environmentTaskOperationSchema.parse({ kind: "prepare", requirements, + ...(input.githubBroker ? { githubBroker: input.githubBroker } : {}), projectIds: [...input.projectIds], ...(recovery ? { recovery } : {}), ...(input.resumeFrom ? { resumeFrom: input.resumeFrom } : {}), ...(input.accountIdentity ? { accountIdentity: input.accountIdentity } : {}),