diff --git a/src/application/HermesClientHome.ts b/src/application/HermesClientHome.ts index c3f148d96c..96861aa5e4 100644 --- a/src/application/HermesClientHome.ts +++ b/src/application/HermesClientHome.ts @@ -1,4 +1,5 @@ import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { userInfo, type UserInfo } from "node:os"; import { basename, dirname, @@ -60,6 +61,33 @@ const expandedDirectory = ( (platform === "win32" && expanded.startsWith("~\\")) ) return join(home, expanded.slice(2)); + if (expanded.startsWith("~")) { + // Named POSIX homes come from the account database, not HOME/USERPROFILE. + // Only the current account has a portable Node API; unresolved names must + // not become writable relative paths in the caller's working directory. + if (platform !== "win32") { + const separator = expanded.indexOf("/"); + const username = expanded.slice(1, separator < 0 ? undefined : separator); + let account: UserInfo; + try { + account = userInfo(); + } catch { + throw new HermesClientPathError( + override, + "Cannot resolve the OS account for this named Hermes home. Set an absolute HERMES_HOME before configuring Hermes.", + ); + } + if (account.username === username && isAbsolute(account.homedir)) + return join( + account.homedir, + separator < 0 ? "" : expanded.slice(separator + 1), + ); + } + throw new HermesClientPathError( + override, + "Cannot resolve this named Hermes home. Set an absolute HERMES_HOME before configuring Hermes.", + ); + } return resolve(expanded); }; const physicalPath = (path: string): string => { diff --git a/tests/acceptance/setup/hermesClientSetup.test.ts b/tests/acceptance/setup/hermesClientSetup.test.ts index 3b2a146a0b..308b034a9a 100644 --- a/tests/acceptance/setup/hermesClientSetup.test.ts +++ b/tests/acceptance/setup/hermesClientSetup.test.ts @@ -1,10 +1,12 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +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 } from "vitest"; +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"], @@ -384,3 +386,126 @@ for (const name of ["missing", "deleted", "ghost", "../escape", "root"]) { }, ); } + +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" }); + }, +); diff --git a/tests/boundary/filesystem/hermesClientConfiguration.test.ts b/tests/boundary/filesystem/hermesClientConfiguration.test.ts index 5923ebff68..33c4369978 100644 --- a/tests/boundary/filesystem/hermesClientConfiguration.test.ts +++ b/tests/boundary/filesystem/hermesClientConfiguration.test.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { userInfo } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parse as parseYaml } from "yaml"; @@ -72,6 +73,39 @@ describe("Hermes configuration paths", () => { ); }); + it.skipIf(process.platform === "win32")( + "expands the named OS account independently of the selected home", + () => { + const account = userInfo(); + for (const suffix of ["", "/rea-owned-profile", "//rea-owned-profile"]) { + expect( + hermesPath({ HERMES_HOME: `~${account.username}${suffix}` }) + ?.markerPath, + ).toBe(join(account.homedir, suffix)); + } + expect( + hermesPath({ + HERMES_HOME: "$PROFILE", + PROFILE: `~${account.username}/rea-owned-profile`, + })?.markerPath, + ).toBe(join(account.homedir, "rea-owned-profile")); + }, + ); + + it.each(["linux", "win32"] as const)( + "keeps an unresolved named home diagnostic without exposing a write destination on %s", + (platform) => { + const requested = `~${userInfo().username}-other/rea-profile`; + expect(hermesPath({ HERMES_HOME: requested }, platform)).toMatchObject({ + configPath: requested, + configPathError: expect.stringContaining("absolute HERMES_HOME"), + }); + expect( + hermesPath({ HERMES_HOME: requested }, platform), + ).not.toHaveProperty("markerPath"); + }, + ); + it("honours HERMES_HOME and Hermes's data-directory suffix", () => { expect( hermesPath({ HERMES_HOME: join("/srv", "hermes") })?.configPath,