From 387aa0077158d43e7de9072a245e7541687d489e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Zander?= <96420681+renezander030@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:21:04 +0000 Subject: [PATCH 1/2] fix(store): leave the CLI's own process chain out of the editor-open guard Under Volta the `capcut` command is a shim executable named `capcut` that starts node, so the guard matched the CLI's own parent as the CapCut editor and refused every --apply. The guard now lists pid and ppid with the process names, leaves out the CLI's own process and every ancestor of it (the direct parent on Windows, where tasklist has no parent ids), and still detects an editor running beside it. Fixes #168 --- src/store.ts | 65 ++++++++++++++++++++++++++++++---- test/editor-processes.test.mjs | 42 +++++++++++++++++++++- test/sync-timelines.test.mjs | 5 +-- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/src/store.ts b/src/store.ts index 8528eda..3031bc7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -809,17 +809,70 @@ export function serializeDraftCandidate( export function editorProcesses(): string[] { try { - if (platform() === "win32") { - const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf-8", timeout: 3000 }); - return matchEditorProcesses(result.stdout ?? "", "win32"); - } - const result = spawnSync("ps", ["-axo", "comm="], { encoding: "utf-8", timeout: 3000 }); - return matchEditorProcesses(result.stdout ?? "", "posix"); + const flavor = platform() === "win32" ? "win32" : "posix"; + const result = + flavor === "win32" + ? spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf-8", timeout: 3000 }) + : spawnSync("ps", ["-axo", "pid=,ppid=,comm="], { encoding: "utf-8", timeout: 3000 }); + const rows = parseProcessRows(result.stdout ?? "", flavor); + return matchEditorRows(rows, flavor, ownProcessChain(rows, process.pid, process.ppid)); } catch { return []; } } +/** One process of a listing: its id, its parent's id (0 when the listing has none) and its executable text. */ +export interface ProcessRow { + pid: number; + ppid: number; + line: string; +} + +/** + * Rows of `ps -axo pid=,ppid=,comm=` (posix) or `tasklist /FO CSV /NH` (win32, + * which lists no parent id). A line without ids is skipped. + */ +export function parseProcessRows(listing: string, flavor: "win32" | "posix"): ProcessRow[] { + const rows: ProcessRow[] = []; + for (const raw of listing.split("\n")) { + const line = raw.trim(); + if (flavor === "win32") { + const pid = Number(line.match(/^"[^"]*","(\d+)"/)?.[1]); + if (pid) rows.push({ pid, ppid: 0, line }); + continue; + } + const m = line.match(/^(\d+)\s+(\d+)\s+(.+)$/); + if (m) rows.push({ pid: Number(m[1]), ppid: Number(m[2]), line: m[3] }); + } + return rows; +} + +/** + * The CLI's own process and every ancestor the listing shows (#168): a Volta + * shim is an executable named `capcut` that starts node, so the CLI's own + * parent looks like the editor by name. The direct parent always counts, also + * where the listing carries no parent ids (win32). + */ +export function ownProcessChain(rows: ProcessRow[], pid: number, ppid: number): Set { + const parent = new Map(rows.map((r) => [r.pid, r.ppid])); + const chain = new Set([pid, ppid]); + let at = parent.get(ppid); + while (at && at > 1 && !chain.has(at)) { + chain.add(at); + at = parent.get(at); + } + return chain; +} + +/** The editors among a listing's rows, leaving out the CLI's own process chain. */ +export function matchEditorRows(rows: ProcessRow[], flavor: "win32" | "posix", own: Set): string[] { + const listing = rows + .filter((r) => !own.has(r.pid)) + .map((r) => r.line) + .join("\n"); + return matchEditorProcesses(listing, flavor); +} + /** * Match a raw process listing against the editor executables, one process per * line — never as a substring of the joined listing: npm rewrites its own diff --git a/test/editor-processes.test.mjs b/test/editor-processes.test.mjs index 76bbb43..513fff3 100644 --- a/test/editor-processes.test.mjs +++ b/test/editor-processes.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { matchEditorProcesses } from "../dist/store.js"; +import { matchEditorProcesses, matchEditorRows, ownProcessChain, parseProcessRows } from "../dist/store.js"; // #99: the editor-open guard must compare process names, not search the joined // listing — npm's rewritten process title contains `capcut-cli`, so a substring @@ -43,3 +43,43 @@ describe("editorProcesses matching", () => { assert.deepEqual(matchEditorProcesses("\n \n", "win32"), []); }); }); + +// #168: under Volta the `capcut` command is a shim executable named `capcut` +// that starts node, so the CLI's own parent matched the editor by name. +describe("editorProcesses leaves out the CLI's own process chain", () => { + const shim = [" 1 0 /sbin/launchd", " 400 1 -zsh", " 500 400 /Users/me/.volta/bin/capcut", " 501 500 node"]; + + it("ignores a Volta shim named capcut that is the CLI's parent (#168)", () => { + const rows = parseProcessRows(shim.join("\n"), "posix"); + assert.deepEqual(matchEditorRows(rows, "posix", ownProcessChain(rows, 501, 500)), []); + }); + + it("still detects an editor running beside the shim chain", () => { + const listing = [...shim, " 700 1 /Applications/CapCut.app/Contents/MacOS/CapCut"].join("\n"); + const rows = parseProcessRows(listing, "posix"); + assert.deepEqual(matchEditorRows(rows, "posix", ownProcessChain(rows, 501, 500)), ["CapCut"]); + }); + + it("walks the whole parent chain, not only the direct parent", () => { + const listing = [" 400 1 -zsh", " 450 400 capcut", " 460 450 npm exec capcut-cli", " 501 460 node"].join("\n"); + const rows = parseProcessRows(listing, "posix"); + assert.deepEqual([...ownProcessChain(rows, 501, 460)].sort((a, b) => a - b), [400, 450, 460, 501]); + assert.deepEqual(matchEditorRows(rows, "posix", ownProcessChain(rows, 501, 460)), []); + }); + + it("keeps a command name with spaces whole", () => { + const rows = parseProcessRows(" 812 1 CapCut Helper (Renderer)", "posix"); + assert.deepEqual(rows, [{ pid: 812, ppid: 1, line: "CapCut Helper (Renderer)" }]); + }); + + it("leaves out the direct parent on win32, where tasklist has no parent ids", () => { + const listing = [ + '"capcut.exe","900","Console","1","4,096 K"', + '"node.exe","901","Console","1","45,678 K"', + '"CapCut.exe","1044","Console","1","12,345 K"', + ].join("\r\n"); + const rows = parseProcessRows(listing, "win32"); + assert.deepEqual(matchEditorRows(rows.slice(0, 2), "win32", ownProcessChain(rows, 901, 900)), []); + assert.deepEqual(matchEditorRows(rows, "win32", ownProcessChain(rows, 901, 900)), ["CapCut.exe"]); + }); +}); diff --git a/test/sync-timelines.test.mjs b/test/sync-timelines.test.mjs index fc2ad3b..224f6a8 100644 --- a/test/sync-timelines.test.mjs +++ b/test/sync-timelines.test.mjs @@ -420,9 +420,10 @@ describe("sync-timelines", () => { // Fake a running editor where only this test sees it: a stand-in `ps` on // the child's PATH lists a CapCut process. A real process named CapCut // would be visible to every test running at the same time, and their - // writes would be refused as well. + // writes would be refused as well. It answers `ps -axo pid=,ppid=,comm=` + // with an editor that is not in the CLI's own process chain (#168). const fakeDir = mkdtempSync(join(tmpdir(), "fake-editor-")); - writeFileSync(join(fakeDir, "ps"), "#!/bin/sh\necho /Applications/CapCut.app/Contents/MacOS/CapCut\n"); + writeFileSync(join(fakeDir, "ps"), "#!/bin/sh\necho ' 4242 1 /Applications/CapCut.app/Contents/MacOS/CapCut'\n"); chmodSync(join(fakeDir, "ps"), 0o755); const env = { PATH: `${fakeDir}${delimiter}${process.env.PATH}` }; after(() => { From 58f5bad09409e1593a099f7ce8577ed4d4fb5fa1 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 6 Nov 2026 09:22:12 +0000 Subject: [PATCH 2/2] style: format the editor-guard tests --- test/editor-processes.test.mjs | 19 ++++++++++++++++--- test/sync-timelines.test.mjs | 5 ++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/test/editor-processes.test.mjs b/test/editor-processes.test.mjs index 513fff3..7d3690e 100644 --- a/test/editor-processes.test.mjs +++ b/test/editor-processes.test.mjs @@ -47,7 +47,12 @@ describe("editorProcesses matching", () => { // #168: under Volta the `capcut` command is a shim executable named `capcut` // that starts node, so the CLI's own parent matched the editor by name. describe("editorProcesses leaves out the CLI's own process chain", () => { - const shim = [" 1 0 /sbin/launchd", " 400 1 -zsh", " 500 400 /Users/me/.volta/bin/capcut", " 501 500 node"]; + const shim = [ + " 1 0 /sbin/launchd", + " 400 1 -zsh", + " 500 400 /Users/me/.volta/bin/capcut", + " 501 500 node", + ]; it("ignores a Volta shim named capcut that is the CLI's parent (#168)", () => { const rows = parseProcessRows(shim.join("\n"), "posix"); @@ -61,9 +66,17 @@ describe("editorProcesses leaves out the CLI's own process chain", () => { }); it("walks the whole parent chain, not only the direct parent", () => { - const listing = [" 400 1 -zsh", " 450 400 capcut", " 460 450 npm exec capcut-cli", " 501 460 node"].join("\n"); + const listing = [ + " 400 1 -zsh", + " 450 400 capcut", + " 460 450 npm exec capcut-cli", + " 501 460 node", + ].join("\n"); const rows = parseProcessRows(listing, "posix"); - assert.deepEqual([...ownProcessChain(rows, 501, 460)].sort((a, b) => a - b), [400, 450, 460, 501]); + assert.deepEqual( + [...ownProcessChain(rows, 501, 460)].sort((a, b) => a - b), + [400, 450, 460, 501], + ); assert.deepEqual(matchEditorRows(rows, "posix", ownProcessChain(rows, 501, 460)), []); }); diff --git a/test/sync-timelines.test.mjs b/test/sync-timelines.test.mjs index 224f6a8..015375c 100644 --- a/test/sync-timelines.test.mjs +++ b/test/sync-timelines.test.mjs @@ -423,7 +423,10 @@ describe("sync-timelines", () => { // writes would be refused as well. It answers `ps -axo pid=,ppid=,comm=` // with an editor that is not in the CLI's own process chain (#168). const fakeDir = mkdtempSync(join(tmpdir(), "fake-editor-")); - writeFileSync(join(fakeDir, "ps"), "#!/bin/sh\necho ' 4242 1 /Applications/CapCut.app/Contents/MacOS/CapCut'\n"); + writeFileSync( + join(fakeDir, "ps"), + "#!/bin/sh\necho ' 4242 1 /Applications/CapCut.app/Contents/MacOS/CapCut'\n", + ); chmodSync(join(fakeDir, "ps"), 0o755); const env = { PATH: `${fakeDir}${delimiter}${process.env.PATH}` }; after(() => {