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

+12
View File
@@ -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.
+35 -2
View File
@@ -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
`<url without trailing /api>/runtime-tools/github/credentials`, with
`Authorization: Bearer <token>`. 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.
@@ -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.
@@ -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.
@@ -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 <managed@example.test>");
}, 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<void>(resolve => server.listen(0, "127.0.0.1", resolve));
cleanups.push(() => new Promise<void>(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"]);
});
+15 -1
View File
@@ -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
@@ -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,
@@ -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);
});
});
@@ -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 } : {}),