From c772081310dd66209063d6e37412325fbf00bc29 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 09:36:54 -0500 Subject: [PATCH] fix(runtime): retain anonymous computer launchers across warm turns --- server/src/services/heartbeat-github-launchers.test.ts | 8 +++++--- server/src/services/heartbeat-github-launchers.ts | 6 +++--- .../native-runtime/native-session-executor.test.ts | 10 +++++++++- .../services/native-runtime/native-session-executor.ts | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/server/src/services/heartbeat-github-launchers.test.ts b/server/src/services/heartbeat-github-launchers.test.ts index 5bb4038bacb..3d5fd005259 100644 --- a/server/src/services/heartbeat-github-launchers.test.ts +++ b/server/src/services/heartbeat-github-launchers.test.ts @@ -8,10 +8,11 @@ import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/com import { describe, expect, it, vi } from "vitest"; import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js"; +const computerTarget = { kind: "remote", transport: "computer", remoteCwd: "/workspace" } as Parameters[0]["target"]; const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" }; describe("heartbeat GitHub launcher lifetime", () => { - it.each([target, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => { + it.each([target, computerTarget, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => { const prepare = vi.fn(); const mint = vi.fn(); const result = await prepareHeartbeatGitHubLaunchers({ @@ -23,7 +24,7 @@ describe("heartbeat GitHub launcher lifetime", () => { expect(result.cleanupLocation).toBeNull(); expect(result.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "" }); }); - it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => { + it.each([target, computerTarget])("keeps anonymous native launchers stable without issuing a run capability (%j)", async (target) => { const createBrokerToken = vi.fn(() => "run-secret"); const prepareLaunchers = vi.fn(async (input) => input.env); const inputs = { native: true, githubConfigured: false, agentId: "agent-a", target, @@ -50,7 +51,7 @@ describe("heartbeat GitHub launcher lifetime", () => { expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target }); }); - it("does not remove shared anonymous wrappers when staging a later run fails", async () => { + it.each([target, computerTarget])("does not remove shared anonymous wrappers when staging a later run fails (%j)", async (target) => { const stagingError = new Error("remote launcher staging failed"); const cleanupLaunchers = vi.fn(async () => undefined); await expect(prepareHeartbeatGitHubLaunchers({ @@ -64,6 +65,7 @@ describe("heartbeat GitHub launcher lifetime", () => { it.each([ { native: false, githubConfigured: true, target }, { native: false, githubConfigured: false, target }, + { native: false, githubConfigured: false, target: computerTarget }, { native: true, githubConfigured: false, target: null }, ])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => { const createBrokerToken = vi.fn(() => "current-run-secret"); diff --git a/server/src/services/heartbeat-github-launchers.ts b/server/src/services/heartbeat-github-launchers.ts index 83786a48eb2..5740b330b31 100644 --- a/server/src/services/heartbeat-github-launchers.ts +++ b/server/src/services/heartbeat-github-launchers.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher"; -import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target"; +import { adapterExecutionTargetIsCommandBacked, cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target"; type LauncherInput = Parameters[0]; @@ -21,12 +21,12 @@ export async function prepareHeartbeatGitHubLaunchers( if (input.native && input.githubConfigured) { return { env: githubBrokerEnvironment(input.env, { url: "", token: "" }), cleanupLocation: null }; } - // An unconfigured sandbox has no managed GitHub identity to broker. Its + // An unconfigured command-backed environment has no managed GitHub identity to broker. Its // token-free wrappers still isolate image credentials, but may live as long // as the workspace so a warm provider never inherits a deleted run path. // Other adapter paths retain their run-scoped capability/retirement rules. const anonymous = input.native && !input.githubConfigured && - input.target?.kind === "remote" && input.target.transport === "sandbox"; + adapterExecutionTargetIsCommandBacked(input.target); const location = { runId: anonymous ? `anonymous-${input.agentId}-${createHash("sha256").update(input.env.PATH ?? "").digest("hex").slice(0, 16)}` 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 cdc18095718..8cde5b2021b 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1,3 +1,4 @@ +import { prepareHeartbeatGitHubLaunchers } from "../heartbeat-github-launchers.js"; import { gunzipSync } from "node:zlib"; import { configuredEnvironmentProjection } from "../../vendor/paperclip-runner/index.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -7609,9 +7610,15 @@ describe("native warm session supervision", () => { expect(options.existingSession).toBe(session); await options.onSession?.(session); return result; }); + const environmentForRun = async (runId: string) => (await prepareHeartbeatGitHubLaunchers({ + native: true, githubConfigured: false, agentId: name, runId, target: target as never, + cwd: target.remoteCwd, env: {}, brokerUrl: "http://127.0.0.1:3104", + createBrokerToken: () => { throw new Error("Anonymous computer must not mint per-run authority"); }, + }, async (input) => input.env)).env; vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { - await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, runnerExecutionTarget: target as never }); + await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, runnerExecutionTarget: target as never, + runnerEnvironment: await environmentForRun(current.binding.runId) }); generation = 2; // The durable admission committed before local reservation. await vi.advanceTimersByTimeAsync(60_000); expect(retire).toHaveBeenCalledOnce(); @@ -7620,6 +7627,7 @@ describe("native warm session supervision", () => { const next = { ...current, binding: { ...current.binding, runId: `${name}-two` } }; const nextRetire = vi.fn(async () => true); await executePaperclipNativeSession({ db: leaseDb(next), execution: next, runnerInstanceId: name, + runnerEnvironment: await environmentForRun(next.binding.runId), runnerExecutionTarget: { ...target, resourceAuthority: { ...target.resourceAuthority, generation: 2 }, retire: nextRetire } as never }); await closeWarmNativeSessionsForRun({ runId: next.binding.runId, reason: "fixture cleanup" }); expect(nextRetire).toHaveBeenCalledOnce(); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 91eb8c7b970..0a301c88514 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -12612,7 +12612,7 @@ async function createRunnerdBackendWithinSessionClaim( timeoutMs: 10_000, }); if (created.exitCode !== 0 || created.timedOut) { - throw new Error(`runner_remote_directory_staging_failed: exit=${result.exitCode} timedOut=${result.timedOut}${result.stderr.trim() ? ` ${redactSensitiveText(result.stderr).trim().slice(-512)}` : ""}`); + throw new Error(`runner_remote_directory_staging_failed: exit=${created.exitCode} timedOut=${created.timedOut}${created.stderr.trim() ? ` ${redactSensitiveText(created.stderr).trim().slice(-512)}` : ""}`); } // Codex launch credentials are intentionally excluded from failover