From 898d6b0192126030cedd7e5c2cb76f40568cda05 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:14:36 +0000 Subject: [PATCH] fix(lifecycle): validate owned Hopper process identity --- src/application/ProcessOwnership.ts | 32 +++++++++++++++++++++++++ src/hopper/BridgeLauncher.ts | 28 ++++++++++++++-------- tests/processOwnership.test.ts | 36 +++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/application/ProcessOwnership.ts b/src/application/ProcessOwnership.ts index dbd96fea0c..b1de0c3c6d 100644 --- a/src/application/ProcessOwnership.ts +++ b/src/application/ProcessOwnership.ts @@ -8,6 +8,10 @@ export interface OwnedProcessGroup { readonly runId: string; readonly leaderPid: number; readonly processGroupId: number; + /** Expected launcher identity, checked only while the leader exists. */ + readonly expectedCommand?: string; + /** Expected launcher parent, checked only while the leader exists. */ + readonly expectedParentPid?: number; } interface ProcessGroupMember { @@ -86,6 +90,25 @@ export const cleanupOwnedProcessGroup = async ( return { cleaned: false, reason: "process group could not be inspected" }; } if (members.length === 0) return { cleaned: true, signaled: false }; + const leader = members.find((member) => member.pid === ownership.leaderPid); + if (leader !== undefined) { + if ( + ownership.expectedParentPid !== undefined && + leader.parentPid !== ownership.expectedParentPid + ) + return { + cleaned: false, + reason: "owned launcher parent identity did not match", + }; + if ( + ownership.expectedCommand !== undefined && + !commandMatches(leader.command, ownership.expectedCommand) + ) + return { + cleaned: false, + reason: "owned launcher command identity did not match", + }; + } for (const member of members) { let environment: Readonly>; try { @@ -113,3 +136,12 @@ export const cleanupOwnedProcessGroup = async ( } return { cleaned: true, signaled: true }; }; + +const commandMatches = (actual: string, expected: string): boolean => { + const normalizedActual = actual.trim(); + const normalizedExpected = expected.trim(); + return ( + normalizedActual === normalizedExpected || + normalizedActual.startsWith(`${normalizedExpected} `) + ); +}; diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 2f7d1f448b..7ad63b7963 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -141,22 +141,18 @@ export class HopperApplicationLauncher implements BridgeLauncher { { encoding: "utf8", mode: 0o600 }, ); } catch (cause: unknown) { - await cleanupOwnedProcessGroup({ - runId: session.runId, - leaderPid: pid, - processGroupId: pid, - }); + await cleanupOwnedProcessGroup( + ownedProcessGroup(session, pid, this.options.launcherPath), + ); return err(new HopperStartError({ cause })); } return ok({ process: child, ownsProcessLifetime: true, cleanup: () => - cleanupOwnedProcessGroup({ - runId: session.runId, - leaderPid: pid, - processGroupId: pid, - }), + cleanupOwnedProcessGroup( + ownedProcessGroup(session, pid, this.options.launcherPath), + ), }); } catch (cause: unknown) { return err(new HopperStartError({ cause })); @@ -164,6 +160,18 @@ export class HopperApplicationLauncher implements BridgeLauncher { } } +const ownedProcessGroup = ( + session: BridgeSession, + pid: number, + launcherPath: string, +) => ({ + runId: session.runId, + leaderPid: pid, + processGroupId: pid, + expectedCommand: launcherPath, + expectedParentPid: process.pid, +}); + const prepareHopperApplication = async ( launcherPath: string, signal?: AbortSignal, diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts index 04e2abfdb2..d98927a360 100644 --- a/tests/processOwnership.test.ts +++ b/tests/processOwnership.test.ts @@ -67,4 +67,40 @@ describe("owned process-group cleanup", () => { signaled: false, }); }); + + it("fails closed when the launcher command identity changes", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + }); + expect( + await cleanupOwnedProcessGroup( + { + ...ownership, + expectedCommand: "/owned/hopper", + expectedParentPid: 1, + }, + adapter, + ), + ).toEqual({ + cleaned: false, + reason: "owned launcher command identity did not match", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); + + it("fails closed when the launcher parent identity changes", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + }); + expect( + await cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture", expectedParentPid: 999 }, + adapter, + ), + ).toEqual({ + cleaned: false, + reason: "owned launcher parent identity did not match", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); });