diff --git a/server/src/__tests__/local-service-supervisor.test.ts b/server/src/__tests__/local-service-supervisor.test.ts index 45748eac2f..99d49e4c1d 100644 --- a/server/src/__tests__/local-service-supervisor.test.ts +++ b/server/src/__tests__/local-service-supervisor.test.ts @@ -11,6 +11,7 @@ import { } from "../services/workspace-runtime.js"; import { doesLocalServiceCommandLineMatch, + isLocalServiceCommandLineComparable, listLocalServiceRegistryRecords, readLocalServicePortOwner, resolveLocalServiceLogPath, @@ -137,6 +138,15 @@ describe("local service supervision", () => { })).toBe(true); }); + it("does not compare shell expressions with the surviving process argv", () => { + expect(isLocalServiceCommandLineComparable( + "env | sort > /tmp/service.env; exec pnpm dev --bind loopback", + )).toBe(false); + expect(isLocalServiceCommandLineComparable( + "node -e \"process.stdout.write('left | right')\"", + )).toBe(true); + }); + it("does not accept a different command merely because it uses node", () => { expect(doesLocalServiceCommandLineMatch({ commandLine: "/usr/bin/node /workspace/server/dist/index.js", diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 6582b320ad..4b59464fd2 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -7434,7 +7434,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { await expect(fetch(service!.url!)).rejects.toThrow(); }); - it("re-adopts a desired service when pnpm is represented as the pnpm.cjs launcher", async () => { + it("re-adopts a live service whose shell command differs from the surviving process argv", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-pnpm-reconcile-")); const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-")); const previousPaperclipHome = process.env.PAPERCLIP_HOME; @@ -7455,7 +7455,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { const projectId = randomUUID(); const executionWorkspaceId = randomUUID(); const runtimeServiceId = randomUUID(); - const command = "pnpm dev"; + const command = "env | sort > /tmp/guest-$$.env; exec pnpm dev --bind loopback"; const service = { name: "web", command, diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 7e591a1125..51c329a816 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -311,6 +311,46 @@ function normalizeCommandToken(value: string) { return /^(?:bun|node|nodejs|npm|npx|pnpm|yarn)$/i.test(launcher) ? launcher : unquoted; } +/** + * Return whether the configured shell command has a stable argv that can be + * compared with the operating system's process command line. + * + * Managed local services are started through `shell -lc`. Once a command uses + * shell control syntax, the surviving process-group leader can be the result of + * that program rather than the configured shell expression. In that case a + * literal argv comparison is not evidence that the process belongs to a + * different service; adoption instead relies on the listener, process group, + * and workspace cwd checks. + */ +export function isLocalServiceCommandLineComparable(recordedCommand: string) { + let quote: "'" | '"' | null = null; + let escaped = false; + + for (const character of recordedCommand) { + if (escaped) { + escaped = false; + continue; + } + if (character === "\\" && quote !== "'") { + escaped = true; + continue; + } + if (quote) { + if (character === quote) quote = null; + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if ([";", "|", "&", "<", ">", "\n"].includes(character)) { + return false; + } + } + + return true; +} + /** * Compare a configured service command with the argv exposed by the OS. * @@ -346,6 +386,7 @@ export function doesLocalServiceCommandLineMatch(input: { async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { if (process.platform === "win32") return true; + if (!isLocalServiceCommandLineComparable(record.command)) return true; try { const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]); const commandLine = stdout.trim(); @@ -624,6 +665,9 @@ async function doesLocalServiceRecordMatchCwd(record: LocalServiceRegistryRecord if (!record.port) return true; const ownerPid = await readLocalServicePortOwner(record.port); if (!ownerPid) return false; + if (!(await isLocalServiceProcessOwnedBy(ownerPid, record.processGroupId ?? record.pid))) { + return false; + } const ownerCwd = await readLocalServiceProcessCwd(ownerPid); return isLocalServiceRegistryCwdCompatible(ownerCwd, record.cwd); }