Resolve the current named POSIX account through OS identity rather than the caller-selected HOME/USERPROFILE. Preserve the home prefix for repeated separators using the same joining semantics as the existing ~/ branch. Require an absolute HERMES_HOME for unresolved names and Windows named forms before exposing config or skill write destinations. Reuse the shared Hermes path owner and existing diagnostics across setup, doctor, skills and uninstall. Validation: 113 checks across seven files pass, including the compiled CLI setup/doctor/skill/repeat/uninstall workflow over an owned profile beneath the actual account home and a separate caller home. The isolated unchanged baseline fails the five new checks and passes44existing checks; final repeated-separator controls also fail that baseline. Static/docs/discovery1559 pass. Both source reviewers clear after correcting repeated-separator composition. Twelve completed setup dry runs compare normal and named overrides, with exact normal-home replies unchanged. Measurements have source hashes and separate first/warm runs; no speedup, full Hermes agent/model or native Windows/Linux qualification claim. No provider software was installed; heavy tests/profiling remain stopped. Fixes #4841
512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
import { access, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
import { join, relative } from "node:path";
|
|
import { userInfo } from "node:os";
|
|
import { parse } from "yaml";
|
|
import { expect, onTestFinished } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
import { removeTestWorkspace } from "../../support/workspace/workspaceFixture.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);
|
|
},
|
|
);
|
|
}
|
|
|
|
cliTest.skipIf(process.platform === "win32")(
|
|
"Hermes named-user home aligns setup, doctor, skill and uninstall despite an isolated HOME",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-hermes-named-caller-");
|
|
const account = userInfo();
|
|
const profile = await mkdtemp(join(account.homedir, ".rea-hermes-named-"));
|
|
onTestFinished(() => removeTestWorkspace(profile));
|
|
const override = `~${account.username}//${relative(account.homedir, profile)}`;
|
|
const config = join(profile, "config.yaml");
|
|
const skill = join(profile, "skills/reverse-engineer-anything/SKILL.md");
|
|
const original =
|
|
"# owned fixture\nmodel: custom\nmcp_servers:\n other:\n command: other-server\n";
|
|
await writeFile(config, original);
|
|
const run = (arguments_: readonly string[]) =>
|
|
cli.run({
|
|
arguments: arguments_,
|
|
cwd: home,
|
|
environment: {
|
|
USERPROFILE: home,
|
|
HERMES_HOME: "$REA_HERMES_PROFILE",
|
|
REA_HERMES_PROFILE: override,
|
|
},
|
|
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" });
|
|
expect(
|
|
(await run(["setup", "--client", "hermes", "--yes", "--json"])).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(
|
|
"name: reverse-engineer-anything",
|
|
);
|
|
expect(
|
|
(await run(["doctor", "--client", "hermes", "--skill", "--json"])).json,
|
|
).toMatchObject({
|
|
healthy: true,
|
|
identity: { skill: { state: "aligned" } },
|
|
});
|
|
expect(
|
|
(await run(["setup", "--client", "hermes", "--yes", "--json"])).json,
|
|
).toMatchObject({ status: "ready", 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" });
|
|
await expect(access(join(home, override))).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"Hermes unresolved named home refuses setup before writes and remains diagnostic in doctor",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-hermes-named-refusal-");
|
|
const override = `~${userInfo().username}-other/profile`;
|
|
const run = (arguments_: readonly string[]) =>
|
|
cli.run({
|
|
arguments: arguments_,
|
|
cwd: home,
|
|
environment: { USERPROFILE: home, HERMES_HOME: override },
|
|
timeoutMs: 20_000,
|
|
});
|
|
const setup = await run(["setup", "--client", "hermes", "--yes", "--json"]);
|
|
expect(setup.exitCode).not.toBe(0);
|
|
expect(setup.json).toMatchObject({
|
|
status: "needs_human",
|
|
plannedActions: [],
|
|
appliedActions: [],
|
|
remediation: expect.stringContaining("absolute HERMES_HOME"),
|
|
});
|
|
const doctor = await run([
|
|
"doctor",
|
|
"--client",
|
|
"hermes",
|
|
"--skill",
|
|
"--json",
|
|
]);
|
|
expect(doctor.exitCode).not.toBe(0);
|
|
expect(doctor.json).toMatchObject({
|
|
identity: {
|
|
registrations: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
client: "hermes",
|
|
state: "invalid",
|
|
remediation: expect.stringContaining("absolute HERMES_HOME"),
|
|
}),
|
|
]),
|
|
},
|
|
});
|
|
await expect(access(join(home, override))).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
await expect(
|
|
access(join(home, ".agents/skills/reverse-engineer-anything")),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|