From b8637fb3f4493fb44a448f74f0fd6a43c8249f82 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 12 Aug 2026 15:06:20 +0000 Subject: [PATCH] fix(runtime): verify Windows port ownership Co-Authored-By: Paperclip --- .../src/__tests__/workspace-runtime.test.ts | 30 +++++++++++ .../src/services/local-service-supervisor.ts | 51 ++++++++++++++++++- server/src/services/workspace-runtime.ts | 21 ++------ 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index a21945dcff1..f25377efba8 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -49,6 +49,7 @@ import { import { findAdoptableLocalService, isLocalServiceRegistryCwdCompatible, + isLocalServiceProcessOwnedBy, isLocalServiceProcessInWorkspace, readLocalServicePortOwner, writeLocalServiceRegistryRecord, @@ -4718,6 +4719,35 @@ describe("readLocalServicePortOwner", () => { } }); + it("attributes a Windows listener to a descendant of the launched process", async () => { + const fakeBin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-windows-tools-")); + const previousPath = process.env.PATH; + const port = 43_123; + const listenerPid = 43_210; + const launchedPid = 32_000; + await fs.writeFile( + path.join(fakeBin, "netstat"), + `#!/bin/sh\nprintf ' TCP 127.0.0.1:${port} 0.0.0.0:0 LISTENING ${listenerPid}\\n'\n`, + { mode: 0o755 }, + ); + await fs.writeFile( + path.join(fakeBin, "powershell.exe"), + `#!/bin/sh\nprintf '${launchedPid}\\n'\n`, + { mode: 0o755 }, + ); + process.env.PATH = `${fakeBin}${path.delimiter}${previousPath ?? ""}`; + Object.defineProperty(process, "platform", { value: "win32" }); + + try { + await expect(readLocalServicePortOwner(port)).resolves.toBe(listenerPid); + await expect(isLocalServiceProcessOwnedBy(listenerPid, launchedPid)).resolves.toBe(true); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await fs.rm(fakeBin, { recursive: true, force: true }); + } + }); + it("accepts service cwd nested within the requested workspace", async () => { const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-")); const serviceCwd = path.join(workspace, "server"); diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 95936c653a8..257eee74d53 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -296,6 +296,39 @@ export async function readLocalServiceProcessGroupId(pid: number) { } } +export async function isLocalServiceProcessOwnedBy(pid: number, ownerProcessId: number) { + if (pid === ownerProcessId) return true; + if (process.platform !== "win32") { + return (await readLocalServiceProcessGroupId(pid)) === ownerProcessId; + } + + try { + const script = [ + `$currentProcessId = ${pid}`, + "while ($currentProcessId -gt 0) {", + " $process = Get-CimInstance Win32_Process -Filter \"ProcessId = $currentProcessId\" -ErrorAction SilentlyContinue", + " if ($null -eq $process) { break }", + " $parentProcessId = [int]$process.ParentProcessId", + " Write-Output $parentProcessId", + " if ($parentProcessId -eq $currentProcessId) { break }", + " $currentProcessId = $parentProcessId", + "}", + ].join("\n"); + const { stdout } = await execFileAsync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + return stdout + .split(/\r?\n/) + .map((line) => Number.parseInt(line.trim(), 10)) + .some((ancestorPid) => ancestorPid === ownerProcessId); + } catch { + return false; + } +} + async function adoptLocalServiceFromPortOwner(input: { serviceKey: string; profileKind?: string | null; @@ -405,8 +438,24 @@ export async function terminateLocalService( } export async function readLocalServicePortOwner(port: number) { - if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null; + if (!Number.isInteger(port) || port <= 0) return null; try { + if (process.platform === "win32") { + const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"]); + for (const line of stdout.split(/\r?\n/)) { + const columns = line.trim().split(/\s+/); + if (columns.length < 5 || columns[0]?.toUpperCase() !== "TCP") continue; + const localAddress = columns[1] ?? ""; + const separatorIndex = localAddress.lastIndexOf(":"); + const localPort = Number.parseInt(localAddress.slice(separatorIndex + 1), 10); + const state = columns.at(-2)?.toUpperCase(); + const pid = Number.parseInt(columns.at(-1) ?? "", 10); + if (localPort === port && state === "LISTENING" && Number.isInteger(pid) && pid > 0) { + return pid; + } + } + return null; + } const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]); const firstPid = stdout .split("\n") diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index b0c5cc0994d..82e41b41b98 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -28,9 +28,9 @@ import { createLocalServiceKey, findLocalServiceRegistryRecordByRuntimeServiceId, findAdoptableLocalService, + isLocalServiceProcessOwnedBy, isLocalServiceProcessInWorkspace, readLocalServiceProcessCwd, - readLocalServiceProcessGroupId, readLocalServicePortOwner, removeLocalServiceRegistryRecord, terminateLocalService, @@ -4008,10 +4008,7 @@ async function waitForAllocatedPortBind(input: { const ownerPid = await readLocalServicePortOwner(input.port); if (ownerPid) { const childPid = input.child.pid ?? null; - const ownerProcessGroupId = ownerPid === childPid - ? childPid - : await readLocalServiceProcessGroupId(ownerPid); - if (!childPid || ownerProcessGroupId !== childPid) { + if (!childPid || !(await isLocalServiceProcessOwnedBy(ownerPid, childPid))) { throw new RuntimeServicePortBindCollision(input.port); } // Require the same launched process group to retain ownership across a stability delay. @@ -4021,12 +4018,7 @@ async function waitForAllocatedPortBind(input: { throw new Error("service process exited after losing its allocated port"); } const stableOwnerPid = await readLocalServicePortOwner(input.port); - const stableOwnerProcessGroupId = stableOwnerPid === childPid - ? childPid - : stableOwnerPid - ? await readLocalServiceProcessGroupId(stableOwnerPid) - : null; - if (stableOwnerProcessGroupId !== childPid) { + if (!stableOwnerPid || !(await isLocalServiceProcessOwnedBy(stableOwnerPid, childPid))) { throw new RuntimeServicePortBindCollision(input.port); } return; @@ -4041,12 +4033,7 @@ async function waitForAllocatedPortBind(input: { } const stableOwnerPid = await readLocalServicePortOwner(input.port); const childPid = input.child.pid ?? null; - const stableOwnerProcessGroupId = stableOwnerPid === childPid - ? childPid - : stableOwnerPid - ? await readLocalServiceProcessGroupId(stableOwnerPid) - : null; - if (childPid && stableOwnerProcessGroupId === childPid) return; + if (stableOwnerPid && childPid && await isLocalServiceProcessOwnedBy(stableOwnerPid, childPid)) return; throw new RuntimeServicePortBindCollision(input.port); } await delay(50);