Reuse Runner plugin capability checks (#20987)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-11-11 17:21:07 -08:00
1 parent 42fd790e80
commit 45170b0cac
8 files changed
+36 -18

No files matched your search

+1
View File
@@ -265,6 +265,7 @@ checks. Providers that omit the capability do not support predecessor recovery.
## Launch authentication validation
Declare `projectAuthCheckVersion: 1` in `describe` for each native task launch.
For a new task, Core reads this flag from the same description it uses to set up Runner.
Core calls `check_project_auth` after preparation and before credential transfer.
The request names the configuration ID, provider, and authorized project IDs.
It contains no credential.
@@ -1984,6 +1984,21 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
expect(execute).not.toHaveBeenCalled();
} finally { execute.mockRestore(); }
});
it("defers a new plugin task's capability check to native launch and still checks retained tasks", async () => {
const runnerTask = vi.fn().mockResolvedValue({ kind: "description", taskId: "task", projectAuthCheckVersion: 1,
runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } });
const target = { kind: "remote", transport: "sandbox", providerKey: "plugin", remoteCwd: ".", runnerTask,
sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: "task" } } as const;
const options = { deferNewRunnerTaskCapabilityCheck: true };
await expect(assertManagedAiProjectAuth({}, "openai", target, options)).resolves.toBeUndefined();
await expect(assertManagedAiProjectAuth({ args: ["--api-key=override"] }, "openai", target, options)).rejects.toThrow("overrides");
expect(runnerTask).not.toHaveBeenCalled();
const retained = { ...target, sandboxLeaseAcquisition: { ...target.sandboxLeaseAcquisition, outcome: "resumed" as const } };
await expect(assertManagedAiProjectAuth({}, "openai", retained, options)).resolves.toBeUndefined();
expect(runnerTask).toHaveBeenCalledExactlyOnceWith({ kind: "describe" });
});
it("keeps personal defaults separate and does not replace the first default", async () => {
const first = await create("alice", "Alice first");
await create("alice", "Alice second");
@@ -103,6 +103,7 @@ export async function assertManagedAiProjectAuth(
config: Record<string, unknown>,
provider: AiConnectionBinding["provider"],
target?: AdapterExecutionTarget | null,
options: { deferNewRunnerTaskCapabilityCheck?: boolean } = {},
) {
const extraArgs = [
...(Array.isArray(config.extraArgs) ? config.extraArgs : []),
@@ -131,6 +132,7 @@ export async function assertManagedAiProjectAuth(
const pattern =
"apiKeyHelper|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|model_provider[[:space:]]*=|env_key[[:space:]]*=|experimental_bearer_token|cli_auth_credentials_store";
if (target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask && files.length) {
if (options.deferNewRunnerTaskCapabilityCheck && target.sandboxLeaseAcquisition?.outcome === "created") return;
const description = environmentTaskResultSchema.parse(await target.runnerTask({ kind: "describe" }));
if (description.kind !== "description" || description.projectAuthCheckVersion !== 1) {
throw unprocessable("The plugin environment cannot validate managed project authentication", { code: "ai_connection_incompatible" });
+7 -2
View File
@@ -2944,8 +2944,13 @@ export function heartbeatService(
appendRunEvent,
});
if (managedAiRuntime && aiBinding) {
try { await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget); }
catch (error) { throw classifyManagedProjectAuthFailure(error, agent.id); }
try {
await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget, {
deferNewRunnerTaskCapabilityCheck: agent.adapterType === "paperclip_runner" && !runOptions.nativeRestartRecovery,
});
} catch (error) {
throw classifyManagedProjectAuthFailure(error, agent.id);
}
}
const remoteExecution = realizationResult.remoteExecution;
if (nativeChatWorkspaceScope && materializeNativeChatWorktree) {
@@ -16022,6 +16022,7 @@ async function createRunnerdBackendWithinSessionClaim(
}) : undefined,
}),
warmTaskVersion: description.warmTaskVersion,
githubCredentialBrokerVersion: description.githubCredentialBrokerVersion,
releaseResumeVersion: description.releaseResumeVersion,
authorityRunId: description.releaseResumeVersion === 1 ? input.execution.binding.runId : undefined,
releasedSource: input.remoteWarmRecovery?.releasedSource,
@@ -44,7 +44,8 @@ export async function probePluginRunnerEnvironment(context: AdapterEnvironmentTe
identity: managed?.identity, accountIdentity: managed?.accountIdentity,
home: managed?.home, environment,
});
const owner = createPluginRunnerTaskOwner({ ...credentials, taskId: description.taskId, projectAuthCheckVersion: description.projectAuthCheckVersion, task, projectIds: [],
const owner = createPluginRunnerTaskOwner({ ...credentials, taskId: description.taskId, projectAuthCheckVersion: description.projectAuthCheckVersion,
githubCredentialBrokerVersion: description.githubCredentialBrokerVersion, task, projectIds: [],
harness: profile.provider === "acpx" ? profile.acpxAgent! : profile.provider,
credentialIdentity: managed?.identity });
probeStarted = true;
@@ -33,8 +33,6 @@ 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: {
@@ -747,7 +745,8 @@ 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 = {taskId:"task",task,projectIds:[],harness,projectAuthCheckVersion:1 as const,warmTaskVersion:1 as const,
accountIdentity:"account",credentialIdentity:"revision",pollMs:1,onSessionRetained:retained, githubBroker:{url:"https://core.example.test",token:"first-run-capability"}};
accountIdentity:"account",credentialIdentity:"revision",pollMs:1,onSessionRetained:retained, githubCredentialBrokerVersion:1 as const,
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");
@@ -765,9 +764,9 @@ it.each(["codex", "claude"])("reuses a warm %s process across run authorities wi
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[1][0]).toMatchObject({kind:"prepare",priorIdentity:requirements.identity,accountIdentity:"account",githubBroker:{url:"https://core.example.test",token:"next-run-capability"}});
expect(nextTask.mock.calls[0][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"]);
expect(nextTask.mock.calls.map(([op])=>op.kind)).toEqual(["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;
@@ -823,14 +822,10 @@ it.each(["codex", "claude"])("refuses warm %s account, harness, project, or cred
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({ taskId: "task",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"]);
expect(task).not.toHaveBeenCalled();
});
it.each((["codex", "claude"] as const).flatMap(harness => ["prepared", "unprepared", "foreign target"].map(mode => [harness, mode] as const)))(
@@ -53,6 +53,7 @@ export function createPluginRunnerTaskOwner(input: {
onResumed?: (identity: DurableRecoveryIdentity) => Promise<void>;
onRetired?: (identity: DurableRecoveryIdentity) => Promise<void>;
githubBroker?: { url: string; token: string };
githubCredentialBrokerVersion?: 1;
adoptIdentity?: DurableRecoveryIdentity;
}) {
const taskId = input.taskId;
@@ -132,11 +133,8 @@ export function createPluginRunnerTaskOwner(input: {
requirements.identity[key as keyof DurableRecoveryIdentity] !== value))) {
throw new Error("Plugin Runner source reconciliation unavailable");
}
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");
}
if (input.githubBroker && input.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 } : {}),