Files
rea/tests/acceptance/setup/hermesClientSetup.test.ts
93e75bc8fe fix(setup): retain Hermes YAML scalar source during updates
Retain source nodes for unchanged scalar values and string keys when
replacing a Hermes registration. Discarding quoted YAML 1.2 keys could
make Hermes' C reader reject duplicate boolean keys and switch to the
pure reader, changing the interpretation of unrelated settings.

Use linear mapping lookup, quote newly created YAML 1.2 strings for
YAML 1.1 compatibility, and preserve direct numeric edit formatting.
Extend actual CLI coverage for both existing reader-selection directions;
verify strings, exact unrelated numeric/timestamp values, backups and
idempotence with the actual Hermes Python loader.

Follow-up to #3102 and main's independent Hermes scalar fix.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-15 00:57:57 +08:00

387 lines
13 KiB
TypeScript

import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parse } from "yaml";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
for (const [version, keyStyle] of [
["1.1", "quoted"],
["1.2", "quoted"],
["1.2", "plain"],
] as const) {
cliTest(
`Hermes setup preserves string environment values under YAML ${version} with ${keyStyle} keys`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-hermes-yaml-strings-");
const profile = join(home, "hermes");
const config = join(profile, "config.yaml");
const environment = {
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
ON: "on",
YES: "yes",
NO: "no",
SHORT_TRUE: "y",
SHORT_FALSE: "n",
DATE: "2026-10-14",
TIME: "12:34:56",
FLOAT: "1.0e-7",
OCTAL: "0600",
NULL_WORD: "NULL",
NULL_MARKER: "~",
};
const directive = version === "1.2" ? "%YAML 1.2\n---\n" : "";
const settings =
directive +
"# User settings\nnotifications: on\ndate: 2026-10-14\n" +
"timestamp: 2026-10-14T00:00:00.123456+08:00\n";
const original =
settings +
"mcp_servers:\n rea:\n command: rea-old\n args: [mcp]\n env:\n" +
Object.entries(environment)
.map(
([key, value]) =>
` ${keyStyle === "plain" ? key : JSON.stringify(key)}: ${JSON.stringify(value)}\n`,
)
.join("");
await mkdir(profile);
await writeFile(config, original);
const run = (arguments_: readonly string[]) =>
cli.run({
arguments: arguments_,
cwd: home,
environment: { USERPROFILE: home, HERMES_HOME: profile },
timeoutMs: 20_000,
});
const setup = [
"setup",
"--client",
"hermes",
"--skill=false",
"--yes",
"--json",
];
expect((await run(setup)).json).toMatchObject({ status: "ready" });
const configured = await readFile(config, "utf8");
expect(parse(configured, { version })).toMatchObject({
mcp_servers: { rea: { env: environment, enabled: true } },
});
// Hermes's C loader applies YAML 1.1 even with the explicit directive.
// Independently model that schema; real Hermes loader verification is
// recorded in the PR rather than requiring Python dependencies in CI.
if (keyStyle === "plain") {
// The real C loader rejects these YAML 1.2 string keys as duplicate
// boolean keys. Preserve that fallback rather than changing how
// Hermes interprets unrelated user settings.
for (const text of [original, configured])
expect(() =>
parse(text.slice(directive.length), { version: "1.1" }),
).toThrow("Map keys must be unique");
} else {
expect(
parse(configured.slice(directive.length), { version: "1.1" }),
).toMatchObject({
mcp_servers: { rea: { env: environment, enabled: true } },
});
}
expect(configured.startsWith(settings)).toBe(true);
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
expect(
(await run(["doctor", "--client", "hermes", "--json"])).json,
).toMatchObject({
healthy: true,
identity: { registrations: [{ client: "hermes", state: "aligned" }] },
});
expect((await run(setup)).json).toMatchObject({
status: "ready",
appliedActions: [],
});
expect(await readFile(config, "utf8")).toBe(configured);
},
);
}
cliTest(
"Hermes setup, doctor and uninstall use the expanded profile and its personal skill",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-hermes-cli-");
const profile = join(home, "profile");
const config = join(profile, "config.yaml");
const skill = join(profile, "skills/reverse-engineer-anything/SKILL.md");
const original =
"# user settings\nmodel: custom\nmcp_servers:\n other:\n command: other-server\n";
await mkdir(profile);
await writeFile(config, original);
const run = (arguments_: readonly string[]) =>
cli.run({
arguments: arguments_,
cwd: home,
environment: {
USERPROFILE: home,
REA_CLIENT_PROFILE_HOME: home,
HERMES_HOME: " ${REA_CLIENT_PROFILE_HOME}/profile ",
},
timeoutMs: 20_000,
});
const planned = await run([
"setup",
"--client",
"hermes",
"--dry-run",
"--json",
]);
expect(planned.exitCode).toBe(0);
expect(planned.json).toMatchObject({
status: "planned",
plannedActions: expect.arrayContaining([
expect.objectContaining({ target: config }),
expect.objectContaining({
target: join(profile, "skills/reverse-engineer-anything"),
}),
]),
});
expect(await readFile(config, "utf8")).toBe(original);
await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" });
const applied = await run([
"setup",
"--client",
"hermes",
"--yes",
"--json",
]);
expect(applied.exitCode).toBe(0);
expect(applied.json).toMatchObject({ status: "ready" });
expect(parse(await readFile(config, "utf8"))).toMatchObject({
model: "custom",
mcp_servers: {
other: { command: "other-server" },
rea: { enabled: true },
},
});
expect(await readFile(config + ".rea.backup", "utf8")).toBe(original);
expect(await readFile(skill, "utf8")).toContain(
"reverse-engineer-anything",
);
await expect(
access(join(home, ".agents/skills/reverse-engineer-anything/SKILL.md")),
).rejects.toMatchObject({ code: "ENOENT" });
const doctor = await run([
"doctor",
"--client",
"hermes",
"--skill",
"--json",
]);
expect(doctor.exitCode).toBe(0);
expect(doctor.json).toMatchObject({
healthy: true,
identity: { skill: { state: "aligned" } },
});
const repeat = await run([
"setup",
"--client",
"hermes",
"--yes",
"--json",
]);
expect(repeat.json).toMatchObject({ status: "ready", appliedActions: [] });
const uninstall = await run(["uninstall", "--json"]);
expect(uninstall.exitCode).toBe(0);
expect(parse(await readFile(config, "utf8"))).toEqual(parse(original));
await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" });
},
);
for (const selection of ["default", "custom", "suffix", "explicit"] as const) {
cliTest(
`Hermes ${selection} sticky-profile selection preserves the root configuration`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-hermes-sticky-");
const suffix = selection === "suffix" ? "-dev" : "";
const native =
process.platform === "win32"
? join(home, "AppData", "Local", `hermes${suffix}`)
: join(home, `.hermes${suffix}`);
const root = selection === "custom" ? join(home, "custom root") : native;
const profile = join(
root,
"profiles",
selection === "explicit" ? "other" : "coder",
);
await mkdir(profile, { recursive: true });
const original =
"# profile settings\nmodel: custom\nmcp_servers:\n other:\n command: other-server\n";
const config = join(profile, "config.yaml");
const rootConfig = join(root, "config.yaml");
await writeFile(config, original);
await writeFile(rootConfig, "model: root-model\n");
await writeFile(join(root, "active_profile"), "\uFEFFCoDeR\r\n");
const run = (args: readonly string[]) =>
cli.run({
arguments: args,
cwd: home,
environment: {
USERPROFILE: home,
LOCALAPPDATA: join(home, "AppData", "Local"),
HERMES_DATA_DIR_SUFFIX: suffix,
HERMES_HOME:
selection === "explicit"
? profile
: selection === "custom"
? root
: undefined,
},
timeoutMs: 20_000,
});
const plan = await run([
"setup",
"--client",
"hermes",
"--dry-run",
"--json",
]);
expect(plan.exitCode).toBe(0);
expect(plan.json).toMatchObject({
status: "planned",
plannedActions: expect.arrayContaining([
expect.objectContaining({ target: config }),
expect.objectContaining({
target: join(profile, "skills", "reverse-engineer-anything"),
}),
]),
});
expect(
(await run(["setup", "--client", "hermes", "--yes", "--json"]))
.exitCode,
).toBe(0);
expect(parse(await readFile(config, "utf8"))).toMatchObject({
mcp_servers: {
rea: { enabled: true },
other: { command: "other-server" },
},
});
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
const skill = join(
profile,
"skills",
"reverse-engineer-anything",
"SKILL.md",
);
expect(await readFile(skill, "utf8")).toContain(
"name: reverse-engineer-anything",
);
if (selection === "custom") {
const doctor = await run([
"doctor",
"--client",
"hermes",
"--skill",
"--json",
]);
expect(doctor.exitCode).toBe(0);
expect(doctor.json).toMatchObject({
healthy: true,
identity: {
skill: { state: "aligned" },
registrations: expect.arrayContaining([
expect.objectContaining({
client: "hermes",
config_path: config,
state: "aligned",
}),
]),
},
});
expect(
(await run(["setup", "--client", "hermes", "--yes", "--json"])).json,
).toMatchObject({ appliedActions: [] });
expect((await run(["uninstall", "--json"])).exitCode).toBe(0);
expect(parse(await readFile(config, "utf8"))).toEqual(parse(original));
await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" });
}
expect(await readFile(rootConfig, "utf8")).toBe("model: root-model\n");
await expect(access(`${rootConfig}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
await expect(
access(join(root, "skills", "reverse-engineer-anything")),
).rejects.toMatchObject({ code: "ENOENT" });
},
);
}
for (const name of ["missing", "deleted", "ghost", "../escape", "root"]) {
cliTest(
`Hermes rejects unusable sticky profile ${name} without writing a fallback home`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-hermes-invalid-profile-");
const root = join(home, "hermes root");
await mkdir(root);
await writeFile(join(root, "active_profile"), name);
if (name === "deleted" || name === "ghost") {
await mkdir(join(root, "profiles", name), { recursive: true });
if (name === "deleted") {
await writeFile(
join(root, "profiles", name, "config.yaml"),
"model: custom\n",
);
await mkdir(join(root, "profiles", ".deleted"));
await writeFile(
join(root, "profiles", ".deleted", name),
"deleted\n",
);
}
}
const run = (args: readonly string[]) =>
cli.run({
arguments: args,
cwd: home,
environment: { USERPROFILE: home, HERMES_HOME: root },
timeoutMs: 20_000,
});
const rejected = await run([
"setup",
"--client",
"hermes",
"--yes",
"--json",
]);
expect(rejected.exitCode).not.toBe(0);
expect(rejected.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("Hermes profile"),
});
await expect(access(join(root, "config.yaml"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(access(join(root, "skills"))).rejects.toMatchObject({
code: "ENOENT",
});
if (name !== "missing") return;
const doctor = await run(["doctor", "--client", "hermes", "--json"]);
expect(doctor.exitCode).not.toBe(0);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "hermes",
state: "invalid",
remediation: expect.stringContaining("Hermes profile"),
}),
]),
},
});
const unrelated = await run([
"setup",
"--client",
"codex",
"--yes",
"--json",
]);
expect(unrelated.exitCode).toBe(0);
},
);
}