diff --git a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md index a22d20e2160..b3d50a724c3 100644 --- a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md +++ b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md @@ -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. diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index c18d8c7941c..fcc30286453 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -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"); diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index f39a8f25803..130d58638c2 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -103,6 +103,7 @@ export async function assertManagedAiProjectAuth( config: Record, 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" }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0ad306b27a5..aa92629d756 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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) { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index e6f6e4e729c..7fbcab9816e 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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, diff --git a/server/src/services/native-runtime/plugin-runner-probe.ts b/server/src/services/native-runtime/plugin-runner-probe.ts index f337c8348b6..4a9a87a773a 100644 --- a/server/src/services/native-runtime/plugin-runner-probe.ts +++ b/server/src/services/native-runtime/plugin-runner-probe.ts @@ -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; diff --git a/server/src/services/native-runtime/plugin-runner-task.test.ts b/server/src/services/native-runtime/plugin-runner-task.test.ts index 004a1e8ab60..75c980061e1 100644 --- a/server/src/services/native-runtime/plugin-runner-task.test.ts +++ b/server/src/services/native-runtime/plugin-runner-task.test.ts @@ -33,8 +33,6 @@ async function prepare(owner: ReturnType, cu function provider() { let stopped = false; const task = vi.fn(async (operation: any): Promise => { - 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)))( diff --git a/server/src/services/native-runtime/plugin-runner-task.ts b/server/src/services/native-runtime/plugin-runner-task.ts index f294e3d5f45..573c0e276f0 100644 --- a/server/src/services/native-runtime/plugin-runner-task.ts +++ b/server/src/services/native-runtime/plugin-runner-task.ts @@ -53,6 +53,7 @@ export function createPluginRunnerTaskOwner(input: { onResumed?: (identity: DurableRecoveryIdentity) => Promise; onRetired?: (identity: DurableRecoveryIdentity) => Promise; 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 } : {}),