fix(lifecycle): validate owned Hopper process identity

This commit is contained in:
morluto committed 2026-07-13 03:14:36 +00:00
1 parent f8e8ea35ff
commit 898d6b0192
3 files changed
+86 -10

No files matched your search

+32
View File
@@ -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<Record<string, string>>;
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} `)
);
};
+18 -10
View File
@@ -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,
+36
View File
@@ -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();
});
});