fix(setup)!: bind managed registrations to selected installation

Use one command owner for setup, client writers, Gemini policy and CLI defaults.
Migrate recognized runner registrations with backups and remove runner prewarm.
Retain unknown identities and exercise real offline npm, CLI and MCP workflows.

Fixes #7757

BREAKING CHANGE: managed MCP registrations use the selected absolute runtime
and REA entry script, including package-runner cache paths. Keep that installation
available or restore it before setup; unknown registrations require confirmed
manual repair. Setup no longer returns prewarm or emits its progress events.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-28 11:34:28 +00:00
1 parent 18a8e71c9f
commit c77735fa83
56 files changed
+768 -1346

No files matched your search

+30 -19
View File
@@ -1,6 +1,9 @@
import { accessSync, constants, realpathSync } from "node:fs";
import { isAbsolute } from "node:path";
import { normalizeEntrypointPath } from "../process/CurrentEntrypointPath.js";
import {
CURRENT_ENTRYPOINT_PATH,
normalizeEntrypointPath,
} from "../process/CurrentEntrypointPath.js";
import { valid } from "semver";
import {
@@ -10,8 +13,8 @@ import {
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { PRODUCT_IDENTITY } from "../identity.js";
/** Package runners whose pinned launcher setup can register. */
export type PackageRunner = "npx" | "bunx";
/** Package runners recognized for invocation guidance and legacy registrations. */
type PackageRunner = "npx" | "bunx";
/** Read one variable; Windows variable names are case-insensitive. */
const environmentValue = (
@@ -57,24 +60,32 @@ export const withoutPackageRunnerIdentity = (
),
);
/** Resolve a pinned package-runner launcher that stdio clients can spawn on the host. */
export const packageRunnerRegistrationCommand = (
runner: PackageRunner,
platform: NodeJS.Platform = process.platform,
/** Bind the selected REA entrypoint to the runtime already executing setup. */
export const currentClientRegistrationCommand = (
selection: {
readonly platform?: NodeJS.Platform;
readonly entrypointPath?: string | undefined;
readonly environment?: Readonly<NodeJS.ProcessEnv>;
} = {},
): readonly string[] => {
const command =
runner === "npx"
? ["npx", "-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]
: ["bunx", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"];
// Both runners may be .cmd shims on Windows, which clients cannot spawn directly.
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
const platform = selection.platform ?? process.platform;
const environment = selection.environment ?? process.env;
const entrypointPath = Object.hasOwn(selection, "entrypointPath")
? selection.entrypointPath
: CURRENT_ENTRYPOINT_PATH;
if (entrypointPath === undefined || entrypointPath.length === 0) return [];
return [
registrationRuntimePath(
process.execPath,
javaScriptRuntime(process.versions).id,
environment,
platform,
),
registrationEntrypointPath(entrypointPath, environment, platform),
"mcp",
];
};
/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */
export const npxRegistrationCommand = (
platform: NodeJS.Platform = process.platform,
): readonly string[] => packageRunnerRegistrationCommand("npx", platform);
const unwrapCmdCommand = (command: readonly string[]): readonly string[] => {
const executable = command[0]?.toLowerCase();
if (executable !== "cmd" && executable !== "cmd.exe") return command;
@@ -170,7 +181,7 @@ const sameFileExecutableAlias = (
};
/** Prefer an executable PATH alias that proves the same selected REA file. */
export const registrationEntrypointPath = (
const registrationEntrypointPath = (
entrypoint: string,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
+1 -12
View File
@@ -24,7 +24,6 @@ import {
inspectClientRegistrationCommand,
type ClientRegistrationCommandFacts,
type ClientRegistrationCommandIdentity,
packageRunnerRegistrationCommand,
} from "./ClientRegistrationIdentity.js";
import {
clientEvidencePaths,
@@ -470,17 +469,7 @@ const registrationAligned = ({
(registration.serverName !== "rea" || registration.transport !== "stdio")
)
return "stale";
if (
(["npx", "bunx"] as const).some((runner) => {
const expected = packageRunnerRegistrationCommand(runner, platform);
return (
command.length === expected.length &&
command.every((argument, index) => argument === expected[index])
);
})
)
return "aligned";
// Other owned package references remain stale until pinned to this version.
// Legacy package runners remain owned, but setup migrates them to the selected installation.
if (commandFacts.package !== undefined) return "stale";
// Owning the script does not establish how its shebang interpreter resolves
// in the client. Setup migrates these launchers without losing ownership.
+2 -2
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { err, ok, type Result } from "../domain/result.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
clientConfigurationValuesEqual,
clientRegistrationEntry,
@@ -211,7 +211,7 @@ export const readClientPolicyBlock = async (
const desired =
desiredRegistration ??
userDocument?.servers[PRODUCT_IDENTITY.mcpServerKey] ??
clientRegistrationEntry("json", npxRegistrationCommand(), {});
clientRegistrationEntry("json", currentClientRegistrationCommand(), {});
if (
override !== undefined &&
!clientConfigurationValuesEqual(
+15 -5
View File
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { FakeSetupHost, options } from "./Setup.fixture.js";
import { runSetup } from "./Setup.js";
import { setupRegistrationCommand } from "./SetupHost.js";
import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { discoverSetupState } from "./SetupPlan.js";
import type { MsixPackageView } from "./MsixPackageStorage.js";
@@ -414,13 +414,23 @@ describe("setup workflow action selection", () => {
expect(result.status).toBe("ready");
expect(host.configuredCommands).toEqual([
setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH),
currentClientRegistrationCommand({
platform: "win32",
entrypointPath: CURRENT_ENTRYPOINT_PATH,
}),
]);
expect(
setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH),
currentClientRegistrationCommand({
platform: "win32",
entrypointPath: CURRENT_ENTRYPOINT_PATH,
}),
).toEqual([process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"]);
expect(setupRegistrationCommand("win32", undefined, undefined)).toEqual([]);
expect(setupRegistrationCommand("win32", "npx", undefined)).not.toEqual([]);
expect(
currentClientRegistrationCommand({
platform: "win32",
entrypointPath: undefined,
}),
).toEqual([]);
});
});
+2 -6
View File
@@ -19,7 +19,7 @@ import type { LinuxDistribution } from "./LinuxHopper.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
import { DEFAULT_CATALOG_IDENTITY } from "../catalogIdentity.js";
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
import { setupRegistrationCommand } from "./SetupHost.js";
import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
/** Recording setup host for service-level planning and recovery tests. */
@@ -64,11 +64,7 @@ export class FakeSetupHost implements SetupHost {
constructor(platform: NodeJS.Platform = "darwin") {
this.platform = platform;
this.registrationCommand = setupRegistrationCommand(
platform,
undefined,
CURRENT_ENTRYPOINT_PATH,
);
this.registrationCommand = currentClientRegistrationCommand({ platform });
}
macosVersion = (): Promise<string | undefined> =>
-45
View File
@@ -778,48 +778,3 @@ it.each(["return", "throw"] as const)(
expect(failure.hasCleanupOwner).toBe(false);
},
);
it("stops setup after interrupted prewarm and retains cleanup through host close retry", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }];
let doctorCalls = 0;
const doctor = host.doctor.bind(host);
host.doctor = async (...args) => {
doctorCalls++;
return doctor(...args);
};
let callsAtInterrupt = -1;
let released = false;
const cleanup = new SetupCleanupOwner([
{
close: async () => {
if (!released) throw new Error("controlled runner cleanup refusal");
},
},
]);
const selected = Object.assign(host, {
prewarmRegistrationRunner: async () => {
callsAtInterrupt = doctorCalls;
return {
status: "failed" as const,
reason: "interrupted prewarm",
interruptedSignal: "SIGINT",
};
},
close: () => cleanup.close(),
});
const result = await runSetup(
{ ...options(true), clientIds: ["codex"], installSkill: false },
selected,
);
expect(callsAtInterrupt).toBeGreaterThanOrEqual(0);
expect(doctorCalls).toBe(callsAtInterrupt);
expect(result.status).toBe("needs_human");
expect(result.appliedActions).toContain("configured_codex");
expect(result.prewarm).toMatchObject({ interruptedSignal: "SIGINT" });
expect(result.cleanup?.reason).toContain("controlled runner cleanup refusal");
released = true;
await cleanup.close();
expect(result.cleanup).toBeUndefined();
});
-40
View File
@@ -35,7 +35,6 @@ import { setupCleanupError, setupCleanupReason } from "./SetupCleanupOwner.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import type { SetupSkillInstallError } from "./SetupSkill.js";
import type { SetupHopperInstallFailure } from "./SetupInstallFailure.js";
import type { SetupRegistrationPrewarm } from "./SetupRegistrationPrewarm.js";
/** Discover, approve, and apply setup actions idempotently. */
export const runSetup = async (
@@ -387,44 +386,6 @@ const runSetupWorkflow = async (
return withHopperFailure(await failSkill(skillFailure));
}
}
// The prewarm only follows client registrations the user approved; an empty
// plan or a skill-only run must not install anything.
const prewarmEligible = appliedActions.some((id) =>
id.startsWith("configured_"),
);
let prewarm: SetupRegistrationPrewarm | undefined;
if (prewarmEligible && host.prewarmRegistrationRunner !== undefined) {
emitProgress(options, {
actionId: "prewarm_registration_runner",
label: "REA package-runner cache",
state: "started",
detail: "Preparing the pinned package entry",
});
prewarm = await host.prewarmRegistrationRunner();
if (prewarm.status === "failed" && prewarm.interruptedSignal !== undefined)
return {
status: "cancelled",
plannedActions,
appliedActions,
clients,
clientStates,
doctor: summarizeDoctor(discovery.initialDoctor),
prewarm,
};
if (prewarm.status === "failed")
emitProgress(options, {
actionId: "prewarm_registration_runner",
label: "REA package-runner cache",
state: "warning",
detail: prewarm.reason,
});
else if (prewarm.status === "warmed")
emitProgress(options, {
actionId: "prewarm_registration_runner",
label: "REA package-runner cache",
state: "completed",
});
}
const doctor = summarizeDoctor(await host.doctor(readinessScope));
const remediation = combineRemediation(
hopperFailure?.remediation ??
@@ -447,7 +408,6 @@ const runSetupWorkflow = async (
clientStates,
...(remediation === undefined ? {} : { remediation }),
...(hopperFailure === undefined ? {} : { code: hopperFailure.code }),
...(prewarm === undefined ? {} : { prewarm }),
};
};
+9 -2
View File
@@ -48,7 +48,7 @@ import {
import { PRODUCT_IDENTITY } from "../identity.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
@@ -93,9 +93,16 @@ export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanu
export const configureClientConfiguration = async (
client: SetupClient,
environment: SetupProviderEnvironment = {},
command: readonly string[] = npxRegistrationCommand(),
command: readonly string[] = currentClientRegistrationCommand(),
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
): Promise<ClientConfigurationResult> => {
if (command.length === 0)
return {
status: "failed",
reason: "registration_ownership",
detail:
"The running REA entrypoint could not be identified; run setup from an installed REA command.",
};
if (client.configPathError !== undefined)
return { status: "failed", reason: "path" };
const transactionPath = await resolveClientConfigTransactionPath(
+4 -49
View File
@@ -9,20 +9,9 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
invokingPackageRunner,
registrationEntrypointPath,
registrationRuntimePath,
packageRunnerRegistrationCommand,
type PackageRunner,
} from "./ClientRegistrationIdentity.js";
import {
materializeRegistrationRunnerCache,
type SetupRegistrationPrewarm,
} from "./SetupRegistrationPrewarm.js";
import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
SUPPORTED_NODE_VERSION_PROSE,
javaScriptRuntime,
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import {
@@ -83,32 +72,6 @@ import { bindRegularFileCleanup } from "./RegularFileRead.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { analysisProviderSelectorSchema } from "../domain/providerSelection.js";
/** Resolve the executable and arguments used in a managed MCP registration. */
export const setupRegistrationCommand = (
platform: NodeJS.Platform,
packageRunner: PackageRunner | undefined,
entrypointPath: string | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
): readonly string[] => {
if (packageRunner !== undefined)
return packageRunnerRegistrationCommand(packageRunner, platform);
if (entrypointPath === undefined || entrypointPath.length === 0) return [];
const script = registrationEntrypointPath(
entrypointPath,
environment,
platform,
);
const runtime = registrationRuntimePath(
process.execPath,
javaScriptRuntime(process.versions).id,
environment,
platform,
);
// The script's env-node shebang depends on the client's PATH, which may
// differ from setup's shell. Bind the runtime that is executing setup.
return [runtime, script, "mcp"];
};
/**
* The launcher setup registers, and why it must not be registered when it
* exists only inside one MSIX package's private storage.
@@ -118,12 +81,11 @@ const setupRegistration = (
homeDirectory: string,
environment: Readonly<NodeJS.ProcessEnv>,
): Pick<SetupHost, "registrationCommand" | "registrationCommandBlocker"> => {
const registrationCommand = setupRegistrationCommand(
const registrationCommand = currentClientRegistrationCommand({
platform,
invokingPackageRunner(environment, platform),
CURRENT_ENTRYPOINT_PATH,
entrypointPath: CURRENT_ENTRYPOINT_PATH,
environment,
);
});
const registrationCommandBlocker =
registrationCommand.length === 0
? "REA could not determine the current executable entrypoint. Run setup through the installed REA CLI or a supported package runner before configuring clients."
@@ -192,12 +154,6 @@ export const systemSetupHost = (
},
};
const registration = setupRegistration(platform, homeDirectory, environment);
const prewarmRegistrationRunner = (): Promise<SetupRegistrationPrewarm> =>
materializeRegistrationRunnerCache(
registration.registrationCommand,
environment,
resources,
);
const cleanup = new SetupCleanupOwner([
macCleanup,
{
@@ -220,7 +176,6 @@ export const systemSetupHost = (
skillDestinations: (clientIds) =>
skillDestinations(homeDirectory, clientIds, environment, platform),
...registration,
prewarmRegistrationRunner,
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
linuxDistribution,
@@ -1,357 +0,0 @@
import { describe, expect, it, onTestFinished } from "vitest";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
launchRegistrationRunnerCommand,
materializeRegistrationRunnerCache,
registrationPrewarmCommand,
type RegistrationRunnerLaunch,
type RegistrationRunnerLauncher,
} from "./SetupRegistrationPrewarm.js";
const scope = () => {
const resources = new ArtifactResourceScope();
onTestFinished(() => resources.close());
return resources;
};
const specifier = PRODUCT_IDENTITY.registrationPackageSpecifier;
const launch = (
overrides: Partial<RegistrationRunnerLaunch> = {},
): RegistrationRunnerLaunch => ({
exitCode: 0,
signal: null,
stdout: "",
stderr: "",
timedOut: false,
...overrides,
});
describe("registrationPrewarmCommand", () => {
it("replaces the server subcommand of an npx registration", () => {
expect(registrationPrewarmCommand(["npx", "-y", specifier, "mcp"])).toEqual(
["npx", "-y", specifier, "--version"],
);
});
it("keeps the Windows command wrapper", () => {
expect(
registrationPrewarmCommand([
"cmd.exe",
"/d",
"/c",
"npx",
"-y",
specifier,
"mcp",
]),
).toEqual(["cmd.exe", "/d", "/c", "npx", "-y", specifier, "--version"]);
});
it("replaces the server subcommand of a bunx registration", () => {
expect(registrationPrewarmCommand(["bunx", specifier, "mcp"])).toEqual([
"bunx",
specifier,
"--version",
]);
});
it("keeps direct launchers and non-server commands undefined", () => {
expect(registrationPrewarmCommand(["/opt/rea/rea", "mcp"])).toBeUndefined();
expect(
registrationPrewarmCommand(["npx", "-y", specifier, "doctor"]),
).toBeUndefined();
expect(registrationPrewarmCommand([])).toBeUndefined();
});
});
describe("materializeRegistrationRunnerCache", () => {
it("skips direct launchers without launching", async () => {
let launched = 0;
const launcher: RegistrationRunnerLauncher = async () => {
launched += 1;
return launch();
};
const result = await materializeRegistrationRunnerCache(
["/opt/rea/rea", "mcp"],
{},
scope(),
launcher,
);
expect(result).toEqual({ status: "skipped", reason: "direct_launcher" });
expect(launched).toBe(0);
});
it("materializes the pinned spec once and reports warmed", async () => {
const commands: (readonly string[])[] = [];
const launcher: RegistrationRunnerLauncher = async (
command,
arguments_,
options,
) => {
commands.push([command, ...arguments_]);
expect(options.timeoutMs).toBeGreaterThan(0);
expect(options.env.REA_PREWARM_PROBE).toBe("1");
expect(options.cwd.length).toBeGreaterThan(0);
return launch({ stdout: "6.7.0\n" });
};
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{ REA_PREWARM_PROBE: "1" },
scope(),
launcher,
);
expect(result).toEqual({ status: "warmed" });
expect(commands).toEqual([["npx", "-y", specifier, "--version"]]);
});
it("reports a bounded failure reason for a non-zero exit", async () => {
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
scope(),
async () => launch({ exitCode: 1, stderr: "spawn EPERM\n" }),
);
expect(result.status).toBe("failed");
if (result.status !== "failed") return;
expect(result.reason).toContain("exit 1");
expect(result.reason).toContain("spawn EPERM");
expect(result.reason).not.toMatch(/[\n\r]/u);
expect(result.reason).toContain("first client start");
});
it("reports a timeout failure without command output facts", async () => {
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
scope(),
async () => launch({ exitCode: null, timedOut: true }),
);
expect(result.status).toBe("failed");
if (result.status !== "failed") return;
expect(result.reason).toContain("timed out");
});
it("reports a spawn failure", async () => {
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
scope(),
async () => launch({ exitCode: null, spawnError: "spawn npx ENOENT" }),
);
expect(result.status).toBe("failed");
if (result.status !== "failed") return;
expect(result.reason).toContain("spawn npx ENOENT");
});
it("reports a forwarded interrupt as a failure", async () => {
const result = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
scope(),
async () => launch({ exitCode: null, interruptedSignal: "SIGINT" }),
);
expect(result.status).toBe("failed");
if (result.status !== "failed") return;
expect(result.reason).toContain("stopped by SIGINT");
});
});
describe("launchRegistrationRunnerCommand", () => {
it("retains bounded head and tail bytes and preserves UTF-8 across writes", async () => {
const result = await launchRegistrationRunnerCommand(
process.execPath,
[
"-e",
`
process.stdout.write(Buffer.from([0xe4]));
setTimeout(() => {
process.stdout.write(Buffer.from([0xb8, 0xad]));
process.stderr.write('x'.repeat(60000));
setTimeout(() => {
process.stderr.write('y'.repeat(60000));
process.stderr.write('FINAL: package permission denied');
process.exitCode = 1;
}, 30);
}, 30);
`,
],
{
env: process.env,
cwd: process.cwd(),
timeoutMs: 5000,
platform: process.platform,
resources: scope(),
},
);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("中");
expect(Buffer.byteLength(result.stderr)).toBeLessThan(64 * 1024 + 50);
expect(result.stderr).toContain("[output omitted]");
expect(result.stderr).toContain("FINAL: package permission denied");
const prewarm = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
scope(),
async () => result,
);
expect(prewarm.status).toBe("failed");
if (prewarm.status === "failed") {
expect(prewarm.reason).toContain("exit 1");
expect(prewarm.reason).toContain("FINAL: package permission denied");
}
});
it("captures a successful command exit", async () => {
const result = await launchRegistrationRunnerCommand(
process.execPath,
["--version"],
{
env: process.env,
cwd: process.cwd(),
timeoutMs: 30_000,
platform: process.platform,
resources: scope(),
},
);
expect(result.exitCode).toBe(0);
expect(result.timedOut).toBe(false);
expect(result.stdout.trim().length).toBeGreaterThan(0);
});
it("stops the runner tree at the deadline and reports the timeout", async () => {
const startedAt = Date.now();
const result = await launchRegistrationRunnerCommand(
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{
env: process.env,
cwd: process.cwd(),
timeoutMs: 300,
platform: process.platform,
resources: scope(),
},
);
expect(result.timedOut).toBe(true);
expect(Date.now() - startedAt).toBeLessThan(10_000);
});
});
it.runIf(process.platform !== "win32")(
"settles the prewarm deadline after a detached descendant inherits the exited runner's pipes",
async () => {
const result = await launchRegistrationRunnerCommand(
process.execPath,
[
"-e",
`
const { spawn } = require('node:child_process');
const child = spawn(process.execPath, ['-e', 'process.stdout.write("descendant:" + process.pid); setTimeout(() => process.stdout.write("natural-descendant-exit"), 60_000)'], {
detached: true, stdio: ['ignore', 'inherit', 'inherit']
});
child.unref();
`,
],
{
env: process.env,
cwd: process.cwd(),
// Ownership admission and two Node starts must finish before the
// deadline tests held output pipes rather than startup cancellation.
timeoutMs: 5000,
platform: process.platform,
resources: scope(),
},
);
expect(result.exitCode).toBe(0);
expect(result.timedOut).toBe(true);
expect(result.stdout).toContain("descendant:");
expect(result.cleanupFailure).toBeUndefined();
expect(result.stdout).not.toContain("natural-descendant-exit");
},
15_000,
);
it.each([false, true])(
"retains the exact runner cleanup for scope close and retry (interrupt=%s)",
async (interrupt) => {
const resources = new ArtifactResourceScope();
let allowCleanup = false;
let attempts = 0;
onTestFinished(async () => {
allowCleanup = true;
await resources.close();
});
const result = await launchRegistrationRunnerCommand(
process.execPath,
["--version"],
{
env: process.env,
cwd: process.cwd(),
timeoutMs: 5000,
platform: process.platform,
resources,
launcher: async (options) => {
const launched = await spawnOwnedProviderProcess(options);
return {
...launched,
cleanup: async () => {
attempts++;
if (interrupt && attempts === 1) process.emit("SIGINT");
return allowCleanup
? cleanupOwnedProcessGroup(launched.ownership)
: {
cleaned: false as const,
reason: "controlled runner cleanup refusal",
};
},
};
},
},
);
expect(result.exitCode).toBe(0);
expect(result.cleanupFailure).toContain(
"controlled runner cleanup refusal",
);
expect(result.interruptedSignal).toBe(interrupt ? "SIGINT" : undefined);
expect(resources.hasPending).toBe(true);
const prewarm = await materializeRegistrationRunnerCache(
["npx", "-y", specifier, "mcp"],
{},
resources,
async () => result,
);
expect(prewarm).toMatchObject({ status: "failed" });
await expect(resources.close()).rejects.toThrow();
expect(attempts).toBe(2);
allowCleanup = true;
await resources.close();
expect(attempts).toBe(3);
expect(resources.hasPending).toBe(false);
},
);
it("captures the selected environment at admission and accepts reconfiguration on the next prewarm", async () => {
const env = { ...process.env, REA_PREWARM_SELECTED_VALUE: "first" };
const resources = scope();
const invoke = () =>
launchRegistrationRunnerCommand(
process.execPath,
["-e", "process.stdout.write(process.env.REA_PREWARM_SELECTED_VALUE)"],
{
env,
cwd: process.cwd(),
timeoutMs: 5000,
platform: process.platform,
resources,
},
);
const first = invoke();
env.REA_PREWARM_SELECTED_VALUE = "second";
expect((await first).stdout).toBe("first");
expect((await invoke()).stdout).toBe("second");
expect(resources.hasPending).toBe(false);
});
-259
View File
@@ -1,259 +0,0 @@
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { randomUUID } from "node:crypto";
import {
ArtifactResourceScope,
type ArtifactResourceOwner,
} from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
} from "../process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import { ownedCommandResources } from "../process/OwnedCommand.js";
import { tmpdir } from "node:os";
import { BoundedOutputExcerpt } from "../process/BoundedOutputExcerpt.js";
import { inspectClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
/**
* Best-effort outcome of materializing the pinned package-runner cache entry.
* A failure never blocks setup; the first client start then performs the
* one-time install inside its own startup deadline.
*/
export type SetupRegistrationPrewarm =
| { readonly status: "skipped"; readonly reason: "direct_launcher" }
| { readonly status: "warmed" }
| {
readonly status: "failed";
readonly reason: string;
readonly interruptedSignal?: string;
};
/** One cold package install at setup time; client startup deadlines stay untouched. */
const PREWARM_TIMEOUT_MS = 180_000;
const PREWARM_CAPTURE_LIMIT = 64 * 1024;
const PREWARM_REASON_LIMIT = 300;
/** Facts of one supervised package-runner launch. */
export interface RegistrationRunnerLaunch {
readonly exitCode: string | number | null;
readonly signal: string | null;
readonly stdout: string;
readonly stderr: string;
readonly timedOut: boolean;
readonly spawnError?: string;
readonly cleanupFailure?: string;
/** Terminal interrupt that was forwarded to the runner tree, if any. */
readonly interruptedSignal?: string;
}
/** Launch shape shared with the supervised spawn so tests can observe it. */
export type RegistrationRunnerLauncher = (
command: string,
arguments_: readonly string[],
options: {
readonly env: Readonly<NodeJS.ProcessEnv>;
/** Neutral directory; inside a REA source checkout the package runner
* would resolve the local project instead of the published entry. */
readonly cwd: string;
readonly timeoutMs: number;
readonly platform: NodeJS.Platform;
readonly resources: ArtifactResourceScope;
readonly launcher?: typeof spawnOwnedProviderProcess;
},
) => Promise<RegistrationRunnerLaunch>;
/**
* Resolve the cache-materializing variant of a registration command. The
* package-runner cache entry is keyed by the requested spec, so a terminating
* subcommand with the same spec warms the entry the client will launch.
* Direct launchers keep no package-runner cache; they return undefined.
*/
export const registrationPrewarmCommand = (
command: readonly string[],
): readonly string[] | undefined => {
if (
command.length === 0 ||
command[command.length - 1] !== "mcp" ||
inspectClientRegistrationCommand(command, undefined).package === undefined
)
return undefined;
return [...command.slice(0, -1), "--version"];
};
/** Materialize the pinned package-runner cache entry once, best-effort. */
export const materializeRegistrationRunnerCache = async (
command: readonly string[],
environment: Readonly<NodeJS.ProcessEnv>,
resources: ArtifactResourceScope,
launchCommand: RegistrationRunnerLauncher = launchRegistrationRunnerCommand,
): Promise<SetupRegistrationPrewarm> => {
const prewarm = registrationPrewarmCommand(command);
if (prewarm === undefined)
return { status: "skipped", reason: "direct_launcher" };
const launch = await launchCommand(prewarm[0] ?? "", prewarm.slice(1), {
env: environment,
cwd: tmpdir(),
timeoutMs: PREWARM_TIMEOUT_MS,
platform: process.platform,
resources,
});
return launch.timedOut ||
launch.spawnError !== undefined ||
launch.cleanupFailure !== undefined ||
launch.interruptedSignal !== undefined ||
launch.exitCode !== 0
? {
status: "failed",
reason: prewarmFailureReason(launch),
...(launch.interruptedSignal === undefined
? {}
: { interruptedSignal: launch.interruptedSignal }),
}
: { status: "warmed" };
};
/** Own the launcher and token-marked descendants until cleanup is verified. */
export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = (
command,
arguments_,
options,
) => {
const environment = snapshotEnvironment(options.env, options.platform);
return options.resources.run(async () => {
const stdout = new BoundedOutputExcerpt(PREWARM_CAPTURE_LIMIT);
const stderr = new BoundedOutputExcerpt(PREWARM_CAPTURE_LIMIT);
const controller = new AbortController();
let timedOut = false;
let spawnError: string | undefined;
let cleanupFailure: string | undefined;
let interruptedSignal: NodeJS.Signals | undefined;
let supervisor: ProviderProcessSupervisor | undefined;
let resource: ArtifactResourceOwner | undefined;
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, options.timeoutMs);
const signalHandlers = new Map<NodeJS.Signals, () => void>(
FORWARD_SIGNALS.map((signal) => [
signal,
() => {
if (interruptedSignal !== undefined) return;
interruptedSignal = signal;
controller.abort();
},
]),
);
for (const [signal, handler] of signalHandlers) process.on(signal, handler);
try {
const launched = await (options.launcher ?? spawnOwnedProviderProcess)({
command,
arguments: arguments_,
cwd: options.cwd,
hostEnvironment: environment,
platform: options.platform,
runId: `rea-registration-prewarm-${randomUUID()}`,
expectedCommand: null,
sweepTokenOwnedProcesses: true,
signal: controller.signal,
});
supervisor = new ProviderProcessSupervisor(
{
...launched,
ownsProcessLifetime: true,
cleanup:
launched.cleanup ??
(() => cleanupOwnedProcessGroup(launched.ownership)),
},
{
captureStdout: false,
maxDiagnosticBytes: 0,
onDiagnostic: (event) => {
if (event.type === "error") spawnError = event.message;
},
},
);
const owner = supervisor;
const lease = new AnalysisCleanupLease(async () => {
const stopped = await owner.stop();
if (stopped.status === "incomplete" || stopped.status === "not-owned")
throw new Error(
stopped.status === "incomplete"
? stopped.reason
: "Registration runner ownership was not confirmed.",
);
}, ownedCommandResources(launched.ownership));
resource = { kind: "lease", resource: command, lease };
options.resources.retain(resource);
launched.process.stdout?.on("data", (chunk: Buffer) =>
stdout.append(chunk),
);
launched.process.stderr?.on("data", (chunk: Buffer) =>
stderr.append(chunk),
);
await supervisor.waitForOutputClose(undefined, controller.signal);
} catch (cause: unknown) {
if (!controller.signal.aborted)
spawnError = cause instanceof Error ? cause.message : String(cause);
} finally {
clearTimeout(timer);
try {
if (resource !== undefined) {
const released = await options.resources.release(resource);
if (released.kind === "failed") throw released.cause;
}
} catch (cause: unknown) {
cleanupFailure = cause instanceof Error ? cause.message : String(cause);
}
for (const [signal, handler] of signalHandlers)
process.removeListener(signal, handler);
// Restore default termination only after stopping the owned runner tree.
if (interruptedSignal !== undefined && cleanupFailure === undefined)
process.kill(process.pid, interruptedSignal);
}
const exit = supervisor?.observedExit();
return {
exitCode: exit?.code ?? exit?.signal ?? null,
signal: exit?.signal ?? null,
stdout: stdout.text,
stderr: stderr.text,
timedOut,
...(spawnError === undefined ? {} : { spawnError }),
...(cleanupFailure === undefined ? {} : { cleanupFailure }),
...(interruptedSignal === undefined ? {} : { interruptedSignal }),
};
});
};
/** Terminal interrupts that must also stop a detached package-runner tree. */
const FORWARD_SIGNALS: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
const prewarmFailureReason = (launch: RegistrationRunnerLaunch): string => {
const outcome =
launch.cleanupFailure !== undefined
? "runner cleanup remains incomplete"
: launch.spawnError !== undefined
? "spawn failed"
: launch.interruptedSignal !== undefined
? `the package runner was stopped by ${launch.interruptedSignal}`
: launch.timedOut
? `the package runner timed out after the bounded prewarm window`
: `exit ${launch.exitCode === null ? "unknown" : String(launch.exitCode)}`;
const normalized = (
launch.cleanupFailure ??
launch.spawnError ??
launch.stderr
)
.replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "")
.replace(/[\u0000-\u001f\u007f]+/gu, " ")
.replace(/\s+/gu, " ")
.trim();
const detailLimit = PREWARM_REASON_LIMIT - outcome.length - 2;
const detail =
normalized.length > detailLimit
? `… ${normalized.slice(-(detailLimit - 2))}`
: normalized;
const observed = `${outcome}${detail === "" ? "" : `: ${detail}`}`;
return `The pinned package-runner entry could not be materialized before the first client start (${observed}); the first client start will install it inside its startup deadline.`;
};
-8
View File
@@ -13,7 +13,6 @@ import type {
AnalysisError,
} from "../domain/analysisErrorBase.js";
import type { SetupClient } from "./SupportedClients.js";
import type { SetupRegistrationPrewarm } from "./SetupRegistrationPrewarm.js";
import type {
SetupFailureCode,
SetupHopperInstallResult,
@@ -143,10 +142,6 @@ export interface SetupHost {
): Promise<Result<void, SetupSkillInstallError>>;
installSkill(clientIds: readonly string[]): Promise<SkillInstallResult>;
doctor(scope?: DoctorScope): Promise<Awaited<ReturnType<typeof runDoctor>>>;
/** Best-effort materialization of the pinned package-runner cache entry
* before the first client start, so a one-time dependency install cannot
* consume a client's MCP startup deadline. Absent hosts skip the step. */
prewarmRegistrationRunner?(): Promise<SetupRegistrationPrewarm>;
}
/** Structured setup outcome carrying remediation instead of prompting. */
export interface SetupResult {
@@ -166,9 +161,6 @@ export interface SetupResult {
readonly code?: SetupFailureCode;
/** Identified resources still retained after finalization; absent after confirmed release. */
readonly cleanup?: AnalysisCleanupObservation | undefined;
/** Best-effort pinned package-runner cache materialization; absent when the
* host offers no prewarm capability or setup finished before finalization. */
readonly prewarm?: SetupRegistrationPrewarm;
}
/** Whether the requested setup actions still need approval or human repair. */