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. 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[] { 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 ||