Files
rea/tests/process-global/updateMaintenance.test.ts
fd65e278be fix(setup): resolve the configuration MSIX Claude Desktop reads (#2426)
* fix(setup): resolve the configuration MSIX Claude Desktop reads

On Windows, setup, doctor and uninstall always used
%APPDATA%\Claude\claude_desktop_config.json. The MSIX build of Claude
Desktop (package family Claude_pzs8sxrjxfjjc) virtualizes AppData writes
and reads its package-private copy under
%LOCALAPPDATA%\Packages\<family>\LocalCache\Roaming\Claude once it exists.
Setup from an ordinary process therefore wrote a file the app never reads,
and doctor reported that file healthy.

Resolve the Claude Desktop location once, in supportedClients(): the
private copy when it exists, else the AppData file the package still
reads, else a new private copy. An unrecognized Claude_* publisher or
unreadable package storage is reported instead of falling back to AppData.

Compare one of the package's private files with its AppData path through
realpath.native to learn whether the running package started this
process. From that redirected view, or when no file can be compared,
setup and uninstall refuse to change Claude Desktop's configuration,
because the running app rewrites it from memory. From a redirected view
they also refuse writes under %APPDATA% and %LOCALAPPDATA% for other
clients and skills, which would land in the package's private storage.

Setup refuses to register a launcher stored in any MSIX package's
private storage, discloses which file the package reads, and tells the
user to quit Claude Desktop fully on every platform. Configuration fails
with target_changed if the package switched to its private copy after
the plan was shown. Uninstall removes REA from both files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(setup): record the verified MSIX Claude Desktop registration

On the Windows 11 host with the MSIX build 2.31226.1.0, setup from an
ordinary terminal with the app quit wrote the package-private copy. After
the app restarted, the entry persisted, doctor reported it aligned, and
Claude Desktop's MCP log showed REA starting and answering tools/list.
Uninstall against the real app remains unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(setup): format MSIX review corrections

* fix(setup): carry the admitted legacy path through uninstall removal

clientRegistrationRemoval returned a removeLegacy boolean, and removeClient
recomputed the legacy path separately, so TypeScript could not prove the
path existed when removeLegacy was true and `tsc --noEmit` failed in
Uninstall.ts. The decision now returns the admitted legacy path itself;
preflight and removal consume that one value.

The alias regression matched new-destination children with a "/"
separator while building paths with path.join, so it failed on Windows.
It now uses path.sep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(setup): split MSIX Claude Desktop lifecycle cases under the function length limit

The uninstall cases pushed the registration lifecycle describe past the 150-line lint limit. Group them separately, with the AppData registration case kept among the registration cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:04:22 +08:00

558 lines
19 KiB
TypeScript

import { mkdir, readFile, rm, writeFile, access } from "node:fs/promises";
import { delimiter, dirname, join, relative, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { err, ok } from "../../src/domain/result.js";
import { runUpdate, type UpdateResult } from "../../src/application/Update.js";
import { PRODUCT_IDENTITY } from "../../src/identity.js";
import { supportedClients } from "../../src/application/SupportedClients.js";
import {
existingMaintenanceScope,
planIntegrationMaintenance,
} from "../../src/application/UpdateMaintenance.js";
import {
runUpdateCommand,
systemUpdateHost,
} from "../../src/application/UpdateRuntime.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const entryPoint = resolve("scripts/rea.mjs");
let home: string;
beforeEach(async () => {
home = await createTestTempDirectory("rea-update-maintenance-");
for (const [name, value] of Object.entries({
HOME: home,
USERPROFILE: home,
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
CLAUDE_CONFIG_DIR: home,
CODEX_HOME: join(home, ".codex"),
COPILOT_HOME: join(home, ".copilot"),
GROK_HOME: join(home, ".grok"),
SAND_DATA_ROOT: join(home, ".grokbot"),
XDG_CONFIG_HOME: join(home, ".config"),
OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.jsonc"),
OMP_PROFILE: undefined,
PI_CODING_AGENT_DIR: undefined,
PI_CONFIG_DIR: undefined,
PI_PROFILE: undefined,
}))
vi.stubEnv(name, value);
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(home, { recursive: true, force: true });
});
const writeClient = async (id: string, content: string): Promise<string> => {
const client = supportedClients(home).find(({ name }) => name === id);
if (client === undefined) throw new Error(`Unknown test client ${id}`);
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(client.configPath, content);
return client.configPath;
};
const writeSkill = async (content: string): Promise<string> => {
const path = join(
home,
".agents",
"skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content);
return path;
};
const staleCodex =
'[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents@0.1.0", "mcp"]\n';
const action = {
id: "configure_client:codex",
kind: "configure_client",
label: "Codex",
target: "/test/.codex/config.toml",
detail: "Refresh the pinned REA version",
external: false,
operation: "update",
backupPath: "/test/.codex/config.toml.rea.backup",
commands: ["rea mcp"],
};
describe("OMP integration maintenance", () => {
it("refreshes an OMP entry that enabledServers forces on", async () => {
const entry = { type: "stdio", command: entryPoint, args: ["mcp"] };
await writeClient(
"omp",
JSON.stringify({ mcpServers: { rea: { ...entry, enabled: false } } }),
);
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toMatchObject({ clients: [] });
await writeClient(
"omp",
JSON.stringify({
mcpServers: { rea: { ...entry, enabled: false } },
enabledServers: ["rea"],
}),
);
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toMatchObject({ clients: ["omp"] });
});
it("preserves Grok Build disabled_mcp_servers during maintenance planning", async () => {
// Strict identity: a user-disabled integration must never be selected
// for maintenance, even with a healthy owned entry underneath.
const registration = `[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(entryPoint)}, "mcp"]\nstartup_timeout_sec = 30\n`;
const configPath = await writeClient(
"grok_build",
`disabled_mcp_servers = ["rea", "other"]\n${registration}`,
);
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toMatchObject({ clients: [] });
expect(await readFile(configPath, "utf8")).toContain(
'disabled_mcp_servers = ["rea", "other"]',
);
await writeFile(
configPath,
`disabled_mcp_servers = ["other"]\n${registration}`,
);
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toMatchObject({ clients: ["grok_build"] });
});
});
describe("maintenance activation and layered configuration", () => {
it("leaves a natively disabled ZCode registration outside maintenance", async () => {
const configPath = await writeClient(
"zcode",
JSON.stringify({
mcp: {
servers: {
rea: { command: entryPoint, args: ["mcp"], enable: false },
},
},
}),
);
const original = await readFile(configPath, "utf8");
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toMatchObject({ clients: [] });
expect(await readFile(configPath, "utf8")).toBe(original);
});
it("selects an enabled registration inherited through layered OpenCode files", async () => {
const directory = join(home, ".config", "opencode");
await mkdir(directory, { recursive: true });
await writeFile(
join(directory, "opencode.json"),
JSON.stringify({
mcp: { rea: { type: "local", command: [entryPoint, "mcp"] } },
}),
);
await writeClient("opencode", JSON.stringify({ theme: "user-selected" }));
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toMatchObject({ clients: ["opencode"] });
});
});
describe("existing REA integration maintenance", () => {
it("selects owned registrations while preserving unconfigured, foreign, and disabled choices", async () => {
await writeClient("codex", staleCodex);
await writeClient("claude_code", '{"mcpServers":{}}');
await writeClient(
"cursor",
'{"mcpServers":{"rea":{"command":"foreign-server","args":[]}}}',
);
await writeClient(
"opencode",
'{"mcp":{"rea":{"type":"local","command":["npx","-y","rea-agents@0.1.0","mcp"],"enabled":false}}}',
);
await writeClient(
"vscode",
JSON.stringify({
servers: { rea: { type: "stdio", command: entryPoint, args: ["mcp"] } },
}),
);
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toEqual({
clients: ["codex", "vscode"],
skill: false,
skillDestinations: [],
});
});
it("does not install a missing skill or overwrite an unrelated skill", async () => {
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toEqual({
clients: [],
skill: false,
skillDestinations: [],
});
await writeSkill("---\nname: another-skill\n---\nUser-authored content\n");
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toEqual({
clients: [],
skill: false,
skillDestinations: [],
});
});
it("includes an existing REA skill and leaves files untouched", async () => {
const path = await writeSkill(
`---\nname: ${PRODUCT_IDENTITY.skillName}\nmetadata:\n version: "1"\n---\n# REA\n`,
);
const original = await readFile(path, "utf8");
await expect(
existingMaintenanceScope(home, entryPoint, process.env),
).resolves.toEqual({
clients: [],
skill: true,
skillDestinations: [
{
client: "shared",
path: join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName),
},
],
});
expect(await readFile(path, "utf8")).toBe(original);
});
it("skips setup entirely when there is no existing integration", async () => {
let invoked = false;
const result = await planIntegrationMaintenance(
home,
entryPoint,
process.env,
async () => {
invoked = true;
return err("Unexpected setup");
},
);
expect(result).toEqual({ status: "current", plannedActions: [] });
expect(invoked).toBe(false);
});
it("returns exact backup and command evidence with a scoped unapplied setup command", async () => {
await writeClient("codex", staleCodex);
const recorded: string[][] = [];
const result = await planIntegrationMaintenance(
home,
entryPoint,
process.env,
async (command) => {
recorded.push([...command]);
return ok(
JSON.stringify({ status: "planned", plannedActions: [action] }),
);
},
);
expect(recorded).toEqual([
[
process.execPath,
entryPoint,
"setup",
"--client",
"codex",
"--skill=false",
"--dry-run",
"--json",
],
]);
expect(result).toEqual({
status: "planned",
scope: { clients: ["codex"], skill: false, skillDestinations: [] },
command: recorded[0]?.slice(0, -2),
plannedActions: [action],
});
});
it.each([
"{broken",
JSON.stringify({ status: "ready", plannedActions: [action] }),
JSON.stringify({
status: "planned",
plannedActions: [{ ...action, kind: "install_hopper", external: true }],
}),
JSON.stringify({
status: "planned",
plannedActions: [{ ...action, id: "configure_client:cursor" }],
}),
])("rejects malformed or out-of-scope subprocess plans", async (output) => {
await writeClient("codex", staleCodex);
expect(
await planIntegrationMaintenance(
home,
entryPoint,
process.env,
async () => ok(output),
),
).toMatchObject({ status: "unavailable" });
});
it("preserves actual setup failure diagnostics", async () => {
await writeClient("codex", staleCodex);
expect(
await planIntegrationMaintenance(
home,
entryPoint,
process.env,
async () => err("EACCES /test/.codex/config.toml"),
),
).toEqual({
status: "unavailable",
remediation: "EACCES /test/.codex/config.toml",
});
});
});
describe("updated executable maintenance planning", () => {
it("runs real setup as a noninteractive dry run and creates no new integrations or backups", async () => {
const configPath = await writeClient("codex", staleCodex);
const untouched = await writeClient("claude_code", '{"mcpServers":{}}');
vi.stubEnv("npm_command", "exec");
const result = await planIntegrationMaintenance(
home,
entryPoint,
process.env,
(command) => runUpdateCommand(command, process.env),
);
expect(result).toMatchObject({
status: "planned",
scope: { clients: ["codex"], skill: false },
plannedActions: [
{ id: "configure_client:codex", kind: "configure_client" },
],
});
expect(await readFile(configPath, "utf8")).toBe(staleCodex);
expect(await readFile(untouched, "utf8")).toBe('{"mcpServers":{}}');
await expect(access(`${configPath}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
await expect(
access(join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName)),
).rejects.toMatchObject({ code: "ENOENT" });
});
});
describe("update subprocess boundaries", () => {
it("exposes update as the sole CLI command", async () => {
const help = await runUpdateCommand(
[process.execPath, entryPoint, "--help"],
process.env,
);
expect(help.ok).toBe(true);
if (help.ok) {
expect(help.value).toMatch(/^\s{2}update\s/mu);
expect(help.value).not.toMatch(/^\s{2}upgrade\s/mu);
}
const removed = await runUpdateCommand(
[process.execPath, entryPoint, "upgrade", "--json"],
process.env,
);
expect(removed.ok).toBe(false);
});
it("captures output through stream closure and keeps arguments literal", async () => {
const literal = 'space and $(echo bad); & | "quotes"';
const result = await runUpdateCommand(
[
process.execPath,
"-e",
'process.stdout.write("x".repeat(1024 * 1024) + process.argv[1])',
literal,
],
process.env,
);
expect(result).toEqual(ok(`${"x".repeat(1024 * 1024)}${literal}`));
});
it("reports actual stderr and launch failures", async () => {
await expect(
runUpdateCommand(
[
process.execPath,
"-e",
'process.stderr.write("EACCES /test/install"); process.exitCode = 2',
],
process.env,
),
).resolves.toEqual(err("EACCES /test/install"));
const missing = await runUpdateCommand(
[join(home, "missing-executable")],
process.env,
);
expect(missing).toMatchObject({ ok: false });
if (!missing.ok) expect(missing.error).toContain("ENOENT");
});
it("verifies the requested package entry rather than PATH's rea", async () => {
const packageRoot = join(home, "package");
const script = join(packageRoot, "scripts", "rea.mjs");
await mkdir(dirname(script), { recursive: true });
await writeFile(script, 'process.stdout.write("3.3.0\\n")');
await expect(
systemUpdateHost(packageRoot, home).installedVersion({
manager: "npm",
prefix: home,
packageRoot,
}),
).resolves.toEqual(ok("3.3.0"));
});
});
describe("Bun global installation update", () => {
it.each([
["1.4.2", "updated", "absolute"],
["1.2.17", "updated", "absolute"],
["1.2.17", "updated", "relative"],
["1.2.14", "failed", "absolute"],
["1.2.17-canary.1", "failed", "absolute"],
["unknown", "failed", "absolute"],
])(
"handles Bun %s with outcome %s using %s global-directory configuration",
async (bunVersion, status, directoryMode) => {
// A recording `bun` on PATH crosses the real subprocess boundary without registry access.
const root = await createTestTempDirectory("rea-update-bun-");
const globalDirectory = join(root, "bun", "install", "global");
const packageRoot = join(
globalDirectory,
"node_modules",
PRODUCT_IDENTITY.packageName,
);
const versionPath = join(root, "installed-version");
const logPath = join(root, "bun-invocations.jsonl");
await mkdir(join(packageRoot, "scripts"), { recursive: true });
await writeFile(
join(globalDirectory, "package.json"),
'{"dependencies":{"rea-agents":"3.2.1"}}\n',
);
await writeFile(versionPath, "3.2.1");
await writeFile(
join(packageRoot, "scripts", "rea.mjs"),
`import { readFileSync } from "node:fs";\nprocess.stdout.write(readFileSync(${JSON.stringify(versionPath)}, "utf8") + "\\n");\n`,
);
const binDirectory = join(root, "bin");
const recorder = join(binDirectory, "record-bun.mjs");
await mkdir(binDirectory);
await writeFile(
recorder,
[
'import { appendFileSync, writeFileSync } from "node:fs";',
"const args = process.argv.slice(2);",
`appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ args, cwd: process.cwd(), globalDirectory: process.env.BUN_INSTALL_GLOBAL_DIR }) + "\\n");`,
`if (JSON.stringify(args) === '["--version"]') console.log(${JSON.stringify(bunVersion)});`,
`else if (JSON.stringify(args) === ${JSON.stringify(JSON.stringify(["info", PRODUCT_IDENTITY.packageName, "dist-tags.latest", "--json"]))}) process.stdout.write('"3.3.0"\\n');`,
`else if (JSON.stringify(args) === ${JSON.stringify(JSON.stringify(["add", "--global", "--ignore-scripts", `${PRODUCT_IDENTITY.packageName}@3.3.0`]))}) writeFileSync(${JSON.stringify(versionPath)}, "3.3.0");`,
"else process.exitCode = 2;",
"",
].join("\n"),
);
if (process.platform === "win32") {
await writeFile(
join(binDirectory, "bun.cmd"),
`@"${process.execPath}" "${recorder}" %*\r\n`,
);
} else {
await writeFile(
join(binDirectory, "bun"),
`#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(recorder)} "$@"\n`,
{ mode: 0o755 },
);
}
const pathKey =
Object.keys(process.env).find((key) => key.toUpperCase() === "PATH") ??
"PATH";
const environment: NodeJS.ProcessEnv = {
...process.env,
[pathKey]: `${binDirectory}${delimiter}${process.env[pathKey] ?? ""}`,
BUN_INSTALL: join(root, "bun"),
};
delete environment.BUN_INSTALL_GLOBAL_DIR;
if (directoryMode === "relative")
environment.BUN_INSTALL_GLOBAL_DIR = relative(root, globalDirectory);
const callerDirectory = process.cwd();
let result: UpdateResult;
try {
process.chdir(root);
result = await runUpdate(
"3.2.1",
systemUpdateHost(packageRoot, home, environment),
"structured",
);
} finally {
process.chdir(callerDirectory);
}
const invocations = (await readFile(logPath, "utf8"))
.trim()
.split("\n")
.map((line): unknown => JSON.parse(line));
const versionInvocation = {
args: ["--version"],
cwd: globalDirectory,
globalDirectory,
};
if (status === "failed") {
expect(result).toEqual({
status: "failed",
currentVersion: "3.2.1",
latestVersion: null,
reason: "version-check",
remediation: `REA updates for Bun global installations require Bun 1.2.17 or later; bun --version returned ${JSON.stringify(bunVersion)}. Upgrade Bun manually, then rerun rea update.`,
});
expect(invocations).toEqual([versionInvocation]);
expect(await readFile(versionPath, "utf8")).toBe("3.2.1");
return;
}
expect(result).toMatchObject({
status: "updated",
installMethod: "bun",
installedVersion: "3.3.0",
command: [
"bun",
"add",
"--global",
"--ignore-scripts",
`${PRODUCT_IDENTITY.packageName}@3.3.0`,
],
});
// `bun info` needs a project in its working directory, and the caller's
// directory must never be selected, so all commands run in the owner.
expect(invocations).toEqual([
versionInvocation,
{
args: [
"info",
PRODUCT_IDENTITY.packageName,
"dist-tags.latest",
"--json",
],
cwd: globalDirectory,
globalDirectory,
},
{
args: [
"add",
"--global",
"--ignore-scripts",
`${PRODUCT_IDENTITY.packageName}@3.3.0`,
],
cwd: globalDirectory,
globalDirectory,
},
]);
},
);
});