diff --git a/server/src/services/heartbeat/runtime-selection.test.ts b/server/src/services/heartbeat/runtime-selection.test.ts index 1da386ff3e9..51415004914 100644 --- a/server/src/services/heartbeat/runtime-selection.test.ts +++ b/server/src/services/heartbeat/runtime-selection.test.ts @@ -95,6 +95,53 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () = const coordinators = (id: string) => db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, id)); const sandbox = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: "/workspace", reusableLeaseConfigured: false }; + it("rejects invalid local Runner configuration before native authority and retains the preparation owner", async () => { + const f = await fixture(); + await claimPreparation(f); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-invalid-dot-retry-controller"); + f.input.runtimeEnv = { PAPERCLIP_RUNNER_BINARY: join(tmpdir(), "ignored-injected-runnerd") }; + const before = await read(f.run.id); + await expect(selectHeartbeatRuntime(db, f.input)).rejects.toThrow("PAPERCLIP_RUNNER_BINARY must be an absolute path"); + expect(await read(f.run.id)).toEqual(before); + expect(await coordinators(f.run.id)).toEqual([]); + expect(await db.select().from(completionContracts)).toHaveLength(0); + expect(f.input.stopControllerLease).not.toHaveBeenCalled(); + expect(buildNativeRuntimeContext).not.toHaveBeenCalled(); + }); + + it("ignores an injected runtime Runner value that the local backend does not consume", async () => { + const f = await fixture(); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", join(tmpdir(), "configuration-only-controller-runnerd")); + f.input.runtimeEnv = { PAPERCLIP_RUNNER_BINARY: "relative-ignored-injected-runnerd" }; + expect((await selectHeartbeatRuntime(db, f.input)).selected).toBe(true); + expect((await read(f.run.id)).runtimeMode).toBe("native"); + expect(await coordinators(f.run.id)).toHaveLength(1); + }); + + it("does not apply local Runner configuration to remote native selection", async () => { + const f = await fixture(); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-unused-remote-controller"); + f.input.workspace.executionTarget = sandbox; + expect((await selectHeartbeatRuntime(db, f.input)).selected).toBe(true); + expect((await read(f.run.id)).runtimeMode).toBe("native"); + expect(await coordinators(f.run.id)).toHaveLength(1); + }); + + it("preserves immutable native recovery when current local Runner configuration is invalid", async () => { + const f = await fixture(); + const selected = await selectHeartbeatRuntime(db, f.input); + if (!selected.selected || !selected.nativeExecution) throw new Error("Expected native selection"); + const saved = await read(f.run.id); + f.input.run = saved; + f.input.session.persistedRunnerProfile = saved.runnerProfileJson ?? {}; + f.input.session.persistedNativeExecutionInput = selected.nativeExecution; + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-unused-recovery-controller"); + const resumed = await selectHeartbeatRuntime(db, f.input); + expect(resumed).toMatchObject({ selected: true, nativeExecution: selected.nativeExecution, nativeRunnerInstanceId: selected.nativeRunnerInstanceId }); + expect((await read(f.run.id)).runnerProfileJson).toEqual(saved.runnerProfileJson); + expect(await coordinators(f.run.id)).toHaveLength(1); + }); + async function connectorFixture(durable = true) { const f = await fixture(), { input } = f; const home = await mkdtemp(join(tmpdir(), "selection-connector-assets-")); @@ -334,6 +381,7 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () = it.each([false, true])("persists the Cursor provider API placement and completion authority without a Computer lease (ephemeral: %s)", async ephemeral => { vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", String(ephemeral)); const { input, run, issue } = await fixture(); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-unused-api-controller"); input.agent = { ...input.agent, adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification" } }; await db.update(agents).set({ adapterConfig: input.agent.adapterConfig }).where(eq(agents.id, input.agent.id)); input.workspace.activeEnvironmentLease = null; diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 2cd2ab9bfd2..790563214aa 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -1,3 +1,4 @@ +import { assertPaperclipRunnerBinaryConfiguration } from "../native-runtime/native-codex-runner.js"; import { readNativeWorkspaceMigration, migratedNativeExecutionCwd } from "../native-runtime/native-workspace-migration.js"; import { resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js"; import { readClaimedNativeStopRecovery } from "../native-runtime/native-cancellation-request.js"; @@ -309,6 +310,13 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele if (!issueRef) { throw new Error("native_runtime_ineligible: issue is required"); } + if (run.runtimeMode !== "native" && agent.adapterType === "paperclip_runner" + && executionTarget?.kind !== "remote" + && nativeRuntimeResolution.profile.backend !== "cursor_cloud_api" + && nativeRuntimeResolution.profile.backend !== "claude_managed_agents_api" + && nativeRuntimeResolution.profile.backend !== "aws_agentcore_harness_api") { + assertPaperclipRunnerBinaryConfiguration(); + } const nativeExecutionWorkspaceId = persistedNativeExecutionInput?.provider.kind === "cursor_cloud" // Older Cursor runs keep their admitted binding identity. Their task diff --git a/server/src/services/native-runtime/native-codex-runner.test.ts b/server/src/services/native-runtime/native-codex-runner.test.ts index f5341c8a73e..c7a5a9ad02e 100644 --- a/server/src/services/native-runtime/native-codex-runner.test.ts +++ b/server/src/services/native-runtime/native-codex-runner.test.ts @@ -9,6 +9,7 @@ import { buildNativeRunnerArguments, buildNativeRunnerPreparePayload, resolvePaperclipRunnerBinary, + assertPaperclipRunnerBinaryConfiguration, resolveRemotePaperclipRunnerBinary, } from "./native-codex-runner.js"; @@ -225,3 +226,12 @@ describe("buildNativeRunnerPreparePayload", () => { }); }); }); + +it("validates only local Runner configuration without requiring or probing an artifact", () => { + expect(() => assertPaperclipRunnerBinaryConfiguration("relative-runnerd")).toThrow("PAPERCLIP_RUNNER_BINARY must be an absolute path"); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-controller-runnerd"); + expect(() => assertPaperclipRunnerBinaryConfiguration()).toThrow("PAPERCLIP_RUNNER_BINARY must be an absolute path"); + expect(() => resolvePaperclipRunnerBinary()).toThrow("PAPERCLIP_RUNNER_BINARY must be an absolute path"); + expect(() => assertPaperclipRunnerBinaryConfiguration("")).not.toThrow(); + expect(() => assertPaperclipRunnerBinaryConfiguration(join(tmpdir(), "not-present-configuration-only-runnerd"))).not.toThrow(); +}); diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index f8e22050f29..9e184e2a202 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -171,12 +171,22 @@ function executableName(): string { return process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; } -export function resolvePaperclipRunnerBinary( - configuredPath = process.env.PAPERCLIP_RUNNER_BINARY, -): string { +function controllerPaperclipRunnerBinaryConfiguration(): string | undefined { + return process.env.PAPERCLIP_RUNNER_BINARY; +} + +export function assertPaperclipRunnerBinaryConfiguration( + configuredPath = controllerPaperclipRunnerBinaryConfiguration(), +): void { if (configuredPath && !isAbsolute(configuredPath)) { throw new Error("PAPERCLIP_RUNNER_BINARY must be an absolute path"); } +} + +export function resolvePaperclipRunnerBinary( + configuredPath = controllerPaperclipRunnerBinaryConfiguration(), +): string { + assertPaperclipRunnerBinaryConfiguration(configuredPath); if (configuredPath) { try { accessSync(configuredPath, constants.R_OK | (process.platform === "win32" ? 0 : constants.X_OK));