mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
feat(plugins): expose GitHub credential broker to task providers (#18178)
This commit is contained in:
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 } : {}),
|
||||
|
||||
Reference in new issue
Block a user