feat(plugins): expose GitHub credential broker to task providers (#18178)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-10-29 10:52:44 -07:00
1 parent 9f3fc2094f
commit e2cfdc7e0c
14 files changed
+190 -18

No files matched your search

@@ -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 });
@@ -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
@@ -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"}});
});
@@ -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<void>;
}
/** 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<PreparedPluginRunnerCredentials> {
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");
}
@@ -32,6 +32,8 @@ async function prepare(owner: ReturnType<typeof createPluginRunnerTaskOwner>, cu
function provider() {
let stopped = false;
const task = vi.fn(async (operation: any): Promise<unknown> => {
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<string,unknown>;
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"]);
});
@@ -41,6 +41,7 @@ export function createPluginRunnerTaskOwner(input: {
onDispatched?: () => Promise<void> | 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 } : {}),