diff --git a/src/process/ProcessCommandIdentity.ts b/src/process/ProcessCommandIdentity.ts new file mode 100644 index 0000000000..3757f88a65 --- /dev/null +++ b/src/process/ProcessCommandIdentity.ts @@ -0,0 +1,24 @@ +import { basename } from "node:path"; + +/** Match process-table command text while handling macOS executable-name normalization. */ +export const matchesOwnedProcessCommand = ( + actual: string, + expected: string, + platform: NodeJS.Platform = process.platform, +): boolean => { + const actualCommand = actual.trim(); + const expectedCommand = expected.trim(); + if ( + actualCommand === expectedCommand || + actualCommand.startsWith(`${expectedCommand} `) + ) + return true; + if (platform !== "darwin") return false; + const actualExecutable = actualCommand.split(/\s+/u, 1)[0]; + const expectedExecutable = expectedCommand.split(/\s+/u, 1)[0]; + return ( + actualExecutable !== undefined && + expectedExecutable !== undefined && + basename(actualExecutable) === basename(expectedExecutable) + ); +}; diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index d6f9e7bf66..bdaff58b4d 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -2,6 +2,8 @@ import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { promisify } from "node:util"; +import { matchesOwnedProcessCommand } from "./ProcessCommandIdentity.js"; + const execFileAsync = promisify(execFile); /** Identity proof required before REA may signal an owned process group. */ @@ -318,7 +320,7 @@ const launcherIdentityFailure = ( return "owned launcher parent identity did not match"; if ( ownership.expectedCommand !== undefined && - !commandMatches(launcher.command, ownership.expectedCommand) + !matchesOwnedProcessCommand(launcher.command, ownership.expectedCommand) ) return "owned launcher command identity did not match"; return null; @@ -471,7 +473,7 @@ export const observeOwnedProcessLineage = async ( ); if ( ownership.expectedCommand !== undefined && - !commandMatches(launcher.command, ownership.expectedCommand) + !matchesOwnedProcessCommand(launcher.command, ownership.expectedCommand) ) return unavailableLineage( ownership, @@ -553,10 +555,3 @@ const liveProcesses = ( members: readonly ProcessTableEntry[], ): readonly ProcessTableEntry[] => members.filter(({ state }) => !state.startsWith("Z")); - -const commandMatches = (actual: string, expected: string): boolean => { - return ( - actual.trim() === expected.trim() || - actual.trim().startsWith(`${expected.trim()} `) - ); -}; diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts index 6a7f716ba0..d3bf6d6558 100644 --- a/tests/processOwnership.test.ts +++ b/tests/processOwnership.test.ts @@ -9,6 +9,7 @@ import { type ProcessOwnershipHost, type WindowsProcessTreeHost, } from "../src/process/ProcessOwnership.js"; +import { matchesOwnedProcessCommand } from "../src/process/ProcessCommandIdentity.js"; const ownership = { runId: "run-token", @@ -61,6 +62,30 @@ describe("owned process-group cleanup", () => { ).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" }); }); + it("matches macOS executable names after process-table normalization", () => { + expect( + matchesOwnedProcessCommand( + "node /tmp/fake-launcher.mjs", + "/opt/node/bin/node", + "darwin", + ), + ).toBe(true); + expect( + matchesOwnedProcessCommand( + "node /tmp/fake-launcher.mjs", + "/opt/python/bin/python3", + "darwin", + ), + ).toBe(false); + expect( + matchesOwnedProcessCommand( + "node /tmp/fake-launcher.mjs", + "/opt/node/bin/node", + "linux", + ), + ).toBe(false); + }); + it("signals only a group whose every member carries the run token", async () => { const { adapter, signalGroup } = host({ 100: { REA_PROCESS_RUN_ID: "run-token" },