Compare commits

..
Author SHA1 Message Date
Cursor Agent 0f5646e203 chore: merge main into codex/audit-client-registration 2026-11-11 02:26:28 +00:00
Cursor Agent 9281278d81 test(setup): expect owned command identity in BOM registration status
Aligned client registration status includes command_identity. The BOM
read-back compared the status without that field, so an owned launcher
failed the equality check.
2026-11-11 01:21:16 +00:00
Cursor Agent 32c078382b test(setup): validate temporary paths before filesystem calls
Contain workspace children and the symlink target before creating the launcher alias so a temporary path cannot leave its root.
2026-11-10 23:39:24 +00:00
Cursor Agent ff5865acd5 test(setup): keep temporary paths inside the workspace root
Resolve setup fixtures through the test workspace path helper so joined paths stay under the temporary root.
2026-11-10 23:06:25 +00:00
morluto d352b99b77 fix(uninstall): retain resource cleanup diagnostics 2026-11-10 17:31:14 -05:00
morluto 76dbf8f125 fix(setup): retain parsed client launcher ownership 2026-11-10 17:31:14 -05:00
morluto bcd45ac67b fix(config): preserve client settings during launcher refresh 2026-11-10 17:31:14 -05:00
27 changed files with 563 additions and 92 deletions

No files matched your search

@@ -39,6 +39,12 @@ path and supported version of the runtime you intend to use, then update only
the runtime command, preserving the REA script arguments and other settings.
Rerun setup to review the registration and restart the client.
Doctor JSON records each registration's `command_identity` as `owned`, `foreign`,
or `unknown`, separately from its readiness `state`. An invalid timeout or client
policy can leave ownership established. Setup uses that recorded observation for
its default client selection, then inspects selected configuration again before
planning writes; a missing or unreadable launcher remains unknown.
`rea mcp doctor --json` tests the production MCP adapter using the runtime
executable and environment of the terminal running doctor. It does not launch
the client's configured command or reproduce the client's environment. If doctor
+6
View File
@@ -481,6 +481,12 @@ updated stops the remaining removals. Uninstall reports each candidate
their individual `actions` alongside its aggregate status and detail,
including earlier removals when a later file fails. The paths name
the discovered configuration candidates, not resolved physical file identities.
If resource cleanup remains incomplete, uninstall reports a failed
`uninstall_cleanup` item alongside the completed removals, with a structured
`cleanup` observation naming the outstanding resources. An embedded host retains
the exact cleanup owners: retrying that host's `close()` updates previously
returned cleanup observations when release is confirmed. A new CLI invocation
does not inherit those owners; inspect the reported resources before proceeding.
For JSON clients, uninstall removes every repeated `rea` member in the effective
server table only when each registration is REA-owned. An unowned earlier
member, or an unowned OpenCode V1 entry that removing V2 would expose, stops
@@ -98,3 +98,27 @@ it("retains comments from dotted REA assignments and nested inline values", () =
);
}
});
it.each(["toml", "grok"] as const)(
"retains scalar types in an inline %s registration while refreshing its launcher",
(format) => {
const source =
'mcp_servers = { rea = { command = "old", large = 1e20, exact = 9007199254740992.0, limit = inf, floor = -inf, unknown = nan, zero = -0.0, date = 2026-11-10, clock = 12:34:56, local = 2026-11-10T12:34:56, offset = 2026-11-10T12:34:56-05:00 }, peer = { command = "peer" } } # retained\n';
const parsed = parseClientConfiguration(source, format);
const previous = parsed.servers.rea as Record<string, unknown>;
const servers = {
...parsed.servers,
rea: { ...previous, command: "new" },
};
const result = serializeClientConfiguration(
withClientServers(parsed, servers),
format,
source,
);
if (!result.ok) throw new Error("TOML edit was refused");
expect(parseClientConfiguration(result.value, format).servers).toEqual(
servers,
);
expect(result.value).toContain("# retained");
},
);
+13 -2
View File
@@ -722,7 +722,18 @@ const tomlInlineKey = (key: string): string =>
const tomlInlineValue = (value: unknown): string => {
if (typeof value === "string") return tomlBasicString(value);
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "number") {
if (Number.isNaN(value)) return "nan";
if (value === Infinity) return "inf";
if (value === -Infinity) return "-inf";
if (Object.is(value, -0)) return "-0.0";
// A float outside the safe integer range must not become an Int64 token.
return Number.isInteger(value) && !Number.isSafeInteger(value)
? value.toFixed(1)
: String(value);
}
// TomlDate overrides toISOString to retain local date/time and offset kinds.
if (value instanceof Date) return value.toISOString();
if (typeof value === "bigint") return String(value);
if (Array.isArray(value))
return `[${value.map((item) => tomlInlineValue(item)).join(", ")}]`;
@@ -734,7 +745,7 @@ const tomlInlineValue = (value: unknown): string => {
.join(", ");
return `{ ${body} }`;
}
return '""';
throw new TypeError("Unsupported TOML inline value");
};
interface InlinePair {
@@ -195,7 +195,10 @@ export const recordedProviderSettings = (
owns?: OwnedProviderSetting,
observe?: ProviderSettingObserver,
): RecordedProviderSettings | undefined => {
const names = Object.keys(selected).sort(compareUnicodeCodePoints);
const selection = mergeEnvironmentOverrides({}, selected, platform);
const names = Object.keys(selection).sort(compareUnicodeCodePoints);
const identity = (name: string): string =>
platform === "win32" ? name.toUpperCase() : name;
if (names.length === 0) return undefined;
const details = clientRegistrationEnvironmentDetails(
parsed,
@@ -214,24 +217,28 @@ export const recordedProviderSettings = (
platform,
);
for (const name of names) {
const value = recorded[name];
if (value !== undefined && expressions[name] === undefined)
const value = recorded[identity(name)];
if (value !== undefined && expressions[identity(name)] === undefined)
observe?.(name, value);
}
const facts = {
unrecorded: names.filter(
(name) => recorded[name] === undefined && expressions[name] === undefined,
(name) =>
recorded[identity(name)] === undefined &&
expressions[identity(name)] === undefined,
),
differing: names.filter(
(name) =>
expressions[name] === undefined &&
recorded[name] !== undefined &&
recorded[name] !== selected[name],
expressions[identity(name)] === undefined &&
recorded[identity(name)] !== undefined &&
recorded[identity(name)] !== selection[name],
),
unresolved: names.filter(
(name) => expressions[identity(name)] !== undefined,
),
unresolved: names.filter((name) => expressions[name] !== undefined),
};
const owned = names.filter((name) => {
const value = recorded[name];
const value = recorded[identity(name)];
return value !== undefined && owns?.(name, value) === true;
});
return facts.unrecorded.length +
+33 -19
View File
@@ -48,6 +48,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
import {
parseClientRegistration,
parseClientRegistrationLauncher,
clientRegistrationOwnershipRemediation,
setupPreservesDisabledActivation,
type ClientRegistration,
@@ -56,6 +57,8 @@ import {
interface ClientRegistrationStatusBase {
readonly client: string;
readonly config_path: string;
/** Parsed launcher ownership; independent of readiness and retained without reinspection. */
readonly command_identity: ClientRegistrationCommandIdentity;
/** Exact package reference requested by npx/bunx, when registration uses one. */
readonly package_reference?: string;
/** Non-blocking timeout guidance; caller-selected values remain aligned. */
@@ -125,8 +128,6 @@ interface ClientRegistrationReadOptions {
export interface ClientRegistrationInspection {
readonly status: ClientRegistrationStatus;
readonly activation: "enabled" | "disabled" | "unknown";
/** Ownership remains unknown until this inspection has parsed the launcher. */
readonly commandIdentity: ClientRegistrationCommandIdentity;
}
/** Project diagnostics from the same inspection used to select maintenance. */
@@ -151,9 +152,8 @@ export const readClientRegistrationInspections = async (
const record = (
status: ClientRegistrationStatus,
activation: ClientRegistrationInspection["activation"] = "unknown",
commandIdentity: ClientRegistrationCommandIdentity = "unknown",
): void => {
inspections.push({ status, activation, commandIdentity });
inspections.push({ status, activation });
};
for (const client of supportedClients(
home,
@@ -166,6 +166,7 @@ export const readClientRegistrationInspections = async (
client: client.name,
config_path: client.configPath,
command: [],
command_identity: "unknown",
state: "invalid",
remediation: client.configPathError,
});
@@ -180,6 +181,7 @@ export const readClientRegistrationInspections = async (
client: client.name,
config_path: presence.error.path,
command: [],
command_identity: "unknown",
state: "invalid",
remediation: presence.error.detail,
});
@@ -193,6 +195,7 @@ export const readClientRegistrationInspections = async (
client: client.name,
config_path: client.markerPath ?? client.configPath,
command: [],
command_identity: "unknown",
state: "manual",
remediation: manualRemediation,
});
@@ -215,6 +218,7 @@ export const readClientRegistrationInspections = async (
client: client.name,
config_path: configuration.error.path,
command: [],
command_identity: "unknown",
state: "invalid",
remediation: configuration.error.detail,
});
@@ -242,6 +246,7 @@ export const readClientRegistrationInspections = async (
client: client.name,
config_path: configPath,
command: [],
command_identity: "unknown",
state: "invalid",
remediation: policy.error.detail,
});
@@ -259,6 +264,7 @@ export const readClientRegistrationInspections = async (
client: client.name,
config_path: configPath,
command: [],
command_identity: "unknown",
state: "invalid",
remediation: editabilityError.message,
});
@@ -278,16 +284,33 @@ export const readClientRegistrationInspections = async (
: client;
const parsedRegistration = parseClientRegistration(raw, effectiveClient);
if (!parsedRegistration.ok) {
// Invalid readiness settings do not erase a usable launcher identity.
const launcher = parseClientRegistrationLauncher(raw, effectiveClient);
const commandIdentity = launcher.ok
? inspectClientRegistrationCommand(
[launcher.value.command, ...launcher.value.args],
currentCommandPath,
).identity
: "unknown";
record({
client: client.name,
config_path: configPath,
command: [],
command_identity: commandIdentity,
state: "invalid",
remediation: `${parsedRegistration.error.message}: ${parsedRegistration.error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}`,
});
continue;
}
const registration = parsedRegistration.value;
const command: RegistrationCommand = [
registration.command,
...registration.args,
];
const commandFacts = inspectClientRegistrationCommand(
command,
currentCommandPath,
);
const registrationEnvironment =
compactInputSchemaByDefault(client) || client.name === "codex"
? await clientRegistrationEnvironmentDetailsWithBudget(
@@ -309,19 +332,12 @@ export const readClientRegistrationInspections = async (
client: client.name,
config_path: configPath,
command: [],
command_identity: commandFacts.identity,
state: "invalid",
remediation: registrationEnvironment.error.message,
});
continue;
}
const command: RegistrationCommand = [
registration.command,
...registration.args,
];
const commandFacts = inspectClientRegistrationCommand(
command,
currentCommandPath,
);
const packageReference = commandFacts.package?.reference;
const activation = registrationActivation(registration, client, parsed);
const packageVersion = commandFacts.package?.exactVersion;
@@ -331,9 +347,7 @@ export const readClientRegistrationInspections = async (
const alignment = registrationAligned({
registration,
client: effectiveClient,
command,
commandFacts,
platform: options.platform ?? process.platform,
inputSchemaProfileSelected:
registrationEnvironment?.ok === true &&
snapshotEnvironment(
@@ -345,6 +359,7 @@ export const readClientRegistrationInspections = async (
configuredStatus(
effectiveClient,
command,
commandFacts.identity,
alignment === "unknown"
? "unknown"
: activation === "enabled" &&
@@ -400,7 +415,6 @@ export const readClientRegistrationInspections = async (
),
),
activation,
commandFacts.identity,
);
} catch (cause: unknown) {
if (
@@ -465,16 +479,12 @@ const registrationActivation = (
const registrationAligned = ({
registration,
client,
command,
commandFacts,
platform,
inputSchemaProfileSelected,
}: {
readonly registration: ClientRegistration;
readonly client: SetupClient;
readonly command: readonly string[];
readonly commandFacts: ClientRegistrationCommandFacts;
readonly platform: NodeJS.Platform;
readonly inputSchemaProfileSelected: boolean;
}): "aligned" | "stale" | "unknown" => {
const { identity } = commandFacts;
@@ -527,6 +537,7 @@ const unknownEntryPointRemediation = (): string =>
const configuredStatus = (
client: SetupClient,
command: RegistrationCommand,
commandIdentity: ClientRegistrationCommandIdentity,
state: "aligned" | "stale" | "unknown",
policyRemediation?: string,
packageReference?: string,
@@ -538,6 +549,7 @@ const configuredStatus = (
client: client.name,
config_path: client.configPath,
command,
command_identity: commandIdentity,
...(packageReference === undefined
? {}
: { package_reference: packageReference }),
@@ -552,6 +564,7 @@ const configuredStatus = (
client: client.name,
config_path: client.configPath,
command,
command_identity: commandIdentity,
...(packageReference === undefined
? {}
: { package_reference: packageReference }),
@@ -615,6 +628,7 @@ const unavailableStatus = (
client: client.name,
config_path: configPath,
command: [],
command_identity: "unknown",
state,
remediation: remediation(client),
});
+7
View File
@@ -330,6 +330,7 @@ describe("doctor MCP startup readiness", () => {
const alignedCodex: ClientRegistrationStatus = {
client: "codex",
config_path: "/home/user/.codex/config.toml",
command_identity: "owned",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
@@ -803,6 +804,7 @@ describe("doctor installation identity", () => {
{
client: "codex",
config_path: "/home/user/.codex/config.toml",
command_identity: "owned",
command: ["/old/rea", "mcp"],
state: "stale",
remediation:
@@ -868,6 +870,7 @@ describe("doctor scoped readiness", () => {
{
client: "codex",
config_path: "/home/user/.codex/config.toml",
command_identity: "owned",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
@@ -875,6 +878,7 @@ describe("doctor scoped readiness", () => {
{
client: "cursor",
config_path: "/home/user/.cursor/mcp.json",
command_identity: "unknown",
command: ["/old/rea", "mcp"],
state: "unknown",
remediation: "Run rea setup to refresh this registration.",
@@ -919,6 +923,7 @@ describe("doctor scoped readiness", () => {
{
client: "kimi",
config_path: "/home/user/.kimi-code/mcp.json",
command_identity: "owned",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
@@ -949,6 +954,7 @@ describe("Grok Bot readiness", () => {
{
client: "grok_bot",
config_path: "/home/user/.grokbot",
command_identity: "unknown",
command: [],
state: "manual",
remediation: "Add the connector from the Grok Bot chat.",
@@ -1009,6 +1015,7 @@ describe("doctor scoped readiness", () => {
{
client: "codex",
config_path: "/home/user/.codex/config.toml",
command_identity: "owned",
command: ["rea", "mcp"],
state: "aligned",
remediation: null,
@@ -29,6 +29,7 @@ const recordingHost = (overrides: Partial<DoctorHost>) => {
{
client: "cursor",
config_path: "/home/user/.cursor/mcp.json",
command_identity: "owned",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
@@ -164,6 +165,7 @@ it("states each comparison result without a claim it did not establish", async (
{
client: "cursor",
config_path: "/cursor.json",
command_identity: "owned",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import {
parseClientConfiguration,
serializeClientConfiguration,
withClientServers,
} from "./ClientConfigurationDocument.js";
import {
editHermesConfigurationYaml,
@@ -462,7 +467,7 @@ describe("Hermes configuration YAML", () => {
parseHermesConfigurationYaml("0600: octal\n600: decimal\n"),
).not.toThrow();
expect(parseHermesConfigurationYaml("value: 9007199254740993\n")).toEqual({
value: Number("9007199254740993"),
value: 9007199254740993n,
});
});
@@ -549,6 +554,34 @@ describe("Hermes configuration YAML", () => {
});
});
it("retains exact integer settings through a parsed Hermes launcher refresh", () => {
const source =
"mcp_servers:\n rea:\n command: old\n identifier: 9007199254740993 # caller identifier\n negative: -9007199254740993\n safe: 9007199254740991\n mode: 0600\n";
const parsed = parseClientConfiguration(source, "hermes");
const previous = parsed.servers.rea as Record<string, unknown>;
const result = serializeClientConfiguration(
withClientServers(parsed, {
...parsed.servers,
rea: { ...previous, command: "rea" },
}),
"hermes",
source,
);
if (!result.ok) throw new Error("Hermes edit was refused");
expect(result.value).toBe(source.replace("command: old", "command: rea"));
expect(parse(result.value, { version: "1.1", intAsBigInt: true })).toEqual({
mcp_servers: {
rea: {
command: "rea",
identifier: 9007199254740993n,
negative: -9007199254740993n,
safe: 9007199254740991n,
mode: 384n,
},
},
});
});
it("keeps argument annotations with unique values and retains removed annotations on the list", () => {
const source =
"mcp_servers:\n rea:\n args:\n - mcp # mcp note\n - old # removed note\n - dup # first duplicate\n - dup # second duplicate\n";
+13 -4
View File
@@ -275,8 +275,8 @@ const createHermesYamlOptions = () => {
// Hermes defaults to YAML 1.1; an explicit document directive still wins.
// Use the same schema to interpret existing values and quote new strings.
version: "1.1" as const,
// Keep integer key identity exact; parseHermesConfigurationYaml's reviver
// restores the existing number-valued consumer contract.
// Keep integer identity exact; the consumer projection converts only
// safely representable integers to numbers.
intAsBigInt: true,
customTags: (tags: Tags) => {
const hasTimestamp = tags.some(
@@ -325,7 +325,9 @@ export const parseHermesConfigurationYaml = (text: string): unknown => {
? {}
: document.toJS({
reviver: (_key, value) =>
typeof value === "bigint" ? Number(value) : value,
typeof value === "bigint" && Number.isSafeInteger(Number(value))
? Number(value)
: value,
});
};
@@ -335,7 +337,14 @@ const retainHermesSource = (
next: Node,
retainedSources: ReadonlySet<Node>,
): Node => {
if (isScalar(previous) && isScalar(next) && previous.value === next.value) {
if (
isScalar(previous) &&
isScalar(next) &&
(previous.value === next.value ||
(typeof previous.value === "bigint" &&
typeof next.value === "number" &&
numericValuesEqual(previous.value, next.value)))
) {
if (!retainedSources.has(previous)) return previous;
const copied = previous.clone();
if (!isScalar(copied)) throw new TypeError("Invalid Hermes scalar copy");
@@ -441,6 +441,7 @@ describe("setup workflow action selection", () => {
{
client: "codex",
config_path: "/codex.toml",
command_identity: "foreign",
command: ["unrelated-agent", "serve"],
state: "stale",
remediation: "Refresh this registration.",
+2
View File
@@ -252,6 +252,7 @@ describe("setup workflow", () => {
{
client: "cursor",
config_path: "/cursor.json",
command_identity: "unknown",
command: [process.execPath, "./rea.mjs", "mcp"],
state: "stale",
remediation: "Run rea setup to refresh this registration.",
@@ -590,6 +591,7 @@ describe("setup workflow lifecycle and provider planning", () => {
{
client: "codex",
config_path: "/codex.toml",
command_identity: "owned",
command: ["rea", "mcp"],
state: "aligned",
remediation: null,
+1
View File
@@ -454,6 +454,7 @@ describe("setup scoped readiness", () => {
{
client: "codex",
config_path: "/codex.toml",
command_identity: "owned",
command: ["rea", "mcp"],
state: "aligned",
remediation: null,
+214
View File
@@ -0,0 +1,214 @@
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { runDoctor } from "./Doctor.js";
import { createDoctorHostFixture } from "./Doctor.fixture.js";
import { FakeSetupHost } from "./Setup.fixture.js";
import { readClientRegistrationStatuses } from "./ClientRegistrationStatus.js";
import { discoverSetupState } from "./SetupPlan.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../../tests/support/workspace/workspaceFixture.js";
/** Keep a workspace child under its root before any filesystem call. */
const pathInside = (root: string, ...parts: string[]): string => {
const candidate = resolve(root, ...parts);
const displacement = relative(root, candidate);
if (
displacement === ".." ||
displacement.startsWith(`..${sep}`) ||
isAbsolute(displacement)
)
throw new TypeError("temporary path escaped its workspace");
return candidate;
};
/** Admit the running entrypoint only when it stays inside its own directory. */
const symlinkTarget = (entrypoint: string): string => {
const target = resolve(entrypoint);
const directory = resolve(dirname(target));
if (!target.startsWith(`${directory}${sep}`))
throw new TypeError("entrypoint escaped its directory");
return target;
};
it.skipIf(process.platform === "win32")(
"retains doctor ownership observations when a launcher alias becomes unavailable before discovery",
async () => {
const workspace = await createTestWorkspace("rea-plan-command-identity-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const home = pathInside(workspace.root, ".");
const entrypoint = CURRENT_ENTRYPOINT_PATH;
if (entrypoint === undefined)
throw new Error("Missing current REA entrypoint");
const alias = pathInside(workspace.root, "alias.mjs");
await symlink(symlinkTarget(entrypoint), alias);
await mkdir(pathInside(workspace.root, ".cursor"));
const configPath = pathInside(workspace.root, ".cursor", "mcp.json");
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
rea: { command: process.execPath, args: [alias, "mcp"] },
},
}),
);
const registrations = await readClientRegistrationStatuses(
home,
entrypoint,
{ environment: {} },
);
expect(registrations).toEqual([
expect.objectContaining({ client: "cursor", state: "aligned" }),
]);
const initialDoctor = await runDoctor(
undefined,
createDoctorHostFixture({
clientRegistrations: async () => registrations,
}),
);
expect(initialDoctor.identity?.registrations).toEqual([
expect.objectContaining({ command_identity: "owned" }),
]);
await rm(alias);
const host = new FakeSetupHost();
host.clients = [{ name: "cursor", configPath }];
const discovery = await discoverSetupState({
host,
providerConfigured: true,
forceHopperInstall: false,
proposeHopper: false,
initialDoctor,
});
expect(discovery.defaultClientIds).toEqual(["cursor"]);
expect(discovery.clientStates).toEqual([
expect.objectContaining({ configured: true, status: "configured" }),
]);
},
);
it.each([
{
settings: { startup_timeout_sec: 0 },
launcher: { command: "rea", args: ["mcp"] },
state: "invalid",
identity: "owned",
status: "invalid",
},
{
settings: {},
launcher: { command: "./rea", args: ["mcp"] },
state: "unknown",
identity: "unknown",
status: "unknown",
},
{
settings: {},
launcher: { command: "other", args: ["serve"] },
state: "stale",
identity: "foreign",
status: "needs_configuration",
},
] as const)(
"keeps $identity launcher ownership separate from $state readiness",
async ({ settings, launcher, state, identity, status }) => {
const workspace = await createTestWorkspace("rea-plan-readiness-identity-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const home = pathInside(workspace.root, ".");
const configPath = pathInside(workspace.root, ".cursor", "mcp.json");
await mkdir(pathInside(workspace.root, ".cursor"));
await writeFile(
configPath,
JSON.stringify({ mcpServers: { rea: { ...launcher, ...settings } } }),
);
const registrations = await readClientRegistrationStatuses(
home,
undefined,
{ environment: {} },
);
expect(registrations).toEqual([
expect.objectContaining({ state, command_identity: identity }),
]);
const initialDoctor = await runDoctor(
undefined,
createDoctorHostFixture({
clientRegistrations: async () => registrations,
}),
);
const host = new FakeSetupHost();
host.clients = [{ name: "cursor", configPath }];
const discovery = await discoverSetupState({
host,
providerConfigured: true,
forceHopperInstall: false,
proposeHopper: false,
initialDoctor,
});
expect(discovery.defaultClientIds).toEqual([]);
expect(discovery.clientStates).toEqual([
expect.objectContaining({ status, configured: false }),
]);
},
);
it("offers detected missing and manual clients without treating absent launcher identity as unknown readiness", async () => {
const workspace = await createTestWorkspace("rea-plan-absent-launcher-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const home = pathInside(workspace.root, ".");
const configPath = pathInside(workspace.root, ".cursor", "mcp.json");
await mkdir(pathInside(workspace.root, ".cursor"));
await writeFile(configPath, JSON.stringify({ mcpServers: {} }));
const grokbotPath = pathInside(workspace.root, ".grokbot");
await mkdir(grokbotPath);
const registrations = await readClientRegistrationStatuses(home, undefined, {
environment: {},
});
expect(registrations).toEqual([
expect.objectContaining({
client: "cursor",
state: "missing",
command_identity: "unknown",
}),
expect.objectContaining({
client: "grok_bot",
state: "manual",
command_identity: "unknown",
}),
]);
const initialDoctor = await runDoctor(
undefined,
createDoctorHostFixture({ clientRegistrations: async () => registrations }),
);
const host = new FakeSetupHost();
host.clients = [
{ name: "cursor", configPath },
{
name: "grok_bot",
configPath: grokbotPath,
format: "unsupported",
},
];
const discovery = await discoverSetupState({
host,
providerConfigured: true,
forceHopperInstall: false,
proposeHopper: false,
initialDoctor,
});
expect(discovery.defaultClientIds).toEqual([]);
expect(discovery.clientStates).toEqual([
expect.objectContaining({
client: { name: "cursor", configPath },
status: "needs_configuration",
configured: false,
}),
expect.objectContaining({
client: host.clients[1],
status: "needs_configuration",
configured: false,
}),
]);
});
+6 -14
View File
@@ -1,5 +1,4 @@
import { SetupSkillInstallError, type SkillDestination } from "./SetupSkill.js";
import { classifyClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import type { LinuxPackageFamily } from "./HopperReleaseFeed.js";
import {
linuxHopperInstallDisclosure,
@@ -23,7 +22,6 @@ import {
import { redirectedWriteBlocker } from "./MsixPackageStorage.js";
import type { DoctorReport } from "./Doctor.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { macosMajorVersion } from "../domain/macosVersion.js";
/**
@@ -94,13 +92,7 @@ export const discoverSetupState = async (input: {
);
const clientStates = clients.map((client): SetupClientState => {
const registration = registrationByClient.get(client.name);
const commandIdentity =
registration === undefined
? undefined
: classifyClientRegistrationCommand(
registration.command,
CURRENT_ENTRYPOINT_PATH,
);
const commandIdentity = registration?.command_identity;
const ownedRegistration =
(registration?.state === "aligned" || registration?.state === "stale") &&
commandIdentity === "owned";
@@ -108,7 +100,10 @@ export const discoverSetupState = async (input: {
const status: SetupClientState["status"] =
registration?.state === "invalid"
? "invalid"
: registration?.state === "unknown" || commandIdentity === "unknown"
: registration?.state === "unknown" ||
((registration?.state === "aligned" ||
registration?.state === "stale") &&
commandIdentity === "unknown")
? "unknown"
: ownedRegistration && registration.state === "aligned"
? "configured"
@@ -128,10 +123,7 @@ export const discoverSetupState = async (input: {
.filter(
(registration) =>
(registration.state === "aligned" || registration.state === "stale") &&
classifyClientRegistrationCommand(
registration.command,
CURRENT_ENTRYPOINT_PATH,
) === "owned",
registration.command_identity === "owned",
)
.map(({ client }) => client);
return {
+66 -9
View File
@@ -16,6 +16,12 @@ import {
} from "./ClientConfigurationDocument.js";
import { err, ok, type Result } from "../domain/result.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
AnalysisError,
type AnalysisCleanupObservation,
} from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { copyFile, lstat, realpath, rm, rmdir } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { isDeepStrictEqual } from "node:util";
@@ -54,7 +60,9 @@ import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
InvalidUtf8FileError,
readRegularFileText,
bindRegularFileCleanup,
} from "./RegularFileRead.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
appDataRedirection,
@@ -132,6 +140,7 @@ export interface UninstallItem extends UninstallAction {
export interface UninstallResult {
readonly status: "complete" | "failed";
readonly items: readonly UninstallItem[];
readonly cleanup?: AnalysisCleanupObservation | undefined;
}
/** Applicability established once from product-specific discovery evidence. */
@@ -151,9 +160,30 @@ export interface UninstallHost {
inspectSkill?(): Promise<UninstallItem | undefined>;
removeSkill(): Promise<UninstallItem>;
purgeData(): Promise<readonly UninstallItem[]>;
close?(): Promise<string | undefined>;
close?(): Promise<AnalysisError | undefined>;
}
/** Preserve the resource owner's explicit retry capabilities through uninstall reporting. */
const uninstallCleanupError = (cause: unknown): AnalysisError => {
if (
cause instanceof AnalysisError &&
(cause.cleanupIncomplete ||
cause.hasCleanupOwner ||
cause.cleanup !== undefined ||
cause instanceof ProviderCleanupError)
)
return cause;
const cleanup = ArtifactReaderFailure.cleanupObservation(cause, []);
const error = new ProviderCleanupError(
"uninstall",
cleanup.resources,
{ reason: cleanup.reason },
{ cause, cleanup, operation: "uninstall" },
);
bindRegularFileCleanup(cause, error);
return error;
};
/**
* Remove only REA-owned registrations and managed files, optionally including
* local state. A client configuration that cannot be read safely stops the
@@ -206,19 +236,30 @@ export const runUninstall = async (
failed = true;
primaryFailure = cause;
}
let cleanupFailure: string | undefined;
let cleanupFailure: AnalysisError | undefined;
try {
cleanupFailure = await host.close?.();
const report = await host.close?.();
cleanupFailure =
report === undefined ? undefined : uninstallCleanupError(report);
} catch (cause: unknown) {
cleanupFailure = `Uninstall resource cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`;
cleanupFailure = uninstallCleanupError(cause);
}
if (cleanupFailure !== undefined && !cleanupFailure.cleanupIncomplete)
cleanupFailure = undefined;
if (failed) {
if (cleanupFailure !== undefined)
if (cleanupFailure !== undefined) {
if (primaryFailure instanceof AnalysisError)
throw analysisErrorWithCleanupFailure(
primaryFailure,
cleanupFailure,
"uninstall",
).bindCleanupDependencies([primaryFailure, cleanupFailure]);
throw new AggregateError(
[primaryFailure, new Error(cleanupFailure)],
[primaryFailure, cleanupFailure],
"Uninstall failed and its resource cleanup remains incomplete.",
{ cause: primaryFailure },
);
}
throw primaryFailure;
}
if (result === undefined)
@@ -226,9 +267,25 @@ export const runUninstall = async (
if (cleanupFailure === undefined) return result;
const items = collapseUninstallItems([
...result.items,
item("uninstall_cleanup", "failed", cleanupFailure),
item(
"uninstall_cleanup",
"failed",
`Uninstall resource cleanup failed: ${cleanupFailure.cleanup?.reason ?? cleanupFailure.message}${cleanupFailure.cleanupResources.length === 0 ? "" : ` (${cleanupFailure.cleanupResources.join(", ")})`}`,
),
]);
return { status: "failed", items };
const retained = cleanupFailure;
return {
status: "failed",
items,
get cleanup() {
return retained.cleanupIncomplete
? {
reason: retained.cleanup?.reason ?? retained.message,
resources: retained.cleanupResources,
}
: undefined;
},
};
};
/**
@@ -719,7 +776,7 @@ export const systemUninstallHost = (
await resources.close();
return undefined;
} catch (cause: unknown) {
return `Uninstall resource cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`;
return uninstallCleanupError(cause);
}
},
};
+2 -6
View File
@@ -78,11 +78,7 @@ export const existingMaintenanceScope = async (
);
const clients: string[] = [];
const unresolved: ClientRegistrationStatus[] = [];
for (const {
status: registration,
activation,
commandIdentity,
} of registrations) {
for (const { status: registration, activation } of registrations) {
if (registration.state === "unknown" || registration.state === "invalid") {
unresolved.push(registration);
continue;
@@ -90,7 +86,7 @@ export const existingMaintenanceScope = async (
if (
activation !== "enabled" ||
(registration.state !== "aligned" && registration.state !== "stale") ||
commandIdentity !== "owned"
registration.command_identity !== "owned"
)
continue;
clients.push(registration.client);
+5 -3
View File
@@ -200,20 +200,22 @@ export const parseEnvironmentValues = (
unresolvedSettings: ReadonlySet<string> = new Set(),
): Result<Environment, ConfigurationError> => {
// Unknown client-specific entries are preserved by registration owners and
// still cross the child-process boundary. Check their byte admission before
// still cross the child-process boundary. Check names and byte admission before
// the known-setting schema strips them; never include rejected values.
// Cordis expression values stay unknown, but their literal names are known.
const keys = new Set([...Object.keys(environment), ...unresolvedSettings]);
const invalidEntries = [...keys]
.filter(
(key) =>
key.includes("=") ||
key.includes("\0") ||
(Object.hasOwn(environment, key) && environment[key]?.includes("\0")),
)
.map((setting) => ({
setting,
constraint:
"Environment variable names and values must not contain NUL bytes",
constraint: setting.includes("=")
? "Environment variable names must not contain '='"
: "Environment variable names and values must not contain NUL bytes",
}));
if (invalidEntries.length > 0)
return err(
@@ -90,6 +90,7 @@ describe.each(["vscode", "copilot_cli", "opencode", "commandcode"])(
config_path: client.configPath,
state: "aligned",
command,
command_identity: "owned",
remediation: null,
},
]);
@@ -377,7 +377,7 @@ it.each([
REA_REFERENCE_SECRET_PATTERNS_JSON: '["*.private", "local/**"]',
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp",
CALLER_SETTING: "preserve exactly",
CALLER_SETTING: "preserve exactly = caller value",
["__proto__"]: "caller prototype setting",
constructor: "caller constructor setting",
toString: "caller string setting",
@@ -748,6 +748,11 @@ it("retains legacy OpenCode environment when migrating into a native server tabl
});
it.each([
[
"environment name containing an equals delimiter",
{ "CALLER=SETTING": "value" },
"CALLER=SETTING",
],
[
"non-string member",
{ REA_MCP_INPUT_SCHEMA_PROFILE: "compact", PORT: 8080 },
@@ -20,6 +20,7 @@ import { ClientConfigurationPublication } from "../../../src/application/ClientC
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
@@ -1819,11 +1820,21 @@ describe("layered uninstall outcomes", () => {
await mkdir(blocker);
const host = systemUninstallHost(home);
const cleanupReason = "Controlled final cleanup diagnostic.";
const cleanup = new ProviderCleanupError(
"uninstall",
[],
{ reason: cleanupReason },
{
operation: "uninstall",
cleanup: { reason: cleanupReason, resources: [] },
},
);
const blocked = await runUninstall(false, {
...host,
close: async () => {
await host.close?.();
return "Controlled final cleanup diagnostic.";
return cleanup;
},
});
@@ -1855,8 +1866,9 @@ describe("layered uninstall outcomes", () => {
expect(blocked.items).toContainEqual({
name: "uninstall_cleanup",
status: "failed",
detail: "Controlled final cleanup diagnostic.",
detail: `Uninstall resource cleanup failed: ${cleanupReason}`,
});
expect(blocked.cleanup).toEqual({ reason: cleanupReason, resources: [] });
await rm(blocker, { recursive: true });
const recovered = await runUninstall(false, systemUninstallHost(home));
@@ -273,6 +273,39 @@ it("folds recorded environment names on Windows", () => {
).toBeUndefined();
});
it("compares selected Windows aliases once using the winning caller spelling", () => {
const servers = { rea: { env: { JAVA_HOME: "/opt/jdk" } } };
const observed: [string, string][] = [];
const owned: [string, string][] = [];
expect(
recordedProviderSettings(
{
document: { mcpServers: servers },
servers,
serversPath: ["mcpServers"],
dialect: "json",
environmentKey: "env",
legacyServers: {},
},
undefined,
{ JAVA_HOME: "/overridden", java_home: "/opt/jdk" },
"win32",
(name, value) => {
owned.push([name, value]);
return true;
},
(name, value) => observed.push([name, value]),
),
).toEqual({
unrecorded: [],
differing: [],
unresolved: [],
owned: ["java_home"],
});
expect(observed).toEqual([["java_home", "/opt/jdk"]]);
expect(owned).toEqual(observed);
});
it("does not treat a map it could not read as absent", () => {
const servers = { rea: { env: { GHIDRA_INSTALL_DIR: 1 } } };
expect(
@@ -270,12 +270,10 @@ describe("client registration launcher identity", () => {
config_path: config,
remediation: expect.stringMatching(/\S/u),
});
expect(status).not.toHaveProperty("commandIdentity");
expect(await readClientRegistrationInspections(home, "")).toEqual([
expect.objectContaining({
status,
activation: "enabled",
commandIdentity: "unknown",
}),
]);
expect(await readFile(config, "utf8")).toBe(original);
@@ -209,16 +209,19 @@ it.skipIf(process.platform === "win32")(
});
expect(await inspect()).toContainEqual(
expect.objectContaining({
commandIdentity: "owned",
status: expect.objectContaining({ client: "cursor", state: "aligned" }),
status: expect.objectContaining({
client: "cursor",
state: "aligned",
command_identity: "owned",
}),
}),
);
await rm(executable);
expect(classifyClientRegistrationCommand(command, script)).toBe("unknown");
expect(await inspect()).toContainEqual(
expect.objectContaining({
commandIdentity: "unknown",
status: expect.objectContaining({
command_identity: "unknown",
client: "cursor",
state: "unknown",
command,
@@ -359,11 +359,42 @@ describe("skill cleanup diagnostics", () => {
detail: expect.stringContaining(manifest),
}),
);
expect(result.cleanup?.resources).toEqual([manifest]);
const primary = new ConfigurationError(
"Controlled uninstall discovery failure",
{
settings: [
{ setting: "fixture", constraint: "Controlled primary failure" },
],
},
);
const failure = await runUninstall(false, {
...host,
clients: async () => {
throw primary;
},
}).then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(AnalysisError);
if (!(failure instanceof AnalysisError))
throw new Error("Expected typed uninstall and cleanup failure");
expect(failure.cleanupIncomplete).toBe(true);
expect(failure.cleanupResources).toEqual([manifest]);
expect(failure.diagnostics?.primary_error).toEqual(
projectAnalysisError(primary),
);
expect(close.attempts.length).toBeGreaterThan(0);
expect(new Set(close.attempts).size).toBe(1);
const attemptsBeforeClose = close.attempts.length;
close.release();
expect(await host.close?.()).toBeUndefined();
expect(result.cleanup).toBeUndefined();
expect(failure.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(failure)).toMatchObject(
projectAnalysisError(primary),
);
expect(close.attempts.length).toBeGreaterThan(attemptsBeforeClose);
expect(new Set(close.attempts).size).toBe(1);
expect(close.attempts[0]?.fd).toBe(-1);
@@ -1043,7 +1043,7 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
expect(await fs.readFile(path, "utf8")).toBe(original);
expect((await fs.lstat(path)).mode & 0o777).toBe(0o400);
expect(state.publicationHandles).toHaveLength(0);
expect(await host.close?.()).toContain("cleanup failed");
expect(await host.close?.()).toBeInstanceOf(AnalysisError);
} finally {
if (inserted !== undefined) await fs.unlink(inserted);
await host.close?.();
@@ -1081,7 +1081,7 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
).toBe("failed");
expect(state.publicationHandles).toHaveLength(1);
expect(state.publicationHandles[0]!.fd).toBeGreaterThanOrEqual(0);
expect(await host.close?.()).toContain("cleanup failed");
expect(await host.close?.()).toBeInstanceOf(AnalysisError);
state.rejectPublicationMode = false;
state.rejectPublicationClose = false;
expect(await host.close?.()).toBeUndefined();
@@ -1351,15 +1351,13 @@ it.each(["setup", "uninstall"] as const)(
).toBe("failed");
}
const cleanupFailure = await host.close?.();
if (operation === "setup") {
expect(cleanupFailure).toBeInstanceOf(AnalysisError);
const retainedRoots = (await fs.readdir(root))
.filter((name) => name.startsWith(".rea-config-"))
.map((name) => join(root, name));
expect((cleanupFailure as AnalysisError).cleanupResources).toEqual(
retainedRoots,
);
} else expect(cleanupFailure).toContain("cleanup");
expect(cleanupFailure).toBeInstanceOf(AnalysisError);
const retainedRoots = (await fs.readdir(root))
.filter((name) => name.startsWith(".rea-config-"))
.map((name) => join(root, name));
expect((cleanupFailure as AnalysisError).cleanupResources).toEqual(
retainedRoots,
);
expect(
(await fs.readdir(root)).filter((name) =>
name.startsWith(".rea-config-"),
+12 -6
View File
@@ -266,30 +266,36 @@ describe("registration ownership in maintenance", () => {
).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: expect.objectContaining({ client: "codex", state: "stale" }),
status: expect.objectContaining({
client: "codex",
state: "stale",
command_identity: "owned",
}),
activation: "enabled",
commandIdentity: "owned",
}),
expect.objectContaining({
status: expect.objectContaining({ client: "cursor", state: "stale" }),
status: expect.objectContaining({
client: "cursor",
state: "stale",
command_identity: "foreign",
}),
activation: "enabled",
commandIdentity: "foreign",
}),
expect.objectContaining({
status: expect.objectContaining({
client: "opencode",
state: "stale",
command_identity: "owned",
}),
activation: "disabled",
commandIdentity: "owned",
}),
expect.objectContaining({
status: expect.objectContaining({
client: "claude_code",
state: "missing",
command_identity: "unknown",
}),
activation: "unknown",
commandIdentity: "unknown",
}),
]),
);