mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f5646e203 | ||
|
|
9281278d81 | ||
|
|
32c078382b | ||
|
|
ff5865acd5 | ||
|
|
d352b99b77 | ||
|
|
76dbf8f125 | ||
|
|
bcd45ac67b |
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
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -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 +
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -454,6 +454,7 @@ describe("setup scoped readiness", () => {
|
||||
{
|
||||
client: "codex",
|
||||
config_path: "/codex.toml",
|
||||
command_identity: "owned",
|
||||
command: ["rea", "mcp"],
|
||||
state: "aligned",
|
||||
remediation: null,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,8 +5,6 @@ import { expect, it, vi } from "vitest";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { apiContractSchema } from "../../domain/apiContract/apiContract.js";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import { projectAnalysisView } from "../../domain/analysisView/analysisView.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { inspectApiContractEvidence } from "./ApiContractService.js";
|
||||
import * as readOwner from "./ApiContractRead.js";
|
||||
@@ -319,89 +317,3 @@ it("cancels a cooperative document traversal after the actual verified file read
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["between-paths", "after-projection"] as const)(
|
||||
"preserves authenticated OpenAPI facts on cancellation %s",
|
||||
async (stage) => {
|
||||
const bytes = JSON.stringify({
|
||||
openapi: "3.1.2",
|
||||
info: { title: "Cancellation", version: "1" },
|
||||
paths: { "/first": { get: {} }, "/second": { post: {} } },
|
||||
});
|
||||
const { path } = await inputFile(bytes);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const realParse = parser.parseApiContractSteps;
|
||||
try {
|
||||
const baseline = await inspectApiContractEvidence({ path }, resources);
|
||||
if (!baseline.ok) throw baseline.error;
|
||||
const complete = apiContractSchema.parse(
|
||||
baseline.value.normalized_result,
|
||||
);
|
||||
const scheduled = vi
|
||||
.spyOn(parser, "parseApiContractSteps")
|
||||
.mockImplementationOnce(function* (...args) {
|
||||
const steps = realParse(...args);
|
||||
if (stage === "between-paths") {
|
||||
expect(steps.next().done).toBe(false);
|
||||
expect(steps.next().done).toBe(false);
|
||||
setImmediate(() => controller.abort(stage));
|
||||
return yield* steps;
|
||||
}
|
||||
const result = yield* steps;
|
||||
setImmediate(() => controller.abort(stage));
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
const result = await inspectApiContractEvidence({ path }, resources, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(stage);
|
||||
const evidence = parseEvidence(result.error.partialObservation);
|
||||
const retained = apiContractSchema.parse(evidence.normalized_result);
|
||||
expect(evidence.subject).toEqual(baseline.value.subject);
|
||||
expect(evidence.parameters).toEqual(baseline.value.parameters);
|
||||
expect(retained.artifact).toEqual(complete.artifact);
|
||||
expect(retained.contract_id).toBe(complete.contract_id);
|
||||
if (stage === "between-paths") {
|
||||
expect(retained.operations).toEqual(complete.operations.slice(0, 1));
|
||||
expect(retained.source_coverage.status).toBe("partial");
|
||||
expect(evidence.evidence_id).not.toBe(baseline.value.evidence_id);
|
||||
const page = projectAnalysisView(
|
||||
{
|
||||
evidenceId: evidence.evidence_id,
|
||||
operation: evidence.operation,
|
||||
predicateType: evidence.predicate_type,
|
||||
normalizedResult: evidence.normalized_result,
|
||||
limitations: evidence.limitations,
|
||||
},
|
||||
{
|
||||
kind: "api-contract",
|
||||
collection: "operations",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
expect(page).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
parent_evidence_id: evidence.evidence_id,
|
||||
items: [{ path: "/first", state: "declared" }],
|
||||
coverage: { exhausted: true },
|
||||
source_coverage: { status: "partial" },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
expect(retained).toEqual(complete);
|
||||
expect(evidence.evidence_id).toBe(baseline.value.evidence_id);
|
||||
}
|
||||
} finally {
|
||||
scheduled.mockRestore();
|
||||
}
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -11,8 +11,6 @@ import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
API_CONTRACT_OPERATION as operation,
|
||||
API_CONTRACT_PROVIDER,
|
||||
apiContractSchema,
|
||||
type ApiContract,
|
||||
type ApiContractArtifact,
|
||||
} from "../../domain/apiContract/apiContract.js";
|
||||
import {
|
||||
@@ -72,32 +70,8 @@ function* documentSteps(document: unknown, artifact: ApiContractArtifact) {
|
||||
return yield* parseApiContractSteps(document, artifact);
|
||||
}
|
||||
|
||||
type OpenApiInput = Extract<
|
||||
InspectApiContractInput,
|
||||
{ format: "openapi-json" }
|
||||
>;
|
||||
|
||||
const documentEvidence = (input: OpenApiInput, result: ApiContract): Evidence =>
|
||||
createEvidence(
|
||||
{ path: input.path, format: "file", sha256: result.artifact.sha256 },
|
||||
API_CONTRACT_PROVIDER,
|
||||
{
|
||||
operation,
|
||||
predicateType: "rea.api-contract-inventory",
|
||||
parameters: {
|
||||
path: input.path,
|
||||
format: input.format,
|
||||
max_file_bytes: input.max_file_bytes,
|
||||
},
|
||||
result,
|
||||
confidence: "observed",
|
||||
limitations: result.limitations,
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
},
|
||||
);
|
||||
|
||||
const importDocument = async (
|
||||
input: OpenApiInput,
|
||||
input: Extract<InspectApiContractInput, { format: "openapi-json" }>,
|
||||
bytes: Buffer,
|
||||
sha256: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -112,16 +86,26 @@ const importDocument = async (
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
if (!parsed.ok) {
|
||||
const partial = apiContractSchema.safeParse(
|
||||
parsed.error.partialObservation,
|
||||
);
|
||||
if (partial.success)
|
||||
parsed.error.partialObservation = documentEvidence(input, partial.data);
|
||||
return parsed;
|
||||
}
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = parsed.value;
|
||||
return completeProviderEvidence(
|
||||
documentEvidence(input, parsed.value),
|
||||
createEvidence(
|
||||
{ path: input.path, format: "file", sha256 },
|
||||
API_CONTRACT_PROVIDER,
|
||||
{
|
||||
operation,
|
||||
predicateType: "rea.api-contract-inventory",
|
||||
parameters: {
|
||||
path: input.path,
|
||||
format: input.format,
|
||||
max_file_bytes: input.max_file_bytes,
|
||||
},
|
||||
result,
|
||||
confidence: "observed",
|
||||
limitations: result.limitations,
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
},
|
||||
),
|
||||
signal,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PartialTaskMeasurement } from "./desktop/taskDifficulty.js";
|
||||
import type { AppleIpsPartialObservation } from "./native/appleIps.js";
|
||||
import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
|
||||
import type { ApiContract } from "./apiContract/apiContract.js";
|
||||
import type { GraphqlContract } from "./apiContract/graphqlContract.js";
|
||||
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
|
||||
import type { AnalysisSettingLimit } from "./analysisSettings.js";
|
||||
@@ -499,7 +498,6 @@ export type AnalysisPartialObservation =
|
||||
}
|
||||
| { readonly kind: "unretained-evidence"; readonly evidence: Evidence }
|
||||
| { readonly kind: "retained-evidence"; readonly evidence_id: string }
|
||||
| ApiContract
|
||||
| GraphqlContract
|
||||
| PlaywrightTrace
|
||||
| SavedAnalysisSnapshotPartialObservation
|
||||
|
||||
@@ -185,7 +185,7 @@ export const projectApiContractView = (
|
||||
unknowns:
|
||||
context.source_coverage.status === "partial"
|
||||
? [
|
||||
"This inventory is partial. Inspect limitations for coverage and query path-item-references and diagnostics for any retained source pointers.",
|
||||
"Unresolved or malformed declarations limit this inventory; query path-item-references and diagnostics for exact source pointers.",
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
import { apiContractSchema, type ApiContractArtifact } from "./apiContract.js";
|
||||
import { parseApiContractSteps } from "./parseApiContract.js";
|
||||
import { AnalysisCancelledError } from "../analysisErrorCore.js";
|
||||
|
||||
const artifact: ApiContractArtifact = {
|
||||
path: "/contract.json",
|
||||
@@ -306,39 +305,9 @@ it("handles 3.2 QUERY and case-sensitive additional methods, diagnosing invalid
|
||||
|
||||
it("bounds expanded identities and source pointers before building a giant row", () => {
|
||||
const result = parse(
|
||||
document({
|
||||
"/retained": { get: {} },
|
||||
[`/${"a".repeat(1_100_000)}`]: { get: {} },
|
||||
}),
|
||||
document({ [`/${"a".repeat(1_100_000)}`]: { get: {} } }),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected a retained-facts refusal");
|
||||
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
const partial = apiContractSchema.parse(result.error.partialObservation);
|
||||
expect(partial.operations.map((row) => row.path)).toEqual(["/retained"]);
|
||||
expect(partial.source_coverage.status).toBe("partial");
|
||||
});
|
||||
|
||||
it("retains an interpreted path when cancellation interrupts traversal", () => {
|
||||
const input = document({
|
||||
"/first": { get: { operationId: "first" } },
|
||||
"/second": { get: { operationId: "second" } },
|
||||
});
|
||||
const steps = parseApiContractSteps(input, artifact);
|
||||
for (let checkpoint = 0; checkpoint < 3; checkpoint++)
|
||||
expect(steps.next().done).toBe(false);
|
||||
const cancellation = new AnalysisCancelledError("inspect_api_contract");
|
||||
const stopped = steps.throw(cancellation);
|
||||
if (!stopped.done || stopped.value.ok)
|
||||
throw new Error("Expected cancellation");
|
||||
expect(stopped.value.error).toBe(cancellation);
|
||||
const partial = apiContractSchema.parse(cancellation.partialObservation);
|
||||
const complete = report(input);
|
||||
expect(partial.operations).toEqual(complete.operations.slice(0, 1));
|
||||
expect(partial.summary.operation_slots).toBe(1);
|
||||
expect(partial.source_coverage).toMatchObject({
|
||||
status: "partial",
|
||||
path_entries_examined: 1,
|
||||
});
|
||||
expect(complete.source_coverage.status).toBe("complete-within-scope");
|
||||
if (!result.ok)
|
||||
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
@@ -317,17 +316,14 @@ function* pathItem(
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
interface ContractReportContext {
|
||||
readonly openapi: string;
|
||||
readonly info: ApiContract["info"];
|
||||
readonly pathsState: ApiContract["source_coverage"]["paths_state"];
|
||||
examined: number;
|
||||
}
|
||||
|
||||
const contractReport = (
|
||||
const completedInventory = (
|
||||
inventory: ContractInventory,
|
||||
context: ContractReportContext,
|
||||
incomplete = false,
|
||||
context: {
|
||||
openapi: string;
|
||||
info: ApiContract["info"];
|
||||
pathsState: ApiContract["source_coverage"]["paths_state"];
|
||||
examined: number;
|
||||
},
|
||||
): ApiContract => {
|
||||
const artifact = inventory.artifact;
|
||||
let declared = 0;
|
||||
@@ -352,9 +348,7 @@ const contractReport = (
|
||||
source_coverage: {
|
||||
scope: "inline-path-operations",
|
||||
status:
|
||||
incomplete ||
|
||||
inventory.references.length > 0 ||
|
||||
inventory.diagnostics.length > 0
|
||||
inventory.references.length > 0 || inventory.diagnostics.length > 0
|
||||
? "partial"
|
||||
: "complete-within-scope",
|
||||
paths_state: context.pathsState,
|
||||
@@ -364,11 +358,6 @@ const contractReport = (
|
||||
duplicate_json_members: "last-value-wins",
|
||||
},
|
||||
limitations: [
|
||||
...(incomplete
|
||||
? [
|
||||
"Traversal stopped before the inventory was complete. Counts describe the retained rows.",
|
||||
]
|
||||
: []),
|
||||
"Operations are document declarations, not evidence of runtime implementation, reachability or authorization.",
|
||||
"Only inline operations under paths are inventoried; callbacks, webhooks, components, servers, parameters, schemas, request/response contracts and security are not analyzed.",
|
||||
"No reference is resolved, including local fragments. A Path Item reference makes its effective operations unknown; adjacent inline slots are preserved without merging referenced definitions.",
|
||||
@@ -379,32 +368,6 @@ const contractReport = (
|
||||
};
|
||||
};
|
||||
|
||||
function* collectPaths(
|
||||
sourcePaths: Record<string, unknown>,
|
||||
inventory: ContractInventory,
|
||||
context: ContractReportContext,
|
||||
modern: boolean,
|
||||
): Generator<void, Result<void, AnalysisError>> {
|
||||
for (const path in sourcePaths) {
|
||||
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
|
||||
yield;
|
||||
context.examined++;
|
||||
// Reserve expanded pointer/path strings before escaping or building a row.
|
||||
const admitted = inventory.reserve(512 + path.length * 16);
|
||||
if (!admitted.ok) return admitted;
|
||||
const pointer = `/paths/${pointerPart(path)}`;
|
||||
const read = path.startsWith("/")
|
||||
? yield* pathItem(sourcePaths[path], { inventory, path, pointer, modern })
|
||||
: inventory.diagnostic(
|
||||
"invalid-path-key",
|
||||
pointer,
|
||||
"A Paths entry must start with /; it was not interpreted as an endpoint.",
|
||||
);
|
||||
if (!read.ok) return read;
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Enumerate declarations only; never resolve references, servers, callbacks or runtime expressions. */
|
||||
export function* parseApiContractSteps(
|
||||
value: unknown,
|
||||
@@ -454,32 +417,36 @@ export function* parseApiContractSteps(
|
||||
);
|
||||
if (!diagnosed.ok) return diagnosed;
|
||||
}
|
||||
const context: ContractReportContext = {
|
||||
openapi: root.openapi,
|
||||
info: info.value,
|
||||
pathsState,
|
||||
examined: 0,
|
||||
};
|
||||
const interrupted = (error: AnalysisError) => {
|
||||
const retainedRows =
|
||||
inventory.operations.length +
|
||||
inventory.references.length +
|
||||
inventory.diagnostics.length;
|
||||
if (retainedRows > 0)
|
||||
error.retainPartialObservation(contractReport(inventory, context, true));
|
||||
return err(error);
|
||||
};
|
||||
try {
|
||||
const read = yield* collectPaths(
|
||||
paths ?? {},
|
||||
inventory,
|
||||
context,
|
||||
version[1] === "2",
|
||||
);
|
||||
if (!read.ok) return interrupted(read.error);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError) return interrupted(cause);
|
||||
throw cause;
|
||||
let examined = 0;
|
||||
const sourcePaths = paths ?? {};
|
||||
for (const path in sourcePaths) {
|
||||
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
|
||||
yield;
|
||||
examined++;
|
||||
// Reserve expanded pointer/path strings before escaping or building a row.
|
||||
const admitted = inventory.reserve(512 + path.length * 16);
|
||||
if (!admitted.ok) return admitted;
|
||||
const pointer = `/paths/${pointerPart(path)}`;
|
||||
const read = path.startsWith("/")
|
||||
? yield* pathItem(sourcePaths[path], {
|
||||
inventory,
|
||||
path,
|
||||
pointer,
|
||||
modern: version[1] === "2",
|
||||
})
|
||||
: inventory.diagnostic(
|
||||
"invalid-path-key",
|
||||
pointer,
|
||||
"A Paths entry must start with /; it was not interpreted as an endpoint.",
|
||||
);
|
||||
if (!read.ok) return read;
|
||||
}
|
||||
return ok(contractReport(inventory, context));
|
||||
return ok(
|
||||
completedInventory(inventory, {
|
||||
openapi: root.openapi,
|
||||
info: info.value,
|
||||
pathsState,
|
||||
examined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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-"),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user