From 8f44c1370b8f8cb0456e16f971fd321b1d8435de Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 1 Nov 2026 06:42:54 -0600 Subject: [PATCH] fix(server): check ephemeral runtime after native selection Fresh runs retain their initial legacy label during workspace preparation. Check remote storage authority there and validate the resolved native warm lifecycle in runtime selection. Reject actual legacy execution before admission. Reproduced both boundaries before the fix. All 156 focused tests, workspace typecheck and build pass. A real Codex/Daytona fixture completed two native runs; full overlap qualification continues separately. Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 9 +++++++++ .../src/__tests__/ephemeral-server-contract.test.ts | 5 +++-- .../src/services/heartbeat/runtime-selection.test.ts | 11 +++++++++++ server/src/services/heartbeat/runtime-selection.ts | 1 + .../services/heartbeat/workspace-preparation.test.ts | 10 +++++++--- .../src/services/heartbeat/workspace-preparation.ts | 2 +- server/src/services/remote-workspaces.ts | 8 +++++--- 7 files changed, 37 insertions(+), 9 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c7dc8f46975..9f9a070363c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -4174,6 +4174,15 @@ capabilities, stable keys, operating limits, automatic history migration and qua Do not remove a web volume until its complete retained evidence inventory and the independent disposable-server requirements are satisfied. +## Volume-free execution admission + +With `PAPERCLIP_EPHEMERAL_SERVER=true`, workspace preparation requires Paperclip +Runner and remote workspace storage. A fresh run still has its initial `legacy` +label at this phase; that label is not the selected execution mode. The runtime +selector checks the resolved native warm lifecycle before admission and rejects +legacy or per-turn execution. External conversation providers keep their existing +exemption because they own no task filesystem. + ## Cloud startup configuration Cloud stores nonsecret startup choices in a private PostgreSQL singleton. diff --git a/server/src/__tests__/ephemeral-server-contract.test.ts b/server/src/__tests__/ephemeral-server-contract.test.ts index 72c319fac84..bfa5e4acae8 100644 --- a/server/src/__tests__/ephemeral-server-contract.test.ts +++ b/server/src/__tests__/ephemeral-server-contract.test.ts @@ -26,14 +26,15 @@ it("never connects the listener with migration privileges, another tenant, or th }); it("rejects disk-dependent execution and instructions while retaining remote warm execution", () => { vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", "true"); - const remote = { externalConversation: false, native: true, adapterType: "paperclip_runner", pluginTaskWorkspace: false, remoteAuthority: true }; + const remote = { externalConversation: false, adapterType: "paperclip_runner", pluginTaskWorkspace: false, remoteAuthority: true }; expect(() => assertEphemeralWorkspaceExecution(remote)).not.toThrow(); expect(() => assertEphemeralWorkspaceExecution({ ...remote, remoteAuthority: false, pluginTaskWorkspace: true })).not.toThrow(); - for (const patch of [{ native: false }, { remoteAuthority: false }, { adapterType: "process" }]) { + for (const patch of [{ remoteAuthority: false }, { adapterType: "process" }]) { expect(() => assertEphemeralWorkspaceExecution({ ...remote, ...patch })).toThrow("warm Paperclip Runner"); } expect(() => assertEphemeralNativeLifecycle("warm")).not.toThrow(); expect(() => assertEphemeralNativeLifecycle("per_turn")).toThrow("warm Paperclip Runner"); + expect(() => assertEphemeralNativeLifecycle(undefined)).toThrow("warm Paperclip Runner"); const agent = { id: "agent", companyId: "company", name: "Agent", adapterConfig: {} }; expect(() => assertEphemeralAgentInstructions(agent)).not.toThrow(); expect(() => assertEphemeralAgentInstructions({ ...agent, adapterConfig: { instructionsBundleMode: "external", instructionsRootPath: "/old-volume" } })).toThrow("managed agent instructions"); diff --git a/server/src/services/heartbeat/runtime-selection.test.ts b/server/src/services/heartbeat/runtime-selection.test.ts index 7b777887f85..f5d4603d050 100644 --- a/server/src/services/heartbeat/runtime-selection.test.ts +++ b/server/src/services/heartbeat/runtime-selection.test.ts @@ -328,6 +328,17 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () = expect(await coordinators(run.id)).toHaveLength(0); }); + it("rejects resolved legacy execution on an ephemeral server before runtime admission", async () => { + const { input, run } = await fixture("process"); + vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", "true"); + expect(input.nativeRuntimeResolution.kind).toBe("legacy"); + await expect(selectHeartbeatRuntime(db, input)).rejects.toThrow("warm Paperclip Runner"); + expect((await read(run.id)).runtimeMode).toBe("legacy"); + expect(await coordinators(run.id)).toHaveLength(0); + expect(input.onNativeLifecycleSelected).not.toHaveBeenCalled(); + expect(prepareNativeWorkspaceSync).not.toHaveBeenCalled(); + }); + it.each(["warm", "per_turn"] as const)("checks resolved %s policy for ephemeral admission", async mode => { const { input, run } = await fixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", "true"); diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 4f10a467302..49d96cf762b 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -267,6 +267,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele const { runtimeConfig, runtimeSkillEntries, runScopedMentionedSkillKeys, instructionCopy, agentIdentity, issueAssigneeOverrides, resolvedConfig, managedAiRuntime } = input.config; const runOptions = input.recovery; const { providerTraceRequested, providerTraceCapture } = input.trace; + if (nativeRuntimeResolution.kind !== "native" && !isDotRun) assertEphemeralNativeLifecycle(undefined); let nativeExecution: NativeExecutionInput | null = null; let getNativeFreshSessionHandoff: (() => Promise) | undefined; let nativeRunnerInstanceId: string | null = null; diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 5aa66c1d9cd..08077429437 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -170,14 +170,15 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace }); it.each([ - { kind: "empty", repoUrl: null, branchMode: null, fresh: false }, + { kind: "empty", repoUrl: null, branchMode: null, fresh: false, ephemeral: true }, { kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: null, fresh: false }, { kind: "git", repoUrl: "ssh://git@github.com/example/remote.git", branchMode: null, fresh: false }, { kind: "git", repoUrl: "git@github.com:example/remote.git", branchMode: null, fresh: false }, { kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "existing", fresh: false }, { kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: false }, { kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: true }, - ] as const)("reserves a $kind remote workspace ($repoUrl, branch: $branchMode, fresh: $fresh) without materializing a controller checkout", async ({ kind, repoUrl, branchMode, fresh }) => { + ] as const)("reserves a $kind remote workspace ($repoUrl, branch: $branchMode, fresh: $fresh) without materializing a controller checkout", async (scenario) => { + const { kind, repoUrl, branchMode, fresh } = scenario; const { input, source, issue, envOrchestrator } = await fixture(); const [savedEnvironment] = await db.insert(environments).values({ name: `Remote ${randomUUID()}`, driver: "sandbox", status: "active", config: { provider: "daytona" } }).returning(); @@ -227,10 +228,13 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace throw new Error("reached remote acquisition"); }); const configured = { DATABASE_URL: database.connectionString, PAPERCLIP_STORAGE_PROVIDER: "s3", - PAPERCLIP_SECRETS_MASTER_KEY: "remote-workspace-preparation-fixture" }; + PAPERCLIP_SECRETS_MASTER_KEY: "remote-workspace-preparation-fixture", + PAPERCLIP_EPHEMERAL_SERVER: "ephemeral" in scenario ? "true" : "false" }; const previous = new Map(Object.keys(configured).map(key => [key, process.env[key]])); Object.assign(process.env, configured); try { + // Fresh runs are still marked legacy until the runtime selector owns promotion. + expect(input.run.runtimeMode).toBe("legacy"); await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("reached remote acquisition"); expect(resolve).not.toHaveBeenCalled(); expect(repositoryPreparation).not.toHaveBeenCalled(); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 15c91735f71..665a070e835 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -241,7 +241,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace && !projectExecutionWorkspacePolicy?.storageProviderPluginId, existingWorkspace: reusableExistingExecutionWorkspace ?? existingExecutionWorkspace, }); - assertEphemeralWorkspaceExecution({ externalConversation: isDotRun, native: run.runtimeMode === "native", + assertEphemeralWorkspaceExecution({ externalConversation: isDotRun, adapterType: agent.adapterType, pluginTaskWorkspace, remoteAuthority }); const assertRemotePreparationOwned = run.runtimeMode === "native" ? () => assertNativeSessionPreparationAuthority({ diff --git a/server/src/services/remote-workspaces.ts b/server/src/services/remote-workspaces.ts index 61776878ae0..37c41fd330e 100644 --- a/server/src/services/remote-workspaces.ts +++ b/server/src/services/remote-workspaces.ts @@ -76,11 +76,13 @@ export function shouldUseRemoteWorkspaces(input: { * remote controller may replace its local projection; host-owned execution * cannot make that promise. External conversation providers own no task disk. */ export function assertEphemeralWorkspaceExecution(input: { - externalConversation: boolean; native: boolean; adapterType: string; + externalConversation: boolean; adapterType: string; pluginTaskWorkspace: boolean; remoteAuthority: boolean; }) { if (process.env.PAPERCLIP_EPHEMERAL_SERVER !== "true" || input.externalConversation) return; - if (!input.native || input.adapterType !== "paperclip_runner" + // Runtime selection has not promoted a fresh run yet. Validate storage here; + // the runtime selector separately admits only its resolved warm native policy. + if (input.adapterType !== "paperclip_runner" || (!input.pluginTaskWorkspace && !input.remoteAuthority)) { throw unprocessable("Volume-free servers require warm Paperclip Runner execution with remote workspace storage.", { code: "ephemeral_execution_configuration_required" }); @@ -89,7 +91,7 @@ export function assertEphemeralWorkspaceExecution(input: { /** The runtime selector calls this after environment, credentials and saved * execution policy have resolved. Raw agent settings are not lifecycle authority. */ -export function assertEphemeralNativeLifecycle(mode: "warm" | "per_turn") { +export function assertEphemeralNativeLifecycle(mode: "warm" | "per_turn" | undefined) { if (process.env.PAPERCLIP_EPHEMERAL_SERVER === "true" && mode !== "warm") { throw unprocessable("Volume-free servers require warm Paperclip Runner execution with remote workspace storage.", { code: "ephemeral_execution_configuration_required" });