Files
reverse-engineering-anything/tests/boundary/cli/cliBuiltinArguments.test.ts
T
Saim ShafiqueandSaim Shafique e5f638310c fix(cli): reject project-scoped Amp registration (#7979)
* fix(cli): reject project-scoped Amp registration

Incur's mcp add parses --no-global but its Amp writer always writes the
global settings file, so a project-scoped Amp request exited 0 reporting
success while changing global config. Fail closed in the entrypoint before
Incur dispatches: project-scoped Amp registration now exits 1 with a
diagnostic and leaves global settings untouched. Global Amp registration
is unchanged.

Fixes #7535

* fix(cli): exempt discovery requests from the Amp project-scope guard

Discovery requests are not registrations: mcp add --no-global with a
discovery flag (--help, -h, --llms, --llms-full, --version, --schema) now
passes through to normal handling instead of exiting 1 with the scope
error, matching the discovery set mcp add itself exempts. Real
project-scoped Amp registrations are still rejected before Incur runs.
Also restores the executable bit on scripts/rea.mjs, and adds launcher
dispatch tests proving the guard rejects a real registration before the
CLI runtime loads and passes discovery requests through.

* fix(cli): share the mcp add discovery classification between guard and routing

The Amp project-scope guard kept its own copy of the mcp add discovery
flags, which could drift from the discovery routing in cli.ts.
classifyMcpAddDiscovery in cliBuiltinArguments.ts is now the single
owner: the guard and the cli.ts mcp add special-casing both consume it,
with bypass/version/schema precedence matching the routing. No behavior
change; covered by classification unit tests and the existing mcp add
discovery serve test.

* fix(cli): emit structured output for Amp project-scope refusal

The Amp project-scope guard wrote a plain message to stderr and left
stdout empty when the caller asked for a structured document (json,
jsonl, yaml), contradicting the failure framing documented for the
output argument. Structured formats now render the refusal through the
same pre-execution error renderer the neighboring output-argument
branch uses, with code AMP_PROJECT_SCOPE_UNSUPPORTED on stdout; every
other format keeps the byte-identical plain stderr message. The guard
still exits 1 before dist/cli-runtime/cli.js is imported.

Co-authored-by: Saim Shafique <128062316+sx4im@users.noreply.github.com>

* test(cli): cover Amp project-scope guard through the launcher

The guard tests only called isProjectScopedAmpRegistration, so they
still passed with its call in scripts/rea.mjs removed. Add a launcher
test with an isolated home and working directory that checks the exit
code, the diagnostic, the unchanged global settings, and the absence
of project files for both explicit Amp and default-agent requests.

Co-authored-by: Saim Shafique <128062316+sx4im@users.noreply.github.com>

* test(cli): assert Amp project-scope refusal by error code

The --no-global refusal tests locked the full English sentence. Structured output now asserts AMP_PROJECT_SCOPE_UNSUPPORTED and a message field.

* fix(cli): share normalized mcp add agent selection with the scope guard

The project-scope guard scanned raw arguments and only recognized the spaced --agent form, while dispatch expanded --agent= equals forms first: --agent=claude-code was wrongly refused as default Amp, and --agent claude-code --agent=amp slipped Amp past --no-global. The equals expansion and agent scan now live once in cliBuiltinArguments.ts and both callers consume them, so guard and dispatch read the same selection.
2026-11-06 02:01:30 +08:00

238 lines
6.3 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
classifyMcpAddDiscovery,
isProjectScopedAmpRegistration,
} from "../../../src/cliBuiltinArguments.js";
describe("mcp add discovery classification", () => {
it("bypasses help and listing flags", () => {
for (const flag of ["--help", "-h", "--llms", "--llms-full"]) {
expect(classifyMcpAddDiscovery(new Set([flag]))).toBe("bypass");
}
});
it("answers version and schema inside the mcp add special-casing", () => {
expect(classifyMcpAddDiscovery(new Set(["--version"]))).toBe("version");
expect(classifyMcpAddDiscovery(new Set(["--schema"]))).toBe("schema");
});
it("keeps routing precedence when several discovery flags combine", () => {
expect(classifyMcpAddDiscovery(new Set(["--help", "--version"]))).toBe(
"bypass",
);
expect(classifyMcpAddDiscovery(new Set(["--version", "--schema"]))).toBe(
"version",
);
});
it("returns undefined for real registrations", () => {
expect(classifyMcpAddDiscovery(new Set(["--json"]))).toBeUndefined();
expect(classifyMcpAddDiscovery(new Set())).toBeUndefined();
});
});
describe("project-scoped Amp registration guard", () => {
it("flags explicit Amp targets under --no-global", () => {
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--agent",
"amp",
"--command",
"node project-rea.mjs",
]),
).toBe(true);
});
it("flags the default Amp registration when no agent is given", () => {
expect(isProjectScopedAmpRegistration(["mcp", "add", "--no-global"])).toBe(
true,
);
});
it("ignores output flags Incur strips before dispatch", () => {
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--agent",
"amp",
"--command",
"node project-rea.mjs",
"--json",
]),
).toBe(true);
});
it("passes global Amp registration through", () => {
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--agent",
"amp",
"--command",
"node project-rea.mjs",
]),
).toBe(false);
});
it("passes project-scoped non-Amp agents through", () => {
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--agent",
"claude-code",
]),
).toBe(false);
});
it("passes discovery requests through, matching mcp add discovery", () => {
for (const discovery of [
"--help",
"-h",
"--llms",
"--llms-full",
"--version",
"--schema",
]) {
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--agent",
"amp",
discovery,
]),
).toBe(false);
}
expect(
isProjectScopedAmpRegistration(["mcp", "add", "--no-global", "--help"]),
).toBe(false);
});
it("reads equals-form agents the same way dispatch does", () => {
// --agent=claude-code is a non-Amp registration, not the default Amp.
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--agent=claude-code",
]),
).toBe(false);
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--agent=amp",
]),
).toBe(true);
// A later equals-form Amp must not slip past an earlier spaced agent.
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--agent",
"claude-code",
"--agent=amp",
]),
).toBe(true);
// A --command value is never read as an agent, even in equals form.
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--command=--agent",
"amp",
]),
).toBe(true);
});
it("passes other commands through", () => {
expect(isProjectScopedAmpRegistration(["mcp", "doctor"])).toBe(false);
expect(isProjectScopedAmpRegistration(["doctor"])).toBe(false);
expect(
isProjectScopedAmpRegistration([
"mcp",
"add",
"--no-global",
"--agent",
"Amp",
]),
).toBe(false);
});
});
describe("project-scoped Amp registration launcher", () => {
it("refuses explicit Amp and default-agent requests without writing settings", async () => {
const home = await createTestTempDirectory("rea-amp-launcher-home-");
const workingDirectory = await createTestTempDirectory(
"rea-amp-launcher-cwd-",
);
const ampDirectory = join(home, ".config", "amp");
await mkdir(ampDirectory, { recursive: true });
const settingsPath = join(ampDirectory, "settings.json");
const sentinel = JSON.stringify({ preexisting: true });
await writeFile(settingsPath, sentinel);
for (const args of [
["mcp", "add", "--no-global", "--agent", "amp", "--json"],
["mcp", "add", "--no-global", "--json"],
["mcp", "add", "--no-global", "--agent=amp", "--json"],
[
"mcp",
"add",
"--no-global",
"--agent",
"claude-code",
"--agent=amp",
"--json",
],
]) {
const result = spawnSync(
process.execPath,
[resolve("scripts/rea.mjs"), ...args],
{
cwd: workingDirectory,
env: {
PATH: process.env.PATH ?? "",
HOME: home,
REA_LOG_LEVEL: "silent",
},
encoding: "utf8",
timeout: 60_000,
},
);
expect(result.error).toBeUndefined();
expect(result.signal).toBeNull();
expect(result.status).toBe(1);
expect(JSON.parse(result.stdout)).toEqual({
ok: false,
error: {
code: "AMP_PROJECT_SCOPE_UNSUPPORTED",
message: expect.any(String),
},
});
expect(result.stderr).toBe("");
expect(await readFile(settingsPath, "utf8")).toBe(sentinel);
expect(await readdir(workingDirectory)).toEqual([]);
}
});
});