mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +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
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in new issue
Block a user