From 66028796f39cca64936610ce3b8c55b834fc7ab7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 12:22:22 -0500 Subject: [PATCH 1/4] fix: explain detached dev servers on persistent computers Co-Authored-By: Paperclip --- .../src/live/runnerd-codex-transport.test.ts | 26 +++++++++++++++++++ .../src/live/runnerd-codex-transport.ts | 11 +++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index debd0c6e3fe..99c26734f8e 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -2158,6 +2158,32 @@ it.each(["installed", "explicit"] as const)("records the selected %s Codex comma } }); +it.each([false, true])("binds detached-process guidance only for a trusted persistent computer (%s)", async (computer) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-computer-process-guidance-")); + const runnerBinary = join(root, "runnerd"); + await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 }); + let instructions = ""; + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", + codexCommand: "/qualified/codex", persistentAgentHome: computer ? "/agent/home" : undefined, + controlPlaneRegistration: async (authority) => { + instructions = String((authority.store.state.runAttachTemplate!.provider as { instructions: string }).instructions); + throw new Error("fixture_stop_before_runner_launch"); + }, + runnerProcessLauncher: () => { throw new Error("must not launch"); }, + }); + try { + await expect(transport.request("thread/start", { cwd: root, model: "gpt-6.1-sol", dynamicTools: [], + developerInstructions: "Preserve the task instructions." })).rejects.toThrow("fixture_stop_before_runner_launch"); + expect(instructions).toContain("Preserve the task instructions."); + expect(instructions.includes("nohup setsid")).toBe(computer); + if (computer) { + expect(instructions).toContain("stop when its warm timeout expires"); + expect(instructions).toContain("Do not create services or change runner ownership"); + } + } finally { await transport.close(); await rm(root, { recursive: true, force: true }); } +}); + it("rejects remote Codex without a guest executable before resolving controller dependencies", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-codex-command-")); const runnerBinary = join(root, "runnerd"); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index ae90a54fb41..14894434b80 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3443,6 +3443,11 @@ export function trustedRuntimeReadOnlyRoots( return [...roots]; } +function withComputerProcessInstructions(instructions: string, persistentAgentHome?: string): string { + if (!persistentAgentHome) return instructions; + return `${instructions}\n\nComputer runtime: files in AGENT_HOME persist across turns. A dev server needed between warm turns must survive the shell tool's process-group cleanup: use a detached session, for example \`nohup setsid dev-server.log 2>&1 &\`, and verify its listener from a separate command after the launching command returns. Detached processes still belong to this runner and stop when its warm timeout expires. Do not create services or change runner ownership to keep them alive.`; +} + export function createRunnerdCodexAppServerArgs(input: { environment: NodeJS.ProcessEnv | undefined; codexHome: string; @@ -4826,9 +4831,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const includeCodexCollaborationInstructions = provider === "codex" && record(params.config).include_collaboration_mode_instructions !== false; - const unboundBaseInstructions = String( + const unboundBaseInstructions = withComputerProcessInstructions(String( params.developerInstructions ?? params.baseInstructions ?? "You are a Paperclip agent.", - ); + ), this.options.persistentAgentHome); const baseInstructions = sourceRuntimeContext && runtimeContext ? unboundBaseInstructions.replaceAll( @@ -5445,7 +5450,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { runtimeContext, this.options.baseInstructions === undefined ? undefined - : { text: this.options.baseInstructions, context: sourceRuntimeContext }, + : { text: withComputerProcessInstructions(this.options.baseInstructions, this.options.persistentAgentHome), context: sourceRuntimeContext }, ); if (provider === "codex") { // These controller-owned, token-free paths belong to the new run. From cfa2b81539a6340c4cf8d5223ad60a30bef7432f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 12:27:30 -0500 Subject: [PATCH 2/4] fix(computers): allocate listeners outside guest ephemeral ports Co-Authored-By: Paperclip --- .../modules/computers/adapters/boat.test.ts | 18 ++++++++- server/src/modules/computers/adapters/boat.ts | 23 +++++++++++ .../modules/computers/application/ports.ts | 2 + .../computers/application/service.test.ts | 38 +++++++++++++++++++ .../modules/computers/application/service.ts | 15 +++++++- server/src/modules/computers/domain/ledger.ts | 20 +++++++++- 6 files changed, 112 insertions(+), 4 deletions(-) diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index fc08211e16b..8ad2fee0f96 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { remoteProgram } from "./remote-program.js"; import { afterEach, describe, it, expect, vi } from "vitest"; -import { boatBackend, createBoatTransportAdmission, desktopReadinessProgram, processProgram } from "./boat.js"; +import { boatBackend, createBoatTransportAdmission, desktopReadinessProgram, processProgram, runnerPortInventoryProgram } from "./boat.js"; import { buildGitAuthInvocation } from "../../../services/git-credentials.js"; import type { ComputerRecord } from "../domain/ledger.js"; const sshFactory = vi.hoisted(() => vi.fn()); @@ -44,6 +44,22 @@ const json = (value: unknown, status = 200, headers?: Record) => headers: { "content-type": "application/json", ...headers }, }); describe("Boat transport validation", () => { + it("reads outgoing and listening TCP ports across IPv4 and IPv6", () => { + const root = mkdtempSync(join(tmpdir(), "boat-port-inventory-")); + try { + writeFileSync(join(root, "range"), "32768 60999\n"); + writeFileSync(join(root, "tcp"), "header\n0: 00000000:3F00 00000000:0000 0A\n"); + writeFileSync(join(root, "tcp6"), "header\n0: 00000000000000000000000000000000:A87A 00000000:01BB 08\n"); + const program = runnerPortInventoryProgram + .replace("/proc/sys/net/ipv4/ip_local_port_range", join(root, "range")) + .replace("/proc/net/tcp6", join(root, "tcp6")) + .replace("/proc/net/tcp", join(root, "tcp")); + const result = spawnSync("python3", ["-c", program], { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ ephemeralStart: 32768, ephemeralEnd: 60999, occupied: [16128, 43130] }); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + it("recognizes idle as an already running computer", async () => { const fetcher = vi.fn(async () => json({ diff --git a/server/src/modules/computers/adapters/boat.ts b/server/src/modules/computers/adapters/boat.ts index 030fde7af36..c578dc17b81 100644 --- a/server/src/modules/computers/adapters/boat.ts +++ b/server/src/modules/computers/adapters/boat.ts @@ -475,6 +475,15 @@ print('{}') }); } }, + async runnerPorts(record) { + const value = await execute(record, runnerPortInventoryProgram, {}); + if (!Number.isInteger(value.ephemeralStart) || !Number.isInteger(value.ephemeralEnd) + || value.ephemeralStart < 1 || value.ephemeralEnd > 65535 + || value.ephemeralStart > value.ephemeralEnd || !Array.isArray(value.occupied) + || value.occupied.some((port: unknown) => !Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535)) + throw new ComputerError("provider_error", "Computer returned invalid listener port availability"); + return value; + }, async advance(record, owner) { await execute(record, processProgram, { action: "advance", owner }); }, @@ -564,3 +573,17 @@ print('{}') }, }; } + +/** Read-only Linux socket inventory; all states matter, not only LISTEN. */ +export const runnerPortInventoryProgram = String.raw` +import json +with open('/proc/sys/net/ipv4/ip_local_port_range') as source: + start,end=map(int,source.read().split()) +occupied=set() +for path in ['/proc/net/tcp','/proc/net/tcp6']: + with open(path) as source: + for line in source.readlines()[1:]: + port=int(line.split()[1].rsplit(':',1)[1],16) + if port:occupied.add(port) +print(json.dumps({'ephemeralStart':start,'ephemeralEnd':end,'occupied':sorted(occupied)})) +`; diff --git a/server/src/modules/computers/application/ports.ts b/server/src/modules/computers/application/ports.ts index c8c7d478dcb..b04c386eada 100644 --- a/server/src/modules/computers/application/ports.ts +++ b/server/src/modules/computers/application/ports.ts @@ -4,6 +4,7 @@ import type { Ledger, Owner, ProcessClaim, + RunnerPortAvailability, } from "../domain/ledger.js"; export interface ComputerRepository { create(record: ComputerRecord): Promise; @@ -31,6 +32,7 @@ export interface ComputerBackend { ready(record: ComputerRecord): Promise; claim(record: ComputerRecord): Promise; advance(record: ComputerRecord, owner: Owner): Promise; + runnerPorts(record: ComputerRecord): Promise; runner(record: ComputerRecord, options?: { control?: boolean }): Promise; launch( record: ComputerRecord, diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index fe44159f256..69106e41edd 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -50,6 +50,7 @@ function fixture() { }), claim: vi.fn(async () => {}), advance: vi.fn(async () => {}), + runnerPorts: vi.fn(async () => ({ ephemeralStart: 32768, ephemeralEnd: 60999, occupied: [] })), runner: vi.fn(async () => ({ execute: vi.fn() })), launch: vi.fn(async (_record, owner) => ({ ...owner.process!, @@ -114,6 +115,43 @@ function fixture() { }; } describe("computer ownership", () => { + it("allocates outside guest ephemeral and occupied ports while reserving live owners", async () => { + const f = fixture(); + await f.attach(); + vi.mocked(f.backend.runnerPorts).mockResolvedValue({ + ephemeralStart: 16127, ephemeralEnd: 16128, occupied: [16129, 43130], + }); + const first = await f.admit(); + const second = await f.admit("other-agent", "other-session"); + expect(first.listenerPort).toBe(16130); + expect(second.listenerPort).toBe(16131); + expect(f.backend.advance).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ port: 16131 })); + for (const port of [first.listenerPort, 43130]) { + await expect(f.service.preview({ ...f.scope, owner: first.owner, port })).rejects.toMatchObject({ code: "forbidden" }); + } + }); + + it("fails closed when the guest reserves the entire fixed listener range", async () => { + const f = fixture(); + await f.attach(); + vi.mocked(f.backend.runnerPorts).mockResolvedValue({ ephemeralStart: 1024, ephemeralEnd: 65535, occupied: [] }); + await expect(f.admit()).rejects.toMatchObject({ code: "conflict", message: "Computer has no available runner ports" }); + expect(f.backend.advance).not.toHaveBeenCalled(); + }); + + it("preserves historical warm listener identity without reallocating an occupied own port", async () => { + const f = fixture(); + await f.attach(); + const first = await f.admit(); + await first.launch({ command: "runnerd" }); + await f.repository.update(f.scope, record => { record.ledger.owners.find(owner => owner.id === first.owner.ownerId)!.port = 43130; }); + await f.service.retainWarm({ ...f.scope, owner: first.owner, idleTimeoutMs: 60_000 }); + vi.mocked(f.backend.runnerPorts).mockClear(); + const next = await f.admit(); + expect(next.listenerPort).toBe(43130); + expect(f.backend.runnerPorts).not.toHaveBeenCalled(); + }); + it("passes checkout credentials transiently without retaining them in computer state", async () => { const f = fixture(); await f.attach(); diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index d5c5548e73f..54450301cc7 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -7,6 +7,7 @@ import { expired, liveOwners, nextPort, + isRunnerPort, segment, timeout, type ComputerRecord, @@ -455,6 +456,18 @@ finally: }); await backend.ready(result.record); await backend.claim(result.record); + if (result.owner.generation === 1 && !result.owner.process) { + const availability = await backend.runnerPorts(result.record); + const allocated = await repository.update(input, (record) => { + const owner = exactOwner(record, ref(result.record, result.owner)); + if (owner.phase !== "starting") + throw new ComputerError("conflict", "Computer admission was retired"); + owner.port = nextPort(record.ledger.owners.filter((other) => other.id !== owner.id), availability); + return { record: structuredClone(record), owner: structuredClone(owner) }; + }); + result.record = allocated.record; + result.owner = allocated.owner; + } await backend.advance(result.record, result.owner); await backend.renew(result.record); if (input.probeId) { @@ -661,7 +674,7 @@ finally: expired(owner, now()) ) throw new ComputerError("conflict", "Preview owner is not live"); - if (input.port >= 43127 && input.port < 44127) + if (isRunnerPort(input.port)) throw new ComputerError( "forbidden", "Runner ports are not application previews", diff --git a/server/src/modules/computers/domain/ledger.ts b/server/src/modules/computers/domain/ledger.ts index c433e576b81..2ef59796be3 100644 --- a/server/src/modules/computers/domain/ledger.ts +++ b/server/src/modules/computers/domain/ledger.ts @@ -87,12 +87,28 @@ export function exactOwner(record: ComputerRecord, ref: OwnerRef): Owner { throw new ComputerError("conflict", "Computer owner has been superseded"); return owner; } -export function nextPort(owners: Owner[]): number { +export interface RunnerPortAvailability { + ephemeralStart: number; + ephemeralEnd: number; + occupied: number[]; +} +// Keep fixed listeners below the usual Linux ephemeral range, and still check +// the actual guest policy: custom images can configure a different range. +export const RUNNER_PORT_START = 16127; +export const RUNNER_PORT_END = 17127; +export function isRunnerPort(port: number): boolean { + return (port >= RUNNER_PORT_START && port < RUNNER_PORT_END) + || (port >= 43127 && port < 44127); // Existing warm owners retain their port. +} +export function nextPort(owners: Owner[], availability?: RunnerPortAvailability): number { const occupied = new Set( owners.filter((o) => o.phase !== "retired").map((o) => o.port), ); - for (let port = 43127; port < 44127; port++) + for (const port of availability?.occupied ?? []) occupied.add(port); + for (let port = RUNNER_PORT_START; port < RUNNER_PORT_END; port++) { + if (availability && port >= availability.ephemeralStart && port <= availability.ephemeralEnd) continue; if (!occupied.has(port)) return port; + } throw new ComputerError("conflict", "Computer has no available runner ports"); } export function liveOwners(ledger: Ledger): Owner[] { From ce107d4710dfa89b9a7307f4ee9bf3af50889793 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 12:27:28 -0500 Subject: [PATCH 3/4] fix: validate persisted warm origin run identity Co-Authored-By: Paperclip --- .../services/native-runtime/native-session-executor.test.ts | 3 ++- server/src/services/native-runtime/native-session-executor.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 f0c8933da59..6aad086d79d 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -13459,6 +13459,7 @@ describe("runnerd provider runtime wiring", () => { { condition: "workspace-coordinate", expected: "verified" }, { condition: "warm-origin", expected: "verified" }, { condition: "origin-other-session", expected: "scope_mismatch" }, + { condition: "origin-wrong-profile-run", expected: "scope_mismatch" }, { condition: "origin-other-owner", expected: "scope_mismatch" }, { condition: "origin-newer-generation", expected: "scope_mismatch" }, { condition: "origin-active", expected: "scope_mismatch" }, @@ -13487,7 +13488,7 @@ describe("runnerd provider runtime wiring", () => { const originDescriptor = { ...descriptor, leaseId: "origin-lease", providerLeaseId: condition === "origin-other-owner" ? "other-owner" : owner.ownerId, ownerGeneration: condition === "origin-newer-generation" ? 3 : 1 }; - const originRow = { status: condition === "origin-active" ? "running" : "succeeded", + const originRow = { id: condition === "origin-wrong-profile-run" ? "other-run" : originExecution.binding.runId, status: condition === "origin-active" ? "running" : "succeeded", runnerProfileJson: { nativeExecutionInput: originExecution, nativeComputerWorkspace: originDescriptor } }; const workspaceCoordinate = ["workspace-coordinate", "wrong-workspace-run"].includes(condition); const db = { select: () => ({ from: (table: unknown) => ({ where: (query: SQL) => { if (table === environmentLeases) leaseQuery = query; return ({ limit: async (limit: number) => diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 55ee4680f13..77ba1f719dd 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -4397,6 +4397,7 @@ export async function verifyPriorRunnerdStateForSessionScope(input: { // workspace coordinate. Resolve that recorded origin, never an arbitrary // run ID, before checking the physical lease and latest retirement. const origins = await input.db.select({ + id: heartbeatRuns.id, status: heartbeatRuns.status, runnerProfileJson: heartbeatRuns.runnerProfileJson, }).from(heartbeatRuns).where(and( @@ -4412,6 +4413,7 @@ export async function verifyPriorRunnerdStateForSessionScope(input: { const originExecution = parseNativeExecutionInput(originProfile.nativeExecutionInput); const originWorkspace = readNativeComputerWorkspaceReference(originProfile.nativeComputerWorkspace); if (!TERMINAL_HEARTBEAT_RUN_STATUSES.has(origin.status) || + originExecution.binding.runId !== origin.id || originExecution.binding.executionWorkspaceId !== input.identity.environmentLeaseId || nativeSessionScopeKey(originExecution) !== nativeSessionScopeKey(priorExecution) || !originWorkspace || originWorkspace.remoteCwd !== priorWorkspace.remoteCwd || From 571fde776c88234feeb16c20302da71b855c0ace Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 12:27:56 -0500 Subject: [PATCH 4/4] test: keep dev-server guidance acceptance in stacked PR Co-Authored-By: Paperclip --- .../src/live/runnerd-codex-transport.test.ts | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 99c26734f8e..debd0c6e3fe 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -2158,32 +2158,6 @@ it.each(["installed", "explicit"] as const)("records the selected %s Codex comma } }); -it.each([false, true])("binds detached-process guidance only for a trusted persistent computer (%s)", async (computer) => { - const root = await mkdtemp(join(tmpdir(), "paperclip-computer-process-guidance-")); - const runnerBinary = join(root, "runnerd"); - await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 }); - let instructions = ""; - const { transport } = createCapabilityRunnerdCodexTransport({ - provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", - codexCommand: "/qualified/codex", persistentAgentHome: computer ? "/agent/home" : undefined, - controlPlaneRegistration: async (authority) => { - instructions = String((authority.store.state.runAttachTemplate!.provider as { instructions: string }).instructions); - throw new Error("fixture_stop_before_runner_launch"); - }, - runnerProcessLauncher: () => { throw new Error("must not launch"); }, - }); - try { - await expect(transport.request("thread/start", { cwd: root, model: "gpt-6.1-sol", dynamicTools: [], - developerInstructions: "Preserve the task instructions." })).rejects.toThrow("fixture_stop_before_runner_launch"); - expect(instructions).toContain("Preserve the task instructions."); - expect(instructions.includes("nohup setsid")).toBe(computer); - if (computer) { - expect(instructions).toContain("stop when its warm timeout expires"); - expect(instructions).toContain("Do not create services or change runner ownership"); - } - } finally { await transport.close(); await rm(root, { recursive: true, force: true }); } -}); - it("rejects remote Codex without a guest executable before resolving controller dependencies", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-codex-command-")); const runnerBinary = join(root, "runnerd");