Compare commits

...
Author SHA1 Message Date
github-actions[bot]andmorluto 27dc42d102 chore(release/6.7.0): release rea-agents 6.7.0 (#4983)
* chore(release/6.7.0): release rea-agents 6.7.0

* docs(release): finalize frozen 6.7.0 contribution notes

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-18 15:40:45 +08:00
Kaoru0822andClaude Opus 5.5 b83b9caf4a fix(setup): edit the duplicate JSON member that clients read (#4957)
JSON.parse, JSONC parse, and native Claude Code keep the last duplicate
object member, and parseClientConfiguration therefore validates the last
one. The shared setup/uninstall serializer instead edited text through
jsonc-parser's modify and findNodeAtLocation, which select the first
matching member, so setup and uninstall on configurations with repeated
server tables or rea members wrote into a shadowed member and failed
readback.

Locate every JSON text edit by the effective (last) member in one place,
replacing the first-member modify path. Removing an effective member
that shadows an earlier duplicate would activate that duplicate, whatever
its ownership, so the serializer now returns a tagged shadowed_member
failure and setup and uninstall refuse before writing a backup or the
configuration.

Fixes #4952

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:15:47 -07:00
morluto bb212ffecb fix(tests): inherit server identity deadline policy
Remove per-case ten-second overrides from MCP catalog and identity checks. Keep the suite-owned bounded ordinary and coverage deadlines, all assertions, and session cleanup.

Fixes #4967
2026-10-18 13:01:44 +08:00
morluto 6b180783ed fix(tests): budget CLI coverage from parsed options
Use the pinned Vitest parser for coverage and shard flags. Keep ordinary CLI workers at 384 MiB and covered workers at the tested 512 MiB with unchanged concurrency. Retain complete coverage and add actual-config regressions for false, provider-only and spaced-shard options.

Validation: 27 focused cases; 16 covered cases with CLI preloaded; 5 docs checks; typecheck/lint; exact discovery of 1578 files. Measured covered control: 22.18s elapsed, 46.52s CPU, about 2.33 GiB sampled summed process-family RSS on macOS arm64. Full Linux CI pending. No runtime/device verification claimed.

Fixes #4960
2026-10-18 12:43:17 +08:00
morluto 8cbaf6c702 fix(tests): isolate command finalization module mocks
Move the five registered WASM finalization cases into the existing per-file isolated project. Preserve all result, exit-code, observation and cleanup assertions. A preloaded shared CLI graph reproduces three failures before the move; the isolated covered packet passes.

Fixes #4959
2026-10-18 12:42:26 +08:00
21 changed files with 605 additions and 127 deletions

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "6.6.0"
".": "6.7.0"
}
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [6.7.0](https://github.com/morluto/rea/compare/rea-agents-6.6.0...rea-agents-6.7.0) (2026-10-18)
### Highlights
- Client configuration edits follow the duplicate-member semantics used by MCP clients.
### Changes
- Includes the [6.6.0 changes and migration notes](https://github.com/morluto/rea/releases/tag/rea-agents-6.6.0). That version was tagged but was not published to npm.
### Fixes
- **Client configuration:** Setup edits the last repeated JSON/JSONC member, matching the value clients actually read. Setup and uninstall refuse a removal that would reactivate a shadowed duplicate, preserving the original configuration. ([#4957](https://github.com/morluto/rea/pull/4957), [morluto/rea#4952](https://github.com/morluto/rea/issues/4952)). Thanks @kaoru0822-kitauji, @xu91102.
Full contribution record: [4 commits and 1 merged PR](https://github.com/morluto/rea/blob/rea-agents-6.7.0/docs/releases/6.7.0.contributions.json).
## [6.6.0](https://github.com/morluto/rea/compare/rea-agents-6.5.0...rea-agents-6.6.0) (2026-10-18)
### Highlights
+3 -3
View File
@@ -276,14 +276,14 @@ Register with the variable unset:
<!-- x-release-please-start-version -->
```bash
claude mcp add --scope local rea -- npx -y rea-agents@6.6.0 mcp
claude mcp add --scope local rea -- npx -y rea-agents@6.7.0 mcp
```
On native Windows, use the same project directory and wrap the package-runner
shim so the stdio client can launch it:
```powershell
claude mcp add --scope local rea -- cmd.exe /d /c npx -y rea-agents@6.6.0 mcp
claude mcp add --scope local rea -- cmd.exe /d /c npx -y rea-agents@6.7.0 mcp
```
<!-- x-release-please-end -->
@@ -1187,7 +1187,7 @@ For a client that requires manual configuration, use:
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@6.6.0", "mcp"]
"args": ["-y", "rea-agents@6.7.0", "mcp"]
}
}
}
+112
View File
@@ -0,0 +1,112 @@
{
"schemaVersion": 1,
"repository": "morluto/rea",
"version": "6.7.0",
"base": "2c4338db1fdb7773e05376301c558cff2a0b4b80",
"target": "b83b9caf4a5f0241a5fd370792f1aed1bffbb2cf",
"commits": [
{
"sha": "8cbaf6c7021b97dd4de6b5771c35fbed06b240e9",
"subject": "fix(tests): isolate command finalization module mocks",
"body": "fix(tests): isolate command finalization module mocks\n\nMove the five registered WASM finalization cases into the existing per-file isolated project. Preserve all result, exit-code, observation and cleanup assertions. A preloaded shared CLI graph reproduces three failures before the move; the isolated covered packet passes.\n\nFixes #4959\n",
"parents": ["2c4338db1fdb7773e05376301c558cff2a0b4b80"],
"authors": [
{
"name": "morluto",
"email": "76467478+morluto@users.noreply.github.com",
"user": {
"login": "morluto",
"__typename": "User"
}
}
],
"pullRequests": []
},
{
"sha": "6b180783ed04ebc87548c8385c80e05725804c47",
"subject": "fix(tests): budget CLI coverage from parsed options",
"body": "fix(tests): budget CLI coverage from parsed options\n\nUse the pinned Vitest parser for coverage and shard flags. Keep ordinary CLI workers at 384 MiB and covered workers at the tested 512 MiB with unchanged concurrency. Retain complete coverage and add actual-config regressions for false, provider-only and spaced-shard options.\n\nValidation: 27 focused cases; 16 covered cases with CLI preloaded; 5 docs checks; typecheck/lint; exact discovery of 1578 files. Measured covered control: 22.18s elapsed, 46.52s CPU, about 2.33 GiB sampled summed process-family RSS on macOS arm64. Full Linux CI pending. No runtime/device verification claimed.\n\nFixes #4960\n",
"parents": ["8cbaf6c7021b97dd4de6b5771c35fbed06b240e9"],
"authors": [
{
"name": "morluto",
"email": "76467478+morluto@users.noreply.github.com",
"user": {
"login": "morluto",
"__typename": "User"
}
}
],
"pullRequests": []
},
{
"sha": "bb212ffecb47e129cc569b9867b2b6e7c9b65685",
"subject": "fix(tests): inherit server identity deadline policy",
"body": "fix(tests): inherit server identity deadline policy\n\nRemove per-case ten-second overrides from MCP catalog and identity checks. Keep the suite-owned bounded ordinary and coverage deadlines, all assertions, and session cleanup.\n\nFixes #4967\n",
"parents": ["6b180783ed04ebc87548c8385c80e05725804c47"],
"authors": [
{
"name": "morluto",
"email": "76467478+morluto@users.noreply.github.com",
"user": {
"login": "morluto",
"__typename": "User"
}
}
],
"pullRequests": []
},
{
"sha": "b83b9caf4a5f0241a5fd370792f1aed1bffbb2cf",
"subject": "fix(setup): edit the duplicate JSON member that clients read (#4957)",
"body": "fix(setup): edit the duplicate JSON member that clients read (#4957)\n\nJSON.parse, JSONC parse, and native Claude Code keep the last duplicate\nobject member, and parseClientConfiguration therefore validates the last\none. The shared setup/uninstall serializer instead edited text through\njsonc-parser's modify and findNodeAtLocation, which select the first\nmatching member, so setup and uninstall on configurations with repeated\nserver tables or rea members wrote into a shadowed member and failed\nreadback.\n\nLocate every JSON text edit by the effective (last) member in one place,\nreplacing the first-member modify path. Removing an effective member\nthat shadows an earlier duplicate would activate that duplicate, whatever\nits ownership, so the serializer now returns a tagged shadowed_member\nfailure and setup and uninstall refuse before writing a backup or the\nconfiguration.\n\nFixes #4952\n\nCo-authored-by: Claude Opus 5.5 <noreply@anthropic.com>",
"parents": ["bb212ffecb47e129cc569b9867b2b6e7c9b65685"],
"authors": [
{
"name": "Kaoru0822",
"email": "kanadekitauji@proton.me",
"user": {
"login": "kaoru0822-kitauji",
"__typename": "User"
}
},
{
"name": "Claude Opus 5.5",
"email": "noreply@anthropic.com",
"user": {
"login": "claude",
"__typename": "User"
}
}
],
"pullRequests": [4957]
}
],
"pullRequests": [
{
"number": 4957,
"title": "fix(setup): edit the duplicate JSON member that clients read",
"url": "https://github.com/morluto/rea/pull/4957",
"author": {
"login": "kaoru0822-kitauji",
"__typename": "User"
},
"mergeCommit": {
"oid": "b83b9caf4a5f0241a5fd370792f1aed1bffbb2cf"
},
"issues": [
{
"number": 4952,
"url": "https://github.com/morluto/rea/issues/4952",
"author": {
"login": "xu91102",
"__typename": "User"
},
"repository": {
"nameWithOwner": "morluto/rea"
}
}
]
}
]
}
+8 -3
View File
@@ -110,9 +110,14 @@ rendered declarations. Keep canonical rejection checks separate from the
intentionally reduced compact advertisement, and probe actual client renderers
when changing schema representations.
The reused CLI boundary workers cap V8 old space at 384 MiB, based on their
profiled allocation and cleanup behavior. This encourages collection between
files; spawned REA processes retain their own heap settings.
The reused CLI boundary workers cap V8 old space at 384 MiB for ordinary runs
and 512 MiB with coverage enabled. Covered ADB fixtures exhaust the ordinary
384 MiB budget; those fixtures also complete with the shared CLI graph preloaded
at 512 MiB. This is a measured capacity control, not a suite-wide speedup claim.
Both profiles keep the
same bounded worker count, and spawned REA processes retain their own heap
settings. Explicitly disabling coverage retains the ordinary profile; coverage
provider options alone do not enable instrumentation.
Vitest worker limits do not bound subprocesses launched within a test. Bound
those batches separately so repeated CLI validation cannot oversubscribe the
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rea-agents",
"version": "6.6.0",
"version": "6.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rea-agents",
"version": "6.6.0",
"version": "6.7.0",
"license": "MIT",
"dependencies": {
"@babel/parser": "8.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rea-agents",
"mcpName": "io.github.morluto/rea",
"version": "6.6.0",
"version": "6.7.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"license": "MIT",
"repository": {
+2 -2
View File
@@ -8,13 +8,13 @@
"url": "https://github.com/morluto/rea",
"source": "github"
},
"version": "6.6.0",
"version": "6.7.0",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "rea-agents",
"version": "6.6.0",
"version": "6.7.0",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
@@ -8,6 +8,14 @@ import {
const registration = { command: "rea", args: ["mcp"] };
const serializedText = (
...input: Parameters<typeof serializeClientConfiguration>
): string => {
const serialized = serializeClientConfiguration(...input);
if (!serialized.ok) throw new Error("TOML serialization was refused");
return serialized.value;
};
describe("TOML configuration source capacity", () => {
it.each(["\n", "\r\n"])(
"retains adjacent assignments and multiline values with %j breaks",
@@ -29,18 +37,14 @@ describe("TOML configuration source capacity", () => {
...parsed.servers,
rea: registration,
});
const configured = serializeClientConfiguration(
desired,
"toml",
original,
);
const configured = serializedText(desired, "toml", original);
expect(
configured.startsWith("\uFEFF" + prefix.replaceAll("\n", newline)),
).toBe(true);
expect(parseClientConfiguration(configured, "toml").document).toEqual(
desired,
);
const removed = serializeClientConfiguration(
const removed = serializedText(
withClientServers(parsed, {}),
"toml",
configured,
@@ -61,16 +65,12 @@ describe("TOML configuration source capacity", () => {
...parsed.servers,
rea: registration,
});
const configured = serializeClientConfiguration(desired, "toml", original);
const configured = serializedText(desired, "toml", original);
expect(configured).toContain(`NOTE="""${note}"""`);
expect(parseClientConfiguration(configured, "toml").document).toEqual(
desired,
);
const removed = serializeClientConfiguration(
parsed.document,
"toml",
configured,
);
const removed = serializedText(parsed.document, "toml", configured);
expect(removed).toContain(`NOTE="""${note}"""`);
expect(parseClientConfiguration(removed, "toml").document).toEqual(
parsed.document,
@@ -87,7 +87,7 @@ describe("TOML configuration source capacity", () => {
...parsed.servers,
rea: registration,
});
const configured = serializeClientConfiguration(desired, "grok", original);
const configured = serializedText(desired, "grok", original);
const result = parseClientConfiguration(configured, "grok");
expect(result.document.disabled_mcp_servers).toEqual(
Array<string>(500_000).fill("other"),
+143 -54
View File
@@ -15,16 +15,18 @@ import {
import {
applyEdits,
createScanner,
findNodeAtLocation,
modify,
format as formatJson,
parse as parseJsonc,
parseTree,
printParseErrorCode,
type Edit,
type Node,
type ParseError,
type SyntaxKind,
} from "jsonc-parser";
import { z } from "zod";
import { err, ok, type Result } from "../domain/result.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -1312,6 +1314,37 @@ const nextJsoncToken = (
return { kind, offset: scanner.getTokenOffset() };
};
const isJsonMember = (property: Node, key: string): boolean =>
property.children?.length === 2 && property.children[0]?.value === key;
/**
* The property a client reads for `key`. `JSON.parse` and JSONC `parse` keep
* the last duplicate member, while jsonc-parser's `findNodeAtLocation` and
* `modify` select the first, so JSON text edits locate members only here.
*/
const effectiveJsonProperty = (object: Node, key: string): Node | undefined =>
object.children?.findLast((property) => isJsonMember(property, key));
/** Apply one edit and format the lines it touched, as `modify` does. */
const applyFormattedJsonEdit = (text: string, edit: Edit): string => {
const edited = applyEdits(text, [edit]);
let begin = edit.offset;
let end = edit.offset + edit.content.length;
if (edit.length === 0) {
while (begin > 0 && !"\r\n".includes(edited.charAt(begin - 1))) begin -= 1;
while (end < edited.length && !"\r\n".includes(edited.charAt(end)))
end += 1;
}
return applyEdits(
edited,
formatJson(
edited,
{ offset: begin, length: end - begin },
{ insertSpaces: true, tabSize: 2, eol: "\n", keepLines: false },
),
);
};
/**
* Delete a property by its JSONC AST span and the separator needed to keep its
* parent object valid. All whitespace and comments outside those tokens stay
@@ -1319,80 +1352,136 @@ const nextJsoncToken = (
*/
const deleteJsonProperty = (
text: string,
path: readonly string[],
): string | undefined => {
const root = parseTree(text, [], { allowTrailingComma: true });
const value =
root === undefined ? undefined : findNodeAtLocation(root, [...path]);
const property = value?.parent;
const parent = property?.parent;
object: Node,
property: Node,
): string => {
const siblings = object.children ?? [];
const propertyIndex = siblings.indexOf(property);
const previous = siblings[propertyIndex - 1];
const next = siblings[propertyIndex + 1];
const comma = nextJsoncToken(
text,
previous === undefined
? property.offset + property.length
: previous.offset + previous.length,
);
if (
property === undefined ||
parent?.type !== "object" ||
property.type !== "property" ||
parent.children === undefined
propertyIndex < 0 ||
(comma.kind !== JSON_COMMA &&
(previous !== undefined || next !== undefined))
)
return undefined;
const propertyIndex = parent.children.indexOf(property);
if (propertyIndex < 0) return undefined;
const previous = parent.children[propertyIndex - 1];
const next = parent.children[propertyIndex + 1];
let commaOffset: number | undefined;
if (previous !== undefined) {
const comma = nextJsoncToken(text, previous.offset + previous.length);
if (comma.kind !== JSON_COMMA) return undefined;
commaOffset = comma.offset;
} else {
const token = nextJsoncToken(text, property.offset + property.length);
if (token.kind === JSON_COMMA) commaOffset = token.offset;
else if (next !== undefined) return undefined;
}
throw new TypeError("JSON property separator was not found");
const edits = [
{ offset: property.offset, length: property.length, content: "" },
...(commaOffset === undefined
? []
: [{ offset: commaOffset, length: 1, content: "" }]),
...(comma.kind === JSON_COMMA
? [{ offset: comma.offset, length: 1, content: "" }]
: []),
];
return applyEdits(text, edits);
};
/** Serialize a validated client document, retaining JSONC and TOML comments. */
/** A JSON edit refused because it would activate a shadowed duplicate member. */
export interface ClientConfigurationShadowedMember {
readonly kind: "shadowed_member";
/** Key path of the effective member whose removal was refused. */
readonly path: readonly string[];
}
/**
* Set or delete the member a client reads at `path`, keeping every byte
* outside that member. Shadowed duplicates stay in place, and deleting a
* member that shadows one is refused so the earlier member never becomes
* active, whatever its ownership.
*/
const editJsonMember = (
text: string,
path: readonly string[],
value: unknown,
): Result<string, ClientConfigurationShadowedMember> => {
let object = parseTree(text, [], { allowTrailingComma: true });
for (const [index, key] of path.entries()) {
if (object?.type !== "object")
throw new TypeError("JSON edit parent is not an object");
const property = effectiveJsonProperty(object, key);
const member = property?.children?.[1];
if (property === undefined || member === undefined) {
if (value === undefined) return ok(text);
const content = `${JSON.stringify(key)}: ${JSON.stringify(
path
.slice(index + 1)
.reduceRight<unknown>((nested, name) => ({ [name]: nested }), value),
)}`;
const previous = object.children?.at(-1);
return ok(
applyFormattedJsonEdit(
text,
previous === undefined
? { offset: object.offset + 1, length: 0, content }
: {
offset: previous.offset + previous.length,
length: 0,
content: `,${content}`,
},
),
);
}
if (index < path.length - 1) {
object = member;
continue;
}
if (value !== undefined)
return ok(
applyFormattedJsonEdit(text, {
offset: member.offset,
length: member.length,
content: JSON.stringify(value),
}),
);
if (
object.children?.some(
(sibling) => sibling !== property && isJsonMember(sibling, key),
)
)
return err({ kind: "shadowed_member", path });
return ok(deleteJsonProperty(text, object, property));
}
throw new TypeError("JSON edit path is empty");
};
/**
* Serialize a validated client document, retaining JSONC and TOML comments.
* JSON edits fail only when they would activate a shadowed duplicate member.
*/
export const serializeClientConfiguration = (
document: Record<string, unknown>,
format: ClientConfigurationFormat | undefined,
originalText?: string,
editedPaths?: readonly (readonly string[])[],
): string => {
): Result<string, ClientConfigurationShadowedMember> => {
if (format === undefined || format === "unsupported")
throw new TypeError("client does not have a supported MCP config format");
if (format === "toml")
return serializeCodexConfiguration(document, originalText);
return ok(serializeCodexConfiguration(document, originalText));
if (format === "grok")
return serializeGrokConfiguration(document, originalText);
return ok(serializeGrokConfiguration(document, originalText));
if (format === "hermes")
return serializeHermesConfiguration(document, originalText);
if (format === "dsh") return serializeDshDocument(document);
return ok(serializeHermesConfiguration(document, originalText));
if (format === "dsh") return ok(serializeDshDocument(document));
// An empty original holds no comments or settings to preserve. Writing a
// fresh document also keeps a lone BOM from shifting past the closing brace.
if (
originalText !== undefined &&
!isEmptyClientConfigurationText(originalText) &&
editedPaths !== undefined
originalText === undefined ||
isEmptyClientConfigurationText(originalText) ||
editedPaths === undefined
)
return editedPaths.reduce((text, path) => {
const value = valueAt(document, path);
if (value === undefined) {
const preserved = deleteJsonProperty(text, path);
if (preserved !== undefined) return preserved;
}
return applyEdits(
text,
modify(text, [...path], value, {
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
}),
);
}, originalText);
return `${JSON.stringify(document, null, 2)}\n`;
return ok(`${JSON.stringify(document, null, 2)}\n`);
let text = originalText;
for (const path of editedPaths) {
const edited = editJsonMember(text, path, valueAt(document, path));
if (!edited.ok) return edited;
text = edited.value;
}
return ok(text);
};
/** Key path of a named registration in the server table. */
+32 -18
View File
@@ -250,16 +250,6 @@ const persistClientConfiguration = async ({
};
if (target.value) return { status: "failed", reason: "target_changed" };
}
let recoveryBackupPath: string | undefined;
if (original !== undefined) {
const preserved = await preserveCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
transactionPath,
original,
);
if (!preserved.ok) return { status: "failed", reason: "backup" };
recoveryBackupPath = preserved.value;
}
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
// otherwise conflict with it.
const legacyPath = legacyClientServerPath(
@@ -292,19 +282,43 @@ const persistClientConfiguration = async ({
),
}
: registered;
let serialized: ReturnType<typeof serializeClientConfiguration>;
try {
serialized = serializeClientConfiguration(document, format, original, [
...Object.keys(fallbackServers)
.filter((name) => name !== PRODUCT_IDENTITY.mcpServerKey)
.map((name) => clientServerPath(parsed, name)),
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
...(legacyPath === undefined ? [] : [legacyPath]),
...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []),
]);
} catch (cause: unknown) {
// An unserializable edit is reported before any file is written.
void cause;
return { status: "failed", reason: "write" };
}
if (!serialized.ok)
return {
status: "failed",
reason: "shadowed_member",
path: serialized.error.path,
};
let recoveryBackupPath: string | undefined;
if (original !== undefined) {
const preserved = await preserveCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
transactionPath,
original,
);
if (!preserved.ok) return { status: "failed", reason: "backup" };
recoveryBackupPath = preserved.value;
}
let publication: ClientConfigurationPublication;
try {
await mkdir(dirname(client.configPath), { recursive: true });
publication = await ClientConfigurationPublication.write(
transactionPath,
serializeClientConfiguration(document, format, original, [
...Object.keys(fallbackServers)
.filter((name) => name !== PRODUCT_IDENTITY.mcpServerKey)
.map((name) => clientServerPath(parsed, name)),
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
...(legacyPath === undefined ? [] : [legacyPath]),
...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []),
]),
serialized.value,
original,
0o600,
);
+2
View File
@@ -20,6 +20,8 @@ export const failedConfigurationMessage = (
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
case "write":
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
case "shadowed_member":
return `Agent configuration repeats the member at ${JSON.stringify(result.path)}; removing the member the client reads would activate an earlier duplicate, so no change was made. Remove the duplicate members, then rerun setup.`;
case "rollback":
return result.backupPath === undefined
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
+6
View File
@@ -37,6 +37,12 @@ export type ClientConfigurationResult =
readonly status: "failed";
readonly reason: "path" | "target_changed" | "backup" | "write";
}
| {
readonly status: "failed";
/** Removing the member a client reads would activate an earlier duplicate. */
readonly reason: "shadowed_member";
readonly path: readonly string[];
}
| {
readonly status: "failed";
readonly reason: "readback";
+22 -6
View File
@@ -551,6 +551,27 @@ const removeClient = async (
...(removeNative ? [clientServerPath(parsed, name)] : []),
...(legacyPath === undefined ? [] : [legacyPath]),
];
let serialized: ReturnType<typeof serializeClientConfiguration>;
try {
serialized = serializeClientConfiguration(
document,
client.format,
original,
editedPaths,
);
} catch {
return item(
client.name,
"failed",
"Configuration could not be updated, so no change was made. Repair the configuration, then rerun uninstall.",
);
}
if (!serialized.ok)
return item(
client.name,
"failed",
`Configuration repeats the member at ${JSON.stringify(serialized.error.path)}; removing the registration the client reads would activate an earlier duplicate, so no change was made. Remove the duplicate members manually, then rerun uninstall.`,
);
const backupPath = await backupClientConfiguration(
client,
transactionPath,
@@ -573,12 +594,7 @@ const removeClient = async (
try {
publication = await fileSystem.publishConfiguration(
transactionPath,
serializeClientConfiguration(
document,
client.format,
original,
editedPaths,
),
serialized.value,
original,
);
} catch (cause: unknown) {
+1 -1
View File
@@ -1,7 +1,7 @@
/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "6.6.0", // x-release-please-version
version: "6.7.0", // x-release-please-version
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
@@ -0,0 +1,144 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
// JSON.parse, JSONC parse, and native Claude Code read the last duplicate.
const shadowParent = '"mcpServers": {"shadow": {"command": "/usr/bin/false"}},';
const shadowLeaf = '"rea": {"command": "/usr/bin/false"},';
const owned =
'"rea": {"command": "npx", "args": ["-y", "rea-agents@6.6.0", "mcp"]}';
const runIn = (cli: TestCli, home: string) => (arguments_: readonly string[]) =>
cli.run({
arguments: arguments_,
cwd: home,
environment: {
HOME: home,
USERPROFILE: home,
CLAUDE_CONFIG_DIR: home,
XDG_CONFIG_HOME: join(home, ".config"),
},
});
const setupArguments = (client: string) =>
["setup", "--client", client, "--skill=false", "--json", "--yes"] as const;
for (const [layout, original] of [
[
"a repeated server table",
`{\n ${shadowParent}\n "mcpServers": {"peer": {"command": "/usr/bin/true"}}\n}\n`,
],
[
"a repeated rea member",
`{\n "mcpServers": {${shadowLeaf} "rea": {"command": "/usr/bin/true"}}\n}\n`,
],
] as const)
cliTest(
`Claude Code setup edits the effective member of ${layout}`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-json-duplicate-");
const config = join(home, ".claude.json");
await writeFile(config, original);
const run = runIn(cli, home);
const setup = await run(setupArguments("claude_code"));
expect(setup.exitCode, setup.stdout).toBe(0);
expect(setup.json).toMatchObject({
status: "ready",
clients: { claude_code: { status: "configured" } },
});
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
const configured = await readFile(config, "utf8");
expect(configured).toContain(
layout === "a repeated server table" ? shadowParent : shadowLeaf,
);
expect(JSON.parse(configured)).toMatchObject({
mcpServers: { rea: { command: expect.not.stringMatching(/^\/usr/) } },
});
const repeated = await run(setupArguments("claude_code"));
expect(repeated.exitCode, repeated.stdout).toBe(0);
expect(repeated.json).toMatchObject({
status: "ready",
plannedActions: [],
});
expect(await readFile(config, "utf8")).toBe(configured);
},
);
cliTest(
"JSONC setup keeps comments and the shadowed server table",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-jsonc-duplicate-");
await mkdir(join(home, ".cursor"));
const config = join(home, ".cursor", "mcp.json");
const head = `{\n // shadowed table\n ${shadowParent}\n /* effective table */\n`;
await writeFile(
config,
`${head} "mcpServers": {\n "peer": {"command": "/usr/bin/true"} // peer\n }\n}\n`,
);
const setup = await runIn(cli, home)(setupArguments("cursor"));
expect(setup.exitCode, setup.stdout).toBe(0);
expect(setup.json).toMatchObject({
clients: { cursor: { status: "configured" } },
});
const configured = await readFile(config, "utf8");
expect(configured.startsWith(head)).toBe(true);
expect(configured).toContain("// peer");
expect(configured.indexOf('"rea"')).toBeGreaterThan(head.length);
},
);
cliTest(
"uninstall removes an owned registration from the effective server table",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-json-duplicate-");
const config = join(home, ".claude.json");
const original = `{\n ${shadowParent}\n "mcpServers": {"peer": {"command": "/usr/bin/true"}, ${owned}}\n}\n`;
await writeFile(config, original);
const removed = await runIn(cli, home)(["uninstall", "--json"]);
expect(removed.exitCode, removed.stdout).toBe(0);
expect(removed.json).toMatchObject({
items: expect.arrayContaining([
expect.objectContaining({ name: "claude_code", status: "removed" }),
]),
});
const remaining = await readFile(config, "utf8");
expect(remaining.startsWith(`{\n ${shadowParent}\n`)).toBe(true);
expect(JSON.parse(remaining)).toEqual({
mcpServers: { peer: { command: "/usr/bin/true" } },
});
},
);
cliTest(
"uninstall refuses to activate a shadowed duplicate registration",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-json-duplicate-");
const config = join(home, ".claude.json");
const original = `{\n "mcpServers": {${shadowLeaf} ${owned}}\n}\n`;
await writeFile(config, original);
const removed = await runIn(cli, home)(["uninstall", "--json"]);
expect(removed.exitCode, removed.stdout).toBe(1);
expect(removed.json).toMatchObject({
status: "failed",
items: expect.arrayContaining([
expect.objectContaining({
name: "claude_code",
status: "failed",
detail: expect.stringContaining("activate an earlier duplicate"),
}),
]),
});
expect(await readFile(config, "utf8")).toBe(original);
await expect(readFile(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
},
);
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import {
@@ -13,6 +14,9 @@ import {
parseDevelopmentTestRequest,
} from "../../../scripts/lib/development-tests.mjs";
import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const repository = process.cwd();
describe("canonical test-project ownership", () => {
@@ -72,4 +76,68 @@ describe("canonical test-project ownership", () => {
expect(config).toContain("PROJECT_POLICIES");
expect(config).not.toContain('"src/{contracts,domain}/**/*.test.ts"');
});
it.each([
{ flags: [], heap: 384, timeout: 30_000, lines: 68 },
{ flags: ["--coverage"], heap: 512, timeout: 60_000, lines: 68 },
{ flags: ["--coverage.enabled"], heap: 512, timeout: 60_000, lines: 68 },
{ flags: ["--coverage=false"], heap: 384, timeout: 30_000, lines: 68 },
{
flags: ["--coverage.enabled=false"],
heap: 384,
timeout: 30_000,
lines: 68,
},
{
flags: ["--coverage.provider=v8"],
heap: 384,
timeout: 30_000,
lines: 68,
},
{
flags: ["--coverage.enabled", "--shard", "1/6"],
heap: 512,
timeout: 60_000,
lines: 0,
},
{
flags: ["--coverage.enabled=false", "--shard=1/6"],
heap: 384,
timeout: 30_000,
lines: 0,
},
])(
"resolves worker resources from actual CLI options: $flags",
async ({ flags, heap, timeout, lines }) => {
const root = await createTestTempDirectory("rea-vitest-worker-policy-");
const script = join(root, "worker-policy.mjs");
await writeFile(
script,
`
import config from ${JSON.stringify(pathToFileURL(join(repository, "vitest.config.ts")).href)};
const project = config.test.projects.find(project => project.test.name === 'cli-boundary');
console.log(JSON.stringify({
execArgv: project.test.execArgv,
timeout: config.test.testTimeout,
lines: config.test.coverage.thresholds.lines,
}));
`,
);
const result = await execFileOutput(
process.execPath,
["--import", "tsx", script, ...flags],
{
cwd: repository,
timeout: 10_000,
maxBuffer: 64 * 1024,
stopSignal: "SIGTERM",
},
);
expect(JSON.parse(result.stdout)).toEqual({
execArgv: [`--max-old-space-size=${String(heap)}`],
timeout,
lines,
});
},
);
});
@@ -39,7 +39,7 @@ it.each([
REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp",
CALLER_SETTING: "preserve exactly",
};
const original = serializeClientConfiguration(
const serialized = serializeClientConfiguration(
{
[serversKey]: {
rea: clientRegistrationEntry(format, ["rea-old", "mcp"], custom),
@@ -48,6 +48,8 @@ it.each([
},
format,
);
if (!serialized.ok) throw new Error("fixture serialization failed");
const original = serialized.value;
await writeFile(configPath, original);
const providers = { GHIDRA_INSTALL_DIR: join(root, "new-ghidra") };
const command = ["rea", "mcp"];
+3 -3
View File
@@ -359,7 +359,7 @@ describe("live server identity over MCP", () => {
session.close(),
]);
}
}, 10_000);
});
it("compares the caller's digest against the selected compact tools/list catalog", async () => {
const session = createTestBinarySession(availabilityProvider());
@@ -401,7 +401,7 @@ describe("live server identity over MCP", () => {
session.close(),
]);
}
}, 10_000);
});
});
describe("active-target availability over MCP", () => {
@@ -463,7 +463,7 @@ describe("active-target availability over MCP", () => {
session.close(),
]);
}
}, 10_000);
});
});
const PLIST_XML =
@@ -1,17 +1,17 @@
import { Cli } from "incur";
import { afterEach, expect, it, vi } from "vitest";
import { WasmArtifactService } from "../../../src/application/wasm/WasmArtifactService.js";
import { registerWasmCommands } from "../../../src/cli/wasmCommands.js";
import { wasmArtifactModule } from "../../../src/composition/wasm.js";
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { err, ok } from "../../../src/domain/result.js";
import { silentLogger } from "../../../src/logger.js";
import { WasmArtifactService } from "../../src/application/wasm/WasmArtifactService.js";
import { registerWasmCommands } from "../../src/cli/wasmCommands.js";
import { wasmArtifactModule } from "../../src/composition/wasm.js";
import { AnalysisOutputError } from "../../src/domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
import { err, ok } from "../../src/domain/result.js";
import { silentLogger } from "../../src/logger.js";
import {
wasmArtifactExecution,
wasmArtifactFixture,
} from "../../fixtures/wasm/artifact.js";
} from "../fixtures/wasm/artifact.js";
const host = vi.hoisted(() => ({
listeners: new Map<string, () => void>(),
@@ -24,10 +24,10 @@ const host = vi.hoisted(() => ({
},
}));
vi.mock("../../../src/cli/commandCancellation.js", async (importOriginal) => {
vi.mock("../../src/cli/commandCancellation.js", async (importOriginal) => {
const original =
await importOriginal<
typeof import("../../../src/cli/commandCancellation.js")
typeof import("../../src/cli/commandCancellation.js")
>();
return {
...original,
+12 -8
View File
@@ -3,6 +3,7 @@ import { availableParallelism, tmpdir } from "node:os";
import { join } from "node:path";
import { defineConfig } from "vitest/config";
import { parseCLI } from "vitest/node";
import {
BUILT_TEST_PROJECTS,
SOURCE_TEST_PROJECTS,
@@ -23,12 +24,14 @@ if (
}
const CANONICAL_TEMPORARY_DIRECTORY = realpathSync(tmpdir());
const COVERAGE_ENABLED = process.argv.some((argument) =>
argument.startsWith("--coverage"),
);
const COVERAGE_SHARD = process.argv.some((argument) =>
argument.startsWith("--shard="),
// Use Vitest's option parser so explicit false and spaced shard values retain
// their meaning. Unknown worker/embedding arguments are not Vitest options.
const { options: testOptions } = parseCLI(
["vitest", ...process.argv.slice(2)],
{ allowUnknownOptions: true },
);
const COVERAGE_ENABLED = testOptions.coverage?.enabled === true;
const COVERAGE_SHARD = testOptions.shard !== undefined;
// Keep local and CI concurrency bounded by the runner rather than command locks.
const MAX_TEST_WORKERS = Math.min(2, availableParallelism());
@@ -63,9 +66,10 @@ const PROJECT_POLICIES = {
// CLI tests create fresh command instances or subprocesses and own their
// cleanup. Reuse the immutable graph; module mocks belong in process-global.
isolate: false,
// Long-lived workers should collect temporary command/schema allocations
// before their two heaps compete with the real CLI child processes.
execArgv: ["--max-old-space-size=384"],
// Coverage of the shared command/schema graph exhausts the ordinary
// 384 MiB budget. The preloaded CLI/ADB control completes with 512 MiB;
// keep both profiles bounded with the same concurrency.
execArgv: [`--max-old-space-size=${COVERAGE_ENABLED ? "512" : "384"}`],
},
"process-boundary": {
pool: "forks" as const,