diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index fbb618c57d2..8c7b8b945ee 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -1188,6 +1188,12 @@ describe("OpenCodeServerDriver", () => { expect( events.filter((event) => event.eventType === "item.delta"), ).toHaveLength(1); + // The session names the exact spawned `opencode serve`, so cleanup can prove it is gone. + expect(events.find((event) => event.eventType === "session.started")?.payload).toMatchObject({ + processId: spawns[0]!.pid, + processGroupId: spawns[0]!.processGroupId, + processStartedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }); expect( events.find((event) => event.eventType === "item.delta")?.payload.text, ).toBe("done [guide](guide.md)"); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 76f9fd97e1d..fa1451c4caa 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1,4 +1,5 @@ import { isOpenRouterUrl, OPENROUTER_ATTRIBUTION_HEADERS } from "../openrouter-attribution.js"; +import { readLocalProcessStartedAt } from "../../live/linux-process-start.js"; import { spawn, type ChildProcess } from "node:child_process"; import { resolveCompletionContinuation } from "../completion-continuation.js"; import { createHash, randomBytes } from "node:crypto"; @@ -231,6 +232,8 @@ interface OpenCodeRuntime { version: string; permissionMode: "allow" | "ask" | "deny"; process: ChildProcess; + /** OS identity of the spawned `opencode serve`, so cleanup can prove that exact process is gone. */ + processIdentity: { processId: number; processGroupId: number | null; processStartedAt: string | null } | null; bridge: OpenCodeMcpBridge; trace: ProviderTraceFileSink | null; sensitiveValues: readonly string[]; @@ -624,6 +627,7 @@ class OpenCodeHarnessSession implements HarnessSession { this.#emit(input.snapshot ? "session.resumed" : "session.started", { driverSessionId: input.providerSessionId, providerSessionId: input.providerSessionId, + ...(input.runtime.processIdentity ?? {}), context: { protocolVersion: "http+sse/v1", opencodeVersion: input.runtime.version, @@ -2726,6 +2730,11 @@ async function startRuntime(input: { providerChild.once("spawn", resolve); providerChild.once("error", reject); }); + const processIdentity = child.pid ? { + processId: child.pid, + processGroupId: globalThis.process.platform === "win32" || !isolateProcessGroup ? null : child.pid, + processStartedAt: readLocalProcessStartedAt(child.pid), + } : null; if (child.pid) await input.options.onSpawn?.({ pid: child.pid, @@ -2762,6 +2771,7 @@ async function startRuntime(input: { version, permissionMode: input.options.permissionMode ?? "allow", process: child, + processIdentity, bridge, trace: input.trace, sensitiveValues, diff --git a/packages/paperclip-runner/src/live/linux-process-start.ts b/packages/paperclip-runner/src/live/linux-process-start.ts index 66abe1322dc..ed46e1da3f6 100644 --- a/packages/paperclip-runner/src/live/linux-process-start.ts +++ b/packages/paperclip-runner/src/live/linux-process-start.ts @@ -38,3 +38,48 @@ export function readLinuxProcessStartedAt(pid: number, options: LinuxProcessStar } return new Date(value).toISOString(); } + +/** OS start time of a local process as an ISO string, or null when it cannot be read. */ +export function readLocalProcessStartedAt(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + if (process.platform === "linux") { + return readLinuxProcessStartedAt(pid); + } + if ( + ["darwin", "freebsd", "openbsd", "aix", "sunos"].includes( + process.platform, + ) + ) { + const raw = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + timeout: 1_500, + windowsHide: true, + }).trim(); + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } + if (process.platform === "win32") { + const script = [ + `$target = Get-Process -Id ${pid} -ErrorAction Stop`, + "$target.StartTime.ToUniversalTime().ToString('o')", + ].join("; "); + for (const command of ["powershell.exe", "pwsh.exe"]) { + try { + const raw = execFileSync( + command, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], + { encoding: "utf8", timeout: 1_500, windowsHide: true }, + ).trim(); + const parsed = new Date(raw); + if (!Number.isNaN(parsed.getTime())) return parsed.toISOString(); + } catch { + // Try the other supported PowerShell host. + } + } + } + } catch { + // Process exit and restricted process metadata both produce no fingerprint. + } + return null; +} diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index c5cdae6cb56..e5398ce79ff 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -25,7 +25,7 @@ import { waitForWarmAttachmentReadiness } from "./warm-attachment-readiness.js"; import { resolveCodexCommand } from "../drivers/codex/codex-command.js"; import { isCanonicalProviderEventType } from "../provider-events.js"; import { execFileSync } from "node:child_process"; -import { readLinuxProcessStartedAt } from "./linux-process-start.js"; +import { readLocalProcessStartedAt } from "./linux-process-start.js"; import { createHash, randomUUID } from "node:crypto"; import { appendFileSync, @@ -117,49 +117,6 @@ const RUNNER_BOOTSTRAP_TICKET_TTL_MS = 60_000; const RUNNERD_MAX_OUTBOX_BYTES = 16 * 1024 * 1024; const RUNNERD_P0_RESERVE_BYTES = 1024 * 1024; -function readLocalProcessStartedAt(pid: number): string | null { - if (!Number.isInteger(pid) || pid <= 0) return null; - try { - if (process.platform === "linux") { - return readLinuxProcessStartedAt(pid); - } - if ( - ["darwin", "freebsd", "openbsd", "aix", "sunos"].includes( - process.platform, - ) - ) { - const raw = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { - encoding: "utf8", - timeout: 1_500, - windowsHide: true, - }).trim(); - const parsed = new Date(raw); - return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); - } - if (process.platform === "win32") { - const script = [ - `$target = Get-Process -Id ${pid} -ErrorAction Stop`, - "$target.StartTime.ToUniversalTime().ToString('o')", - ].join("; "); - for (const command of ["powershell.exe", "pwsh.exe"]) { - try { - const raw = execFileSync( - command, - ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], - { encoding: "utf8", timeout: 1_500, windowsHide: true }, - ).trim(); - const parsed = new Date(raw); - if (!Number.isNaN(parsed.getTime())) return parsed.toISOString(); - } catch { - // Try the other supported PowerShell host. - } - } - } - } catch { - // Process exit and restricted process metadata both produce no fingerprint. - } - return null; -} const CONTROL_PLANE_STATE_MAX_BYTES = 256 * 1024 * 1024;