diff --git a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md index e632a50a1c3..b8c646ce8ba 100644 --- a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md +++ b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md @@ -170,6 +170,10 @@ supports authorized run attachment. Select the agent's warm lifecycle or set `runnerLifecycleMode: "warm"` and `runnerIdleTimeoutMs` on its plugin environment. Core requires an explicitly selected account. 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. +Project authorization is still checked for each new run. + A Launcher task owns one Runner process, independently of Core heartbeat runs. Successful warm run completion retains the task and its mounts. The reuse fingerprint includes the authorized project set; changing it retires the previous diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index e881b964bb6..6c48569a2d6 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -10378,14 +10378,16 @@ describe("runnerd provider runtime wiring", () => { await rm(isolatedStateDirectory, { recursive: true, force: true }); }); - it.each(["unchanged", "changed", "runtime-context", "retirement-failed", "stale-owner", "active-owner", "missing-owner", "foreign-environment", "plugin-replaced"] as const)("rotates an owned warm remote runner before checking changed configuration: %s", async scenario => { + it.each(["unchanged", "changed", "runtime-context", "retirement-failed", "stale-owner", "active-owner", "missing-owner", "foreign-environment", "plugin-replaced", "plugin-managed-github"] as const)("rotates an owned warm remote runner before checking changed configuration: %s", async scenario => { + const plugin = scenario === "plugin-replaced" || scenario === "plugin-managed-github"; + const reusable = scenario === "unchanged" || scenario === "plugin-managed-github"; const first = { ...scopedNativeExecution(`warm-admission-${scenario}`), schema: "paperclip.native-execution-input.v5" as const, provider: { kind: "codex" as const, model: null, approvalPolicy: "never" as const }, executionMode: "default" as const, planningContext: null, runtimeContext: nativeRuntimeContextFixture() }; first.session.lifecyclePolicy = { mode: "warm", idleTimeoutMs: 60_000 }; const next = { ...first, binding: { ...first.binding, runId: `${first.binding.runId}-next` }, - session: { ...first.session, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: ["unchanged", "runtime-context", "stale-owner", "missing-owner", "plugin-replaced"].includes(scenario) ? 60_000 : 90_000 } } }; + session: { ...first.session, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: ["unchanged", "runtime-context", "stale-owner", "missing-owner", "plugin-replaced", "plugin-managed-github"].includes(scenario) ? 60_000 : 90_000 } } }; if (scenario === "runtime-context") { next.runtimeContext = { ...first.runtimeContext, mcp: { ...first.runtimeContext.mcp, digest: "1".repeat(64) } }; @@ -10403,16 +10405,19 @@ describe("runnerd provider runtime wiring", () => { return {kind:"description",taskId:acquisition.providerLeaseId,launchPreparationVersion:1,projectAuthCheckVersion:1,warmTaskVersion:1, runner:{version:"fixture",digest:`sha256:${"a".repeat(64)}`}}; }); - const pluginTarget=scenario === "plugin-replaced" ? {...target,remoteCwd:".",leaseId:identity.environmentLeaseId, + const pluginTarget=plugin ? {...target,remoteCwd:".",leaseId:identity.environmentLeaseId, runnerTask:task,sandboxLeaseAcquisition:acquisition} : target; const home=join(isolatedStateDirectory,"warm-plugin-home"); - if(scenario === "plugin-replaced") { + if(plugin) { await mkdir(home,{recursive:true});await writeFile(join(home,"auth.json"),JSON.stringify({OPENAI_API_KEY:"synthetic-fixture"}),{mode:0o600}); } - const pluginOptions=scenario === "plugin-replaced" ? {runnerIngressAuthorized:true,managedAiCredentialHome:home, + const pluginOptions=plugin ? {runnerIngressAuthorized:true,managedGitHub:scenario === "plugin-managed-github",managedAiCredentialHome:home, managedAiCredentialIdentity:"revision",managedAiAccountIdentity:"account"} : {}; const resume=scenario === "plugin-replaced" ? vi.spyOn(environmentTaskRuntime,"resolvePluginTaskResume").mockResolvedValue("task-first") : undefined; - const owners=scenario === "plugin-replaced" ? vi.spyOn(pluginTaskRuntime,"createPluginRunnerTaskOwner") : undefined; + const createOwner = pluginTaskRuntime.createPluginRunnerTaskOwner; + const owners=plugin ? vi.spyOn(pluginTaskRuntime,"createPluginRunnerTaskOwner") : undefined; + const rebind = vi.fn(); + if (scenario === "plugin-managed-github") owners!.mockImplementation(input => ({...createOwner(input),rebind})); const root = await initializeScopedRunnerRoot(first, identity.runnerInstanceId); await writeRunnerAuthority(root, identity, "ready"); @@ -10433,8 +10438,8 @@ describe("runnerd provider runtime wiring", () => { return result; }).mockImplementationOnce(async options => { order.push("execute-next"); - expect(options.existingSession).toBe(scenario === "unchanged" ? firstSession : undefined); - await options.onSession?.(scenario === "unchanged" ? firstSession : nextSession); + expect(options.existingSession).toBe(reusable ? firstSession : undefined); + await options.onSession?.(reusable ? firstSession : nextSession); return result; }); const older = { ...first, binding: { ...first.binding, runId: `${first.binding.runId}-older` } }; @@ -10458,7 +10463,7 @@ describe("runnerd provider runtime wiring", () => { const continuation = executePaperclipNativeSession({ db: nextDb, execution: next, runnerInstanceId: `runner-next-${scenario}`, useRunnerd: true, runnerExecutionTarget: (scenario === "foreign-environment" ? { ...target, environmentId: `${environmentId}-other` } - : scenario === "plugin-replaced" ? {...pluginTarget,leaseId:"next-core-lease"} : target) as never,...pluginOptions }); + : plugin ? {...pluginTarget,leaseId:"next-core-lease"} : target) as never,...pluginOptions }); if (["stale-owner", "active-owner", "missing-owner", "foreign-environment"].includes(scenario)) { await expect(continuation).rejects.toThrow(scenario === "active-owner" ? "prior_owner_active" : "prior_owner_terminal_state_indeterminate"); @@ -10476,7 +10481,8 @@ describe("runnerd provider runtime wiring", () => { expect(owners!.mock.calls.at(-1)![0]).toMatchObject({resumeFrom:"task-first"}); expect(owners!.mock.calls.at(-1)![0].adoptIdentity).toBeUndefined(); } - expect(order).toEqual(scenario === "unchanged" ? ["execute-next"] : ["retire", "execute-next"]); + if (scenario === "plugin-managed-github") expect(rebind).toHaveBeenCalledOnce(); + expect(order).toEqual(reusable ? ["execute-next"] : ["retire", "execute-next"]); if (scenario === "unchanged") { await writeRunnerAuthority(root, { ...identity, runId: next.binding.runId }, "ready"); const third = { ...next, binding: { ...next.binding, runId: `${next.binding.runId}-third` } }; @@ -10493,7 +10499,7 @@ describe("runnerd provider runtime wiring", () => { }); await expect(executePaperclipNativeSession({ db: thirdDb, execution: third, runnerInstanceId: `runner-third-${scenario}`, useRunnerd: true, - runnerExecutionTarget: target as never })).resolves.toMatchObject({ exitCode: 0 }); + runnerExecutionTarget: pluginTarget as never,...pluginOptions })).resolves.toMatchObject({ exitCode: 0 }); expect(firstSession.close).not.toHaveBeenCalled(); expect(state.execute).toHaveBeenCalledTimes(3); } diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index c0446b8385d..024074bc6f1 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -6156,8 +6156,13 @@ function persistWarmNativeCheckpoint( chmodSync(path, 0o600); } +function needsCoreGitHubAccess(input: Pick[0], "managedGitHub" | "runnerExecutionTarget">): boolean { + const target = input.runnerExecutionTarget; + return Boolean(input.managedGitHub && !(target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask)); +} + function warmNativeProcessConfigDigest(input: Parameters[0]): string | undefined { - if (input.managedGitHub || input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN || input.instructionWorkingCopy) return undefined; + if (needsCoreGitHubAccess(input) || input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN || input.instructionWorkingCopy) return undefined; return nativeSha256({ environment: configuredEnvironment(input.runnerEnvironment), managedAiCredentialIdentity: input.managedAiCredentialIdentity ?? null, @@ -8681,7 +8686,7 @@ async function executePaperclipNativeSessionWithinScope( entry.instructionRoot !== input.instructionWorkingCopy?.root || entry.managedAiCredentialIdentity !== input.managedAiCredentialIdentity || credentialRunChanged || - Boolean(entry.githubAccess) !== Boolean(input.managedGitHub) || + Boolean(entry.githubAccess) !== needsCoreGitHubAccess(input) || entry.githubAccess?.ready === false || (entry.githubAccess !== undefined && entry.githubAccess.duplexRequested !== adapterExecutionTargetEnablesSandboxDuplexBridge(input.runnerExecutionTarget)) || entry.githubAuthenticationMode !== @@ -8849,7 +8854,7 @@ async function executePaperclipNativeSessionWithinScope( try { const usesPluginTask = input.runnerExecutionTarget?.kind === "remote" && input.runnerExecutionTarget.transport === "sandbox" && !!input.runnerExecutionTarget.runnerTask; const prepareRunnerEnvironment = async () => { - if (!usesPluginTask && input.managedGitHub && input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8") { + if (needsCoreGitHubAccess(input) && input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8") { if (!input.execution.workspace.cwd) throw new Error("native_workspace_required"); githubAccess ??= await timeController("github_access", () => createNativeGitHubAccess({ scope: input.execution.binding, @@ -8864,7 +8869,7 @@ async function executePaperclipNativeSessionWithinScope( } return input.runnerEnvironment; }; - const overlapGitHubPreparation = !usesPluginTask && input.managedGitHub && input.useRunnerd && input.backend === undefined + const overlapGitHubPreparation = needsCoreGitHubAccess(input) && input.useRunnerd && input.backend === undefined && input.runnerExecutionTarget?.kind === "remote" && input.execution.provider.kind === "codex" && input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8";