From 8471bcb18871aa812be695c34cb9a06716e58878 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Wed, 29 Jul 2026 09:23:06 -0700 Subject: [PATCH 1/3] fix(hermesagent): follow the platform default profile root and stabilize the shared-file key Four follow-ups from the #2394 review (issue #2439, findings 1, 2, 9, 10): - Upstream Hermes defaults its profile root to %LOCALAPPDATA%\hermes on win32 and ~/.hermes elsewhere, but rulesync wrote ~/.hermes on every platform when HERMES_HOME was unset, so a global generate on Windows landed where Hermes never reads. getHermesagentRelativeDirPath now re-anchors the de-prefixed path on the platform default, and the false docs sentence is corrected. - The shared-file key derived from getSettablePaths depended on HERMES_HOME (and now the platform), so the gateway drift guards went blind in exactly the configuration this feature targets. Every Hermes config.yaml writer now declares all three spellings through getExtraSharedWritePaths, and the ownership table declares them alongside .hermes/config.yaml. - resolveToolOutputRoot validates a substituted home override with validateOutputRoot, closing the same hole for KIMI_CODE_HOME. - The de-prefix guard uses checkPathTraversal semantics on both the input and the de-prefixed result, rejecting any .. segment rather than a bare prefix. The unit suite is now hermetic with respect to HERMES_HOME and KIMI_CODE_HOME (finding 5): both are cleared in vitest.config.ts before any module loads, since they change paths resolved at module load. E2E specs use the platform-aware directory instead of a hardcoded .hermes for global scope. Part of #2439 (findings 1, 2, 5, 9, 10). Co-Authored-By: Claude Fable 5 --- docs/reference/supported-tools.md | 7 +- skills/rulesync/supported-tools.md | 7 +- src/constants/hermesagent-paths.ts | 12 +++- src/e2e/e2e-checks.spec.ts | 9 +-- src/e2e/e2e-commands.spec.ts | 15 ++-- src/e2e/e2e-hermesagent-home.spec.ts | 7 +- src/e2e/e2e-hooks.spec.ts | 7 +- src/e2e/e2e-ignore.spec.ts | 9 ++- src/e2e/e2e-mcp.spec.ts | 5 +- src/e2e/e2e-permissions.spec.ts | 9 ++- src/e2e/e2e-skills.spec.ts | 5 +- src/e2e/e2e-subagents.spec.ts | 13 ++-- src/features/commands/hermesagent-command.ts | 21 +++--- src/features/hooks/hermesagent-hooks.ts | 10 +++ src/features/mcp/hermesagent-mcp.ts | 10 +++ .../permissions/hermesagent-permissions.ts | 10 +++ src/features/shared/shared-config-gateway.ts | 37 ++++++++++ .../subagents/hermesagent-subagent.test.ts | 17 +++-- .../subagents/hermesagent-subagent.ts | 24 +++---- src/lib/shared-file-derive.test.ts | 14 ++++ src/utils/hermesagent.test.ts | 41 ++++++++++- src/utils/hermesagent.ts | 72 +++++++++++++++++-- src/utils/tool-output-root.ts | 27 +++++-- vitest.config.ts | 10 +++ 24 files changed, 314 insertions(+), 84 deletions(-) diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 85da1d8c9..0c577ea1d 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -64,8 +64,11 @@ covered for both advertised scopes. Rulesync honors Hermes profiles through `HERMES_HOME`. When it is set, its value is the profile root itself: global configuration is read and written directly under `$HERMES_HOME` (`config.yaml`, `skills/`, `plugins/`, and `rulesync/`), -without appending `.hermes`. When it is unset, the default remains -`~/.hermes`. Project-scoped paths remain rooted in the project. +without appending `.hermes`. When it is unset, Rulesync follows Hermes's own +platform default: `~/.hermes` everywhere except Windows, where it is +`%LOCALAPPDATA%\hermes`. Because `HERMES_HOME` names where Hermes itself reads +the profile, it also takes precedence over `--output-roots` in global scope. +Project-scoped paths remain rooted in the project. Project plugins are registered by adding their names to `$HERMES_HOME/config.yaml`, but Rulesync does not persist Hermes's global diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 85da1d8c9..0c577ea1d 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -64,8 +64,11 @@ covered for both advertised scopes. Rulesync honors Hermes profiles through `HERMES_HOME`. When it is set, its value is the profile root itself: global configuration is read and written directly under `$HERMES_HOME` (`config.yaml`, `skills/`, `plugins/`, and `rulesync/`), -without appending `.hermes`. When it is unset, the default remains -`~/.hermes`. Project-scoped paths remain rooted in the project. +without appending `.hermes`. When it is unset, Rulesync follows Hermes's own +platform default: `~/.hermes` everywhere except Windows, where it is +`%LOCALAPPDATA%\hermes`. Because `HERMES_HOME` names where Hermes itself reads +the profile, it also takes precedence over `--output-roots` in global scope. +Project-scoped paths remain rooted in the project. Project plugins are registered by adding their names to `$HERMES_HOME/config.yaml`, but Rulesync does not persist Hermes's global diff --git a/src/constants/hermesagent-paths.ts b/src/constants/hermesagent-paths.ts index 7771b7c31..fc95e90f8 100644 --- a/src/constants/hermesagent-paths.ts +++ b/src/constants/hermesagent-paths.ts @@ -16,9 +16,19 @@ /** Project-root instruction file auto-injected by Hermes Agent. */ export const HERMESAGENT_RULE_FILE_NAME = ".hermes.md"; -/** Root directory for Hermes Agent global configuration (the HERMES_HOME dir). */ +/** + * Root directory for Hermes Agent global configuration (the HERMES_HOME dir). + * Also the project-local plugin tree, which is `.hermes/` on every platform. + */ export const HERMESAGENT_GLOBAL_DIR = ".hermes"; +/** + * Home-relative global profile root on Windows: upstream defaults to + * `%LOCALAPPDATA%\hermes` there, not `~/.hermes`. + * Resolve it through `getHermesagentGlobalDir()` rather than reading it directly. + */ +export const HERMESAGENT_GLOBAL_WIN32_DIR = join("AppData", "Local", "hermes"); + /** MCP servers and other settings live in `config.yaml` under `~/.hermes/`. */ export const HERMESAGENT_CONFIG_FILE_NAME = "config.yaml"; export const HERMESAGENT_CONFIG_FILE_PATH = join( diff --git a/src/e2e/e2e-checks.spec.ts b/src/e2e/e2e-checks.spec.ts index acbb6564b..f3338b092 100644 --- a/src/e2e/e2e-checks.spec.ts +++ b/src/e2e/e2e-checks.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { RULESYNC_CHECKS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; import { ChecksProcessor } from "../features/checks/checks-processor.js"; import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -116,10 +117,10 @@ Look for injection vulnerabilities. join(testDir, ".hermes", "plugins", "rulesync-checks", "__init__.py"), ); expect(plugin).toContain('ctx.register_hook("pre_verify", require_rulesync_checks)'); - expect(await readFileContent(join(homeDir, ".hermes", "config.yaml"))).toContain( - "rulesync-checks", - ); - expect(await fileExists(join(homeDir, ".hermes", ".env"))).toBe(false); + expect( + await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml")), + ).toContain("rulesync-checks"); + expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), ".env"))).toBe(false); } expect(generatedContent).toContain("Look for injection vulnerabilities."); }, diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index e1e0cfe3a..a4ecf05e6 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; import { CommandsProcessor } from "../features/commands/commands-processor.js"; import { fileExists, readFileContent, removeFile, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -79,7 +80,7 @@ const commandsGlobalTargets = [ // Hermes commands are global plugin-backed slash commands, separate from skills. { target: "hermesagent", - outputPath: join(".hermes", "rulesync", "commands", "review-pr.json"), + outputPath: join(getHermesagentGlobalDir(), "rulesync", "commands", "review-pr.json"), }, { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, { target: "rovodev", outputPath: join(".rovodev", "prompts", "review-pr.md") }, @@ -349,9 +350,9 @@ describe("E2E: commands (global mode)", () => { }); const plugin = await readFileContent( - join(homeDir, ".hermes", "plugins", "rulesync-commands", "__init__.py"), + join(homeDir, getHermesagentGlobalDir(), "plugins", "rulesync-commands", "__init__.py"), ); - const config = await readFileContent(join(homeDir, ".hermes", "config.yaml")); + const config = await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml")); expect(plugin).toContain("ctx.register_command(slug, handler, description)"); expect(plugin).toContain('"delegate_task"'); expect(plugin).toContain('Path(__file__).resolve().parents[2] / "rulesync" / "commands"'); @@ -368,7 +369,7 @@ describe("E2E: commands (global mode)", () => { '---\ndescription: "Review a pull request"\ntargets: ["hermesagent"]\n---\nReview it.\n', ); await writeFileContent( - join(homeDir, ".hermes", "config.yaml"), + join(homeDir, getHermesagentGlobalDir(), "config.yaml"), "plugins:\n enabled:\n - existing-plugin\n", ); @@ -388,9 +389,11 @@ describe("E2E: commands (global mode)", () => { }); expect( - await fileExists(join(homeDir, ".hermes", "plugins", "rulesync-commands", "__init__.py")), + await fileExists( + join(homeDir, getHermesagentGlobalDir(), "plugins", "rulesync-commands", "__init__.py"), + ), ).toBe(false); - const config = await readFileContent(join(homeDir, ".hermes", "config.yaml")); + const config = await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml")); expect(config).toContain("- existing-plugin"); expect(config).not.toContain("rulesync-commands"); }); diff --git a/src/e2e/e2e-hermesagent-home.spec.ts b/src/e2e/e2e-hermesagent-home.spec.ts index 5cac36780..b94dfbb63 100644 --- a/src/e2e/e2e-hermesagent-home.spec.ts +++ b/src/e2e/e2e-hermesagent-home.spec.ts @@ -12,6 +12,7 @@ import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, } from "../constants/rulesync-paths.js"; import { fileExists, readFileContent, removeFile, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { execFileAsync, rulesyncArgs, @@ -137,7 +138,7 @@ describe("E2E: HERMES_HOME", () => { expect(config).toContain("rm -rf *"); expect(config).toContain("rulesync-commands"); expect(config).toContain("rulesync-subagents"); - expect(await fileExists(join(homeDir, ".hermes", "config.yaml"))).toBe(false); + expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))).toBe(false); await runGenerate({ target: "hermesagent", @@ -205,7 +206,7 @@ describe("E2E: HERMES_HOME", () => { expect(await fileExists(join(homeDir, "plugins", "rulesync-commands", "plugin.yaml"))).toBe( false, ); - expect(await fileExists(join(homeDir, ".hermes", "config.yaml"))).toBe(false); + expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))).toBe(false); }); it("imports every global feature from the custom profile into the global RuleSync root", async () => { @@ -229,7 +230,7 @@ describe("E2E: HERMES_HOME", () => { ].join("\n"), ); await writeFileContent( - join(homeDir, ".hermes", "config.yaml"), + join(homeDir, getHermesagentGlobalDir(), "config.yaml"), ["mcp_servers:", " wrong-profile:", " command: false"].join("\n"), ); await writeFileContent( diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index fb7e17e84..111620c78 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { RULESYNC_HOOKS_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js"; import { HooksProcessor } from "../features/hooks/hooks-processor.js"; import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -980,7 +981,9 @@ describe("E2E: hooks (global mode)", () => { // The config is YAML; assert the canonical hooks survive generation under // Hermes's real, functioning event keys. - const generatedContent = await readFileContent(join(homeDir, ".hermes", "config.yaml")); + const generatedContent = await readFileContent( + join(homeDir, getHermesagentGlobalDir(), "config.yaml"), + ); expect(generatedContent).not.toContain("rulesync:"); expect(generatedContent).toContain("on_session_start"); expect(generatedContent).toContain("pre_tool_call"); @@ -993,7 +996,7 @@ describe("E2E: hooks (global mode)", () => { it("should import Hermes native-only hooks without dropping them", async () => { const homeDir = getHomeDir(); await writeFileContent( - join(homeDir, ".hermes", "config.yaml"), + join(homeDir, getHermesagentGlobalDir(), "config.yaml"), [ "hooks:", " pre_tool_call:", diff --git a/src/e2e/e2e-ignore.spec.ts b/src/e2e/e2e-ignore.spec.ts index b963227bb..04c467635 100644 --- a/src/e2e/e2e-ignore.spec.ts +++ b/src/e2e/e2e-ignore.spec.ts @@ -17,6 +17,7 @@ import { RULESYNC_AIIGNORE_RELATIVE_FILE_PATH } from "../constants/rulesync-path import { getZedGlobalDir, ZED_SETTINGS_FILE_NAME } from "../constants/zed-paths.js"; import { IgnoreProcessor } from "../features/ignore/ignore-processor.js"; import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -126,7 +127,9 @@ credentials/ if (target === "hermesagent") { expect(await readFileContent(join(hermesHome, "config.yaml"))).toContain("rulesync-ignore"); expect(await fileExists(join(hermesHome, ".env"))).toBe(false); - expect(await fileExists(join(homeDir, ".hermes", "config.yaml"))).toBe(false); + expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))).toBe( + false, + ); } }, ); @@ -148,8 +151,8 @@ credentials/ expect( await fileExists(join(testDir, ".hermes", "plugins", "rulesync-ignore", "__init__.py")), ).toBe(false); - expect(await fileExists(join(homeDir, ".hermes", "config.yaml"))).toBe(false); - expect(await fileExists(join(homeDir, ".hermes", ".env"))).toBe(false); + expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))).toBe(false); + expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), ".env"))).toBe(false); await runGenerate({ target: "hermesagent", diff --git a/src/e2e/e2e-mcp.spec.ts b/src/e2e/e2e-mcp.spec.ts index 60df58e57..b86873bc2 100644 --- a/src/e2e/e2e-mcp.spec.ts +++ b/src/e2e/e2e-mcp.spec.ts @@ -14,6 +14,7 @@ import { import { getZedGlobalDir } from "../constants/zed-paths.js"; import { McpProcessor } from "../features/mcp/mcp-processor.js"; import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -750,7 +751,7 @@ const mcpGlobalTargets = [ { target: "cursor", outputPath: join(".cursor", "mcp.json") }, { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "goose", outputPath: join(".config", "goose", "config.yaml") }, - { target: "hermesagent", outputPath: join(".hermes", "config.yaml") }, + { target: "hermesagent", outputPath: join(getHermesagentGlobalDir(), "config.yaml") }, { target: "opencode", outputPath: join(".config", "opencode", "opencode.jsonc") }, { target: "codexcli", outputPath: join(".codex", "config.toml") }, { target: "grokcli", outputPath: join(".grok", "config.toml") }, @@ -849,7 +850,7 @@ describe("E2E: mcp (global mode)", () => { it("should import Hermes OAuth and lifecycle settings into a target override", async () => { const homeDir = getHomeDir(); await writeFileContent( - join(homeDir, ".hermes", "config.yaml"), + join(homeDir, getHermesagentGlobalDir(), "config.yaml"), [ "mcp_servers:", " remote:", diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts index 32a709386..36e072dac 100644 --- a/src/e2e/e2e-permissions.spec.ts +++ b/src/e2e/e2e-permissions.spec.ts @@ -13,6 +13,7 @@ import { import { getZedGlobalDir } from "../constants/zed-paths.js"; import { PermissionsProcessor } from "../features/permissions/permissions-processor.js"; import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -2421,7 +2422,7 @@ describe("E2E: permissions (global mode)", () => { // Pre-seed config.yaml with unrelated user settings to verify the // non-destructive merge into ~/.hermes/config.yaml. await writeFileContent( - join(homeDir, ".hermes", "config.yaml"), + join(homeDir, getHermesagentGlobalDir(), "config.yaml"), ["model: hermes-large", "terminal: tmux"].join("\n"), ); @@ -2436,7 +2437,9 @@ describe("E2E: permissions (global mode)", () => { // merged into the shared global ~/.hermes/config.yaml. Allow rules are also // surfaced as a flat `command_allowlist`, and the canonical map is preserved // under `permissions.rulesync` for round-tripping. - const parsed = toTable(load(await readFileContent(join(homeDir, ".hermes", "config.yaml")))); + const parsed = toTable( + load(await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))), + ); expect(parsed.command_allowlist).toEqual(["git status *"]); // The bash deny reaches Hermes's hard denylist (previously silently dropped). expect(toTable(parsed.approvals).deny).toEqual(["rm -rf *"]); @@ -2454,7 +2457,7 @@ describe("E2E: permissions (global mode)", () => { it("should import native Hermes permission settings without private provenance", async () => { const homeDir = getHomeDir(); await writeFileContent( - join(homeDir, ".hermes", "config.yaml"), + join(homeDir, getHermesagentGlobalDir(), "config.yaml"), [ "model: hermes-large", 'command_allowlist: ["git *", "pnpm *"]', diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index bad1f6437..5d40f5e9e 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; import { SkillsProcessor } from "../features/skills/skills-processor.js"; import { ensureDir, fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -665,7 +666,7 @@ const skillsGlobalTargets = [ { // Hermes Agent reads skills from ~/.hermes/skills/ (global only). target: "hermesagent", - outputPath: join(".hermes", "skills", "test-skill", "SKILL.md"), + outputPath: join(getHermesagentGlobalDir(), "skills", "test-skill", "SKILL.md"), }, { // Kiro reads global skills from ~/.kiro/skills/. @@ -697,7 +698,7 @@ describe("E2E: skills (global mode)", () => { it("should import Hermes skill metadata into a target override", async () => { const homeDir = getHomeDir(); await writeFileContent( - join(homeDir, ".hermes", "skills", "test-skill", "SKILL.md"), + join(homeDir, getHermesagentGlobalDir(), "skills", "test-skill", "SKILL.md"), [ "---", "name: test-skill", diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index 7c95e991a..55497ae29 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; import { SubagentsProcessor } from "../features/subagents/subagents-processor.js"; import { ensureDir, fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { getHermesagentGlobalDir } from "../utils/hermesagent.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -177,7 +178,7 @@ const subagentsGlobalTargets = [ }, { target: "hermesagent", - outputPath: join(".hermes", "rulesync", "subagents", "planner.json"), + outputPath: join(getHermesagentGlobalDir(), "rulesync", "subagents", "planner.json"), }, ] as const; @@ -220,10 +221,10 @@ You are the planner. Analyze files and create a plan. expect(generatedContent).toContain("Analyze files and create a plan."); if (target === "hermesagent") { - expect(await readFileContent(join(homeDir, ".hermes", "config.yaml"))).toContain( - "rulesync-subagents", - ); - expect(await fileExists(join(homeDir, ".hermes", ".env"))).toBe(false); + expect( + await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml")), + ).toContain("rulesync-subagents"); + expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), ".env"))).toBe(false); expect( await readFileContent( join(testDir, ".hermes", "plugins", "rulesync-subagents", "__init__.py"), @@ -726,7 +727,7 @@ describe("E2E: subagents (global mode)", () => { it("should import Hermes global subagents into the global RuleSync source directory", async () => { const homeDir = getHomeDir(); await writeFileContent( - join(homeDir, ".hermes", "rulesync", "subagents", "planner.json"), + join(homeDir, getHermesagentGlobalDir(), "rulesync", "subagents", "planner.json"), JSON.stringify({ slug: "planner", name: "Planner", diff --git a/src/features/commands/hermesagent-command.ts b/src/features/commands/hermesagent-command.ts index 4e868e0be..125dca8c0 100644 --- a/src/features/commands/hermesagent-command.ts +++ b/src/features/commands/hermesagent-command.ts @@ -16,6 +16,7 @@ import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH, RULESYNC_SKILLS_RELATIVE_DIR_PATH, } from "../../constants/rulesync-paths.js"; +import type { SharedWritePath } from "../../lib/shared-file-derive.js"; import { ValidationResult } from "../../types/ai-file.js"; import { ToolFile } from "../../types/tool-file.js"; import { findFilesByGlobs, readFileContentOrNull, toPosixPath } from "../../utils/file.js"; @@ -23,6 +24,7 @@ import { getHermesagentRelativeDirPath, getHermesagentRelativeFilePath, getHermesagentRulesyncOutputRoot, + getHermesagentSharedConfigWritePaths, } from "../../utils/hermesagent.js"; import { applySharedConfigPatch, @@ -258,19 +260,12 @@ export class HermesagentCommand extends ToolCommand { }; } - static getExtraSharedWritePaths({ global = false }: { global?: boolean } = {}): { - relativeDirPath: string; - relativeFilePath: string; - }[] { - return [ - { - relativeDirPath: getHermesagentRelativeDirPath({ - global, - relativeDirPath: HERMESAGENT_GLOBAL_DIR, - }), - relativeFilePath: basename(HERMESAGENT_CONFIG_FILE_PATH), - }, - ]; + /** + * `config.yaml` under every spelling the global profile root can take. + * @see getHermesagentSharedConfigWritePaths + */ + static getExtraSharedWritePaths(): SharedWritePath[] { + return getHermesagentSharedConfigWritePaths(); } static async validateRulesyncCommands({ diff --git a/src/features/hooks/hermesagent-hooks.ts b/src/features/hooks/hermesagent-hooks.ts index aea5b110f..0e3b75ee5 100644 --- a/src/features/hooks/hermesagent-hooks.ts +++ b/src/features/hooks/hermesagent-hooks.ts @@ -4,6 +4,7 @@ import { HERMESAGENT_CONFIG_FILE_NAME, HERMESAGENT_GLOBAL_DIR, } from "../../constants/hermesagent-paths.js"; +import type { SharedWritePath } from "../../lib/shared-file-derive.js"; import { type AiFileParams, ValidationResult } from "../../types/ai-file.js"; import { CANONICAL_TO_HERMESAGENT_EVENT_NAMES, @@ -17,6 +18,7 @@ import { readFileContent } from "../../utils/file.js"; import { getHermesagentRelativeDirPath, getHermesagentRulesyncOutputRoot, + getHermesagentSharedConfigWritePaths, } from "../../utils/hermesagent.js"; import type { Logger } from "../../utils/logger.js"; import { PROTOTYPE_POLLUTION_KEYS } from "../../utils/prototype-pollution.js"; @@ -267,6 +269,14 @@ export class HermesagentHooks extends ToolHooks { }; } + /** + * `config.yaml` under every spelling the global profile root can take. + * @see getHermesagentSharedConfigWritePaths + */ + static getExtraSharedWritePaths(): SharedWritePath[] { + return getHermesagentSharedConfigWritePaths(); + } + constructor(params: HermesagentHooksParams) { super({ ...params, diff --git a/src/features/mcp/hermesagent-mcp.ts b/src/features/mcp/hermesagent-mcp.ts index f19db6c76..db170e885 100644 --- a/src/features/mcp/hermesagent-mcp.ts +++ b/src/features/mcp/hermesagent-mcp.ts @@ -4,12 +4,14 @@ import { HERMESAGENT_CONFIG_FILE_NAME, HERMESAGENT_GLOBAL_DIR, } from "../../constants/hermesagent-paths.js"; +import type { SharedWritePath } from "../../lib/shared-file-derive.js"; import { ValidationResult } from "../../types/ai-file.js"; import { McpServers } from "../../types/mcp.js"; import { readFileContentOrNull } from "../../utils/file.js"; import { getHermesagentRelativeDirPath, getHermesagentRulesyncOutputRoot, + getHermesagentSharedConfigWritePaths, } from "../../utils/hermesagent.js"; import { omitPrototypePollutionKeys, @@ -359,6 +361,14 @@ export class HermesagentMcp extends ToolMcp { }; } + /** + * `config.yaml` under every spelling the global profile root can take. + * @see getHermesagentSharedConfigWritePaths + */ + static getExtraSharedWritePaths(): SharedWritePath[] { + return getHermesagentSharedConfigWritePaths(); + } + static async fromFile({ outputRoot = process.cwd(), validate = true, diff --git a/src/features/permissions/hermesagent-permissions.ts b/src/features/permissions/hermesagent-permissions.ts index a98839696..b662621e5 100644 --- a/src/features/permissions/hermesagent-permissions.ts +++ b/src/features/permissions/hermesagent-permissions.ts @@ -8,6 +8,7 @@ import { RULESYNC_PERMISSIONS_FILE_NAME, RULESYNC_RELATIVE_DIR_PATH, } from "../../constants/rulesync-paths.js"; +import type { SharedWritePath } from "../../lib/shared-file-derive.js"; import { type AiFileParams, ValidationResult } from "../../types/ai-file.js"; import { type PermissionAction, @@ -18,6 +19,7 @@ import { readFileContent } from "../../utils/file.js"; import { getHermesagentRelativeDirPath, getHermesagentRulesyncOutputRoot, + getHermesagentSharedConfigWritePaths, } from "../../utils/hermesagent.js"; import { isRecord, isStringArray } from "../../utils/type-guards.js"; import { @@ -188,6 +190,14 @@ export class HermesagentPermissions extends ToolPermissions { }; } + /** + * `config.yaml` under every spelling the global profile root can take. + * @see getHermesagentSharedConfigWritePaths + */ + static getExtraSharedWritePaths(): SharedWritePath[] { + return getHermesagentSharedConfigWritePaths(); + } + constructor(params: HermesagentPermissionsParams) { super({ ...params, diff --git a/src/features/shared/shared-config-gateway.ts b/src/features/shared/shared-config-gateway.ts index 588c2b97d..10dc7d9d4 100644 --- a/src/features/shared/shared-config-gateway.ts +++ b/src/features/shared/shared-config-gateway.ts @@ -262,6 +262,8 @@ export type SharedConfigFileDeclaration = { // separators, independent of the platform-specific path constants. export const CLAUDE_SETTINGS_SHARED_FILE_KEY = ".claude/settings.json"; export const HERMES_CONFIG_SHARED_FILE_KEY = ".hermes/config.yaml"; +export const HERMES_WIN32_CONFIG_SHARED_FILE_KEY = "AppData/Local/hermes/config.yaml"; +export const HERMES_HOME_CONFIG_SHARED_FILE_KEY = "config.yaml"; export const TAKT_CONFIG_SHARED_FILE_KEY = ".takt/config.yaml"; export const CODEXCLI_CONFIG_SHARED_FILE_KEY = ".codex/config.toml"; export const GROKCLI_CONFIG_SHARED_FILE_KEY = ".grok/config.toml"; @@ -325,6 +327,41 @@ export const SHARED_CONFIG_OWNERSHIP: Readonly path.relativeDirPath)).toEqual([ + HERMESAGENT_GLOBAL_DIR, + HERMESAGENT_GLOBAL_WIN32_DIR, + ".", ]); }); }); diff --git a/src/features/subagents/hermesagent-subagent.ts b/src/features/subagents/hermesagent-subagent.ts index 5a8320d67..a2221ce7b 100644 --- a/src/features/subagents/hermesagent-subagent.ts +++ b/src/features/subagents/hermesagent-subagent.ts @@ -10,10 +10,12 @@ import { HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_MANIFEST_PATH, } from "../../constants/hermesagent-paths.js"; import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import type { SharedWritePath } from "../../lib/shared-file-derive.js"; import { type ValidationResult } from "../../types/ai-file.js"; import { getHermesagentRelativeDirPath, getHermesagentRulesyncOutputRoot, + getHermesagentSharedConfigWritePaths, } from "../../utils/hermesagent.js"; import { applySharedConfigPatch, @@ -292,22 +294,12 @@ export class HermesagentSubagent extends ToolSubagent { * shared `~/.hermes/config.yaml` (enabling the `rulesync-subagents` plugin), * so the write must be declared for the shared-file order derivation. */ - static getExtraSharedWritePaths({ - global = false, - }: { - global?: boolean; - } = {}): { relativeDirPath: string; relativeFilePath: string }[] { - return global - ? [ - { - relativeDirPath: getHermesagentRelativeDirPath({ - global, - relativeDirPath: HERMESAGENT_GLOBAL_DIR, - }), - relativeFilePath: basename(HERMESAGENT_CONFIG_FILE_PATH), - }, - ] - : []; + /** + * `config.yaml` under every spelling the global profile root can take. + * @see getHermesagentSharedConfigWritePaths + */ + static getExtraSharedWritePaths(): SharedWritePath[] { + return getHermesagentSharedConfigWritePaths(); } static getSettablePathsForRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): string[] { diff --git a/src/lib/shared-file-derive.test.ts b/src/lib/shared-file-derive.test.ts index e92bdb927..6ca07c3d6 100644 --- a/src/lib/shared-file-derive.test.ts +++ b/src/lib/shared-file-derive.test.ts @@ -201,11 +201,25 @@ describe("shared-file write derivation", () => { "mcp", "permissions", ], + "AppData/Local/hermes/config.yaml": [ + "commands", + "hooks", + "mcp", + "permissions", + "subagents", + ], "AppData/Roaming/Zed/settings.json": [ "ignore", "mcp", "permissions", ], + "config.yaml": [ + "commands", + "hooks", + "mcp", + "permissions", + "subagents", + ], "kilo.json": [ "mcp", "rules", diff --git a/src/utils/hermesagent.test.ts b/src/utils/hermesagent.test.ts index 0bc097993..8cddacb01 100644 --- a/src/utils/hermesagent.test.ts +++ b/src/utils/hermesagent.test.ts @@ -3,10 +3,12 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + getHermesagentGlobalDir, getHermesagentHome, getHermesagentRelativeDirPath, getHermesagentRelativeFilePath, getHermesagentRulesyncOutputRoot, + getHermesagentSharedConfigWritePaths, resolveHermesagentOutputRoot, } from "./hermesagent.js"; @@ -28,7 +30,7 @@ describe("Hermes Agent profile paths", () => { "/default-home", ); expect(getHermesagentRelativeDirPath({ global: true, relativeDirPath: ".hermes/skills" })).toBe( - join(".hermes", "skills"), + join(getHermesagentGlobalDir(), "skills"), ); process.env.HERMES_HOME = " "; @@ -62,6 +64,43 @@ describe("Hermes Agent profile paths", () => { expect(() => getHermesagentRelativeFilePath({ global: true, relativeFilePath: "config.yaml" }), ).toThrow("Hermes Agent global path must be within .hermes"); + // A `..` segment that does not escape `.hermes` on its own is still + // rejected, matching the codebase-standard checkPathTraversal semantics. + expect(() => + getHermesagentRelativeDirPath({ + global: true, + relativeDirPath: ".hermes/skills/../../.hermes/plugins", + }), + ).toThrow("Hermes Agent global path must be within .hermes"); + }); + + it("follows the platform default profile directory when HERMES_HOME is unset", () => { + delete process.env.HERMES_HOME; + + // Upstream defaults to %LOCALAPPDATA%\hermes on win32 and ~/.hermes elsewhere. + expect(getHermesagentGlobalDir()).toBe( + process.platform === "win32" ? join("AppData", "Local", "hermes") : ".hermes", + ); + expect( + getHermesagentRelativeFilePath({ global: true, relativeFilePath: ".hermes/config.yaml" }), + ).toBe(join(getHermesagentGlobalDir(), "config.yaml")); + }); + + it("declares every config.yaml spelling regardless of platform and HERMES_HOME", () => { + // The shared-write derivation runs at module load, so the declared set must + // not depend on the ambient environment or the drift guards go blind. + const declared = getHermesagentSharedConfigWritePaths().map((path) => path.relativeDirPath); + process.env.HERMES_HOME = "/custom-hermes"; + + expect(declared).toEqual([".hermes", join("AppData", "Local", "hermes"), "."]); + expect(getHermesagentSharedConfigWritePaths().map((path) => path.relativeDirPath)).toEqual( + declared, + ); + expect( + declared.includes( + getHermesagentRelativeDirPath({ global: true, relativeDirPath: ".hermes" }), + ), + ).toBe(true); }); it("keeps project paths and the global RuleSync source root separate", () => { diff --git a/src/utils/hermesagent.ts b/src/utils/hermesagent.ts index 2ddcd13e3..165aa2563 100644 --- a/src/utils/hermesagent.ts +++ b/src/utils/hermesagent.ts @@ -1,13 +1,32 @@ -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; -import { HERMESAGENT_GLOBAL_DIR } from "../constants/hermesagent-paths.js"; -import { getHomeDirectory } from "./file.js"; +import { + HERMESAGENT_CONFIG_FILE_NAME, + HERMESAGENT_GLOBAL_DIR, + HERMESAGENT_GLOBAL_WIN32_DIR, +} from "../constants/hermesagent-paths.js"; +import type { SharedWritePath } from "../lib/shared-file-derive.js"; +import { checkPathTraversal, getHomeDirectory } from "./file.js"; export function getHermesagentHome(): string | undefined { const configuredHome = process.env.HERMES_HOME?.trim(); return configuredHome ? resolve(configuredHome) : undefined; } +/** + * The home-relative Hermes profile directory used when `HERMES_HOME` is unset. + * + * Upstream `_get_platform_default_hermes_home()` returns `%LOCALAPPDATA%\hermes` + * on win32 and `~/.hermes` everywhere else, so the global output directory is + * platform-dependent — a global generate on Windows that wrote `~/.hermes` + * would land where Hermes never reads. + * + * @see https://github.com/NousResearch/hermes-agent `hermes_constants.py` + */ +export function getHermesagentGlobalDir(): string { + return process.platform === "win32" ? HERMESAGENT_GLOBAL_WIN32_DIR : HERMESAGENT_GLOBAL_DIR; +} + export function resolveHermesagentOutputRoot({ outputRoot, global, @@ -18,6 +37,15 @@ export function resolveHermesagentOutputRoot({ return global ? (getHermesagentHome() ?? outputRoot) : outputRoot; } +/** + * Map a canonical `.hermes/...` path constant onto the directory rulesync + * actually writes in the requested scope. + * + * Project scope keeps the constant as-is (the project tree is `.hermes/` + * everywhere). Global scope strips the `.hermes` prefix and re-anchors it: + * `HERMES_HOME` *is* the profile root, so nothing is prepended; otherwise the + * platform default directory takes its place. + */ export function getHermesagentRelativeDirPath({ global, relativeDirPath, @@ -25,15 +53,23 @@ export function getHermesagentRelativeDirPath({ global: boolean; relativeDirPath: string; }): string { - if (!global || !getHermesagentHome()) return relativeDirPath; + if (!global) return relativeDirPath; const relativePath = relative(HERMESAGENT_GLOBAL_DIR, relativeDirPath); - if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + try { + // The input is checked as well as the de-prefixed result: `relative()` + // normalizes `..` away, so a path that walks out of `.hermes` and back in + // would otherwise pass a containment check on the result alone. + checkPathTraversal({ relativePath: relativeDirPath, intendedRootDir: "." }); + checkPathTraversal({ relativePath, intendedRootDir: HERMESAGENT_GLOBAL_DIR }); + } catch { throw new Error( `Hermes Agent global path must be within ${HERMESAGENT_GLOBAL_DIR}: ${relativeDirPath}`, ); } - return relativePath; + // `.` rather than `""` for the profile root itself: both join identically, but + // only one of them compares equal to the declared shared-write path. + return getHermesagentHome() ? relativePath || "." : join(getHermesagentGlobalDir(), relativePath); } export function getHermesagentRelativeFilePath({ @@ -52,6 +88,30 @@ export function getHermesagentRelativeFilePath({ ); } +/** + * Every spelling `config.yaml` can take in global scope, so that the + * shared-write derivation and the gateway ownership table it is checked against + * see the same set of keys on every platform and with or without `HERMES_HOME`. + * + * `getHermesagentRelativeDirPath` resolves exactly one of these per process, + * which would otherwise make the derived shared-file key depend on the ambient + * environment — the drift guards would then go blind in precisely the + * configuration this feature exists for. + */ +export function getHermesagentSharedConfigWritePaths(): SharedWritePath[] { + return [ + // `~/.hermes` (every platform but win32) and `%LOCALAPPDATA%\hermes` (win32). + { relativeDirPath: HERMESAGENT_GLOBAL_DIR, relativeFilePath: HERMESAGENT_CONFIG_FILE_NAME }, + { + relativeDirPath: HERMESAGENT_GLOBAL_WIN32_DIR, + relativeFilePath: HERMESAGENT_CONFIG_FILE_NAME, + }, + // The `HERMES_HOME` case: the profile root is the output root itself, so the + // config sits at the root with no directory component. + { relativeDirPath: ".", relativeFilePath: HERMESAGENT_CONFIG_FILE_NAME }, + ]; +} + export function getHermesagentRulesyncOutputRoot({ nativeOutputRoot, global, diff --git a/src/utils/tool-output-root.ts b/src/utils/tool-output-root.ts index d34e5e3ad..7e5d04cc1 100644 --- a/src/utils/tool-output-root.ts +++ b/src/utils/tool-output-root.ts @@ -1,7 +1,19 @@ import type { ToolTarget } from "../types/tool-targets.js"; +import { validateOutputRoot } from "./file.js"; import { resolveHermesagentOutputRoot } from "./hermesagent.js"; import { getKimiCodeHome } from "./kimi-code.js"; +/** + * Substitute a tool's home override (`HERMES_HOME`, `KIMI_CODE_HOME`) for the + * output root in global scope. + * + * The override wins over `--output-roots`: it names where the tool itself reads + * its profile, so writing anywhere else would produce files the tool ignores. + * + * The substituted value goes through the same `validateOutputRoot` the CLI and + * config paths use, so an override of `/` or an unnormalized path is rejected + * instead of silently becoming the output root. + */ export function resolveToolOutputRoot({ outputRoot, toolTarget, @@ -12,11 +24,14 @@ export function resolveToolOutputRoot({ global: boolean; }): string { if (!global) return outputRoot; - if (toolTarget === "hermesagent") { - return resolveHermesagentOutputRoot({ outputRoot, global }); + const resolved = + toolTarget === "hermesagent" + ? resolveHermesagentOutputRoot({ outputRoot, global }) + : toolTarget === "kimi-code" + ? (getKimiCodeHome() ?? outputRoot) + : outputRoot; + if (resolved !== outputRoot) { + validateOutputRoot(resolved); } - if (toolTarget === "kimi-code") { - return getKimiCodeHome() ?? outputRoot; - } - return outputRoot; + return resolved; } diff --git a/vitest.config.ts b/vitest.config.ts index 8aff8b72c..1ed510005 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,16 @@ export default defineConfig({ environment: "node", include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], exclude: ["src/**/*.spec.ts"], // Exclude E2E tests + // Neutralize the tool home overrides before any test module loads. They + // change the paths `getSettablePaths` returns (and therefore the shared-file + // keys derived at module load), so a developer who exports them — which the + // Hermes and Kimi users this code targets are the most likely to do — would + // otherwise see unrelated specs fail on `pnpm cicheck`. Specs that exercise + // the overrides set them explicitly at runtime. + env: { + HERMES_HOME: "", + KIMI_CODE_HOME: "", + }, typecheck: { enabled: false, include: ["src/**/*.test-d.ts"], From 372c63247268733c1829c2006901bb4f47234dcb Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Wed, 29 Jul 2026 11:18:56 -0700 Subject: [PATCH 2/3] refactor(shared-config): derive the hermes/kimi config file key from the path written Review follow-ups on PR #2483: - The three Hermes config.yaml ownership entries (and now the two kimi-code config.toml entries) share one hoisted declaration, so a policy edit cannot land on a single spelling while the others keep the old semantics. - Writers pass the key of the file they are actually writing via getHermesagentConfigSharedFileKey / getKimiCodeConfigSharedFileKey instead of a constant that only names the non-HERMES_HOME, non-win32 spelling. This is the sharedConfigFileKey convention the Zed and Codex adapters already follow, and it makes the new declarations live rather than guard-only data. - kimi-code gets the same two-spelling treatment: KIMI_CODE_HOME made its derived key collapse to a bare config.toml, so both drift guards broke for it exactly as they did for hermes. Fixing it here keeps the vitest env clearing from masking a live bug. - resolveToolOutputRoot re-throws a rejected home override naming the variable (the user never passed --output-roots), and gains a unit test file. - vitest.e2e.config.ts clears the same two overrides; e2e helpers spread process.env into the CLI child, so an exported one would redirect global output away from the pseudo-home the specs assert against. - Records why the win32 profile dir is home-relative rather than read from LOCALAPPDATA, matching the Zed and Warp constants. Co-Authored-By: Claude Fable 5 --- src/constants/hermesagent-paths.ts | 5 + src/features/commands/commands-processor.ts | 5 +- src/features/commands/hermesagent-command.ts | 36 ++++-- src/features/hooks/hermesagent-hooks.ts | 4 +- src/features/hooks/kimi-code-hooks.ts | 14 +- src/features/mcp/hermesagent-mcp.ts | 11 +- src/features/mcp/kimi-code-mcp.ts | 23 +--- .../permissions/hermesagent-permissions.ts | 4 +- .../permissions/kimi-code-permissions.ts | 13 +- src/features/shared/shared-config-gateway.ts | 121 ++++++++---------- .../subagents/hermesagent-subagent.ts | 26 ++-- src/lib/shared-file-derive.test.ts | 5 + src/utils/hermesagent.ts | 17 +++ src/utils/kimi-code.ts | 25 +++- src/utils/tool-output-root.test.ts | 66 ++++++++++ src/utils/tool-output-root.ts | 21 ++- vitest.e2e.config.ts | 8 ++ 17 files changed, 279 insertions(+), 125 deletions(-) create mode 100644 src/utils/tool-output-root.test.ts diff --git a/src/constants/hermesagent-paths.ts b/src/constants/hermesagent-paths.ts index fc95e90f8..9ec8ec616 100644 --- a/src/constants/hermesagent-paths.ts +++ b/src/constants/hermesagent-paths.ts @@ -26,6 +26,11 @@ export const HERMESAGENT_GLOBAL_DIR = ".hermes"; * Home-relative global profile root on Windows: upstream defaults to * `%LOCALAPPDATA%\hermes` there, not `~/.hermes`. * Resolve it through `getHermesagentGlobalDir()` rather than reading it directly. + * + * Home-relative rather than read from `LOCALAPPDATA`, matching how every other + * Windows global path in rulesync is spelled (`ZED_GLOBAL_WIN32_DIR`, + * `WARP_WIN32_DIR`). A profile with `LOCALAPPDATA` redirected elsewhere is not + * followed; those users should set `HERMES_HOME` explicitly. */ export const HERMESAGENT_GLOBAL_WIN32_DIR = join("AppData", "Local", "hermes"); diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index ca9062d0d..2cde80e4e 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -914,7 +914,10 @@ export class CommandsProcessor extends FeatureProcessor { ); const currentContent = await readFileContentOrNull(configPath); if (currentContent === null) return changedCount; - const nextContent = getDisabledHermesCommandsPluginConfigContent(currentContent); + const nextContent = getDisabledHermesCommandsPluginConfigContent({ + currentContent, + global: this.global, + }); if (nextContent === currentContent) return changedCount; if (this.dryRun) { diff --git a/src/features/commands/hermesagent-command.ts b/src/features/commands/hermesagent-command.ts index 125dca8c0..d4b6ea700 100644 --- a/src/features/commands/hermesagent-command.ts +++ b/src/features/commands/hermesagent-command.ts @@ -21,16 +21,13 @@ import { ValidationResult } from "../../types/ai-file.js"; import { ToolFile } from "../../types/tool-file.js"; import { findFilesByGlobs, readFileContentOrNull, toPosixPath } from "../../utils/file.js"; import { + getHermesagentConfigSharedFileKey, getHermesagentRelativeDirPath, getHermesagentRelativeFilePath, getHermesagentRulesyncOutputRoot, getHermesagentSharedConfigWritePaths, } from "../../utils/hermesagent.js"; -import { - applySharedConfigPatch, - HERMES_CONFIG_SHARED_FILE_KEY, - parseSharedConfig, -} from "../shared/shared-config-gateway.js"; +import { applySharedConfigPatch, parseSharedConfig } from "../shared/shared-config-gateway.js"; import { HermesagentSkill } from "../skills/hermesagent-skill.js"; import { RulesyncSkill } from "../skills/rulesync-skill.js"; import { RulesyncCommand } from "./rulesync-command.js"; @@ -131,7 +128,13 @@ def register(ctx): `; } -function getEnabledPluginConfigContent(currentContent: string): string { +function getEnabledPluginConfigContent({ + currentContent, + global, +}: { + currentContent: string; + global: boolean; +}): string { const config = parseSharedConfig({ format: "yaml", fileContent: currentContent }); const plugins = config.plugins && typeof config.plugins === "object" @@ -140,7 +143,7 @@ function getEnabledPluginConfigContent(currentContent: string): string { const enabled = Array.isArray(plugins.enabled) ? plugins.enabled : []; return applySharedConfigPatch({ - fileKey: HERMES_CONFIG_SHARED_FILE_KEY, + fileKey: getHermesagentConfigSharedFileKey({ global }), feature: "commands", existingContent: currentContent, patch: { @@ -152,7 +155,13 @@ function getEnabledPluginConfigContent(currentContent: string): string { }); } -export function getDisabledHermesCommandsPluginConfigContent(currentContent: string): string { +export function getDisabledHermesCommandsPluginConfigContent({ + currentContent, + global, +}: { + currentContent: string; + global: boolean; +}): string { const config = parseSharedConfig({ format: "yaml", fileContent: currentContent }); const plugins = config.plugins && typeof config.plugins === "object" @@ -161,7 +170,7 @@ export function getDisabledHermesCommandsPluginConfigContent(currentContent: str const enabled = Array.isArray(plugins.enabled) ? plugins.enabled : []; return applySharedConfigPatch({ - fileKey: HERMES_CONFIG_SHARED_FILE_KEY, + fileKey: getHermesagentConfigSharedFileKey({ global }), feature: "commands", existingContent: currentContent, patch: { @@ -200,7 +209,9 @@ class HermesagentCommandAuxiliaryFile extends ToolFile { }), ) ) { - super.setFileContent(getEnabledPluginConfigContent(newFileContent)); + super.setFileContent( + getEnabledPluginConfigContent({ currentContent: newFileContent, global: this.global }), + ); return; } super.setFileContent(newFileContent); @@ -238,7 +249,10 @@ class HermesagentCommandAuxiliaryFile extends ToolFile { }), ) ) { - return getEnabledPluginConfigContent(super.getFileContent()); + return getEnabledPluginConfigContent({ + currentContent: super.getFileContent(), + global: this.global, + }); } return super.getFileContent(); } diff --git a/src/features/hooks/hermesagent-hooks.ts b/src/features/hooks/hermesagent-hooks.ts index 0e3b75ee5..4c98a0ad7 100644 --- a/src/features/hooks/hermesagent-hooks.ts +++ b/src/features/hooks/hermesagent-hooks.ts @@ -16,6 +16,7 @@ import { } from "../../types/hooks.js"; import { readFileContent } from "../../utils/file.js"; import { + getHermesagentConfigSharedFileKey, getHermesagentRelativeDirPath, getHermesagentRulesyncOutputRoot, getHermesagentSharedConfigWritePaths, @@ -24,7 +25,6 @@ import type { Logger } from "../../utils/logger.js"; import { PROTOTYPE_POLLUTION_KEYS } from "../../utils/prototype-pollution.js"; import { applySharedConfigPatch, - HERMES_CONFIG_SHARED_FILE_KEY, parseSharedConfig, stringifySharedConfig, } from "../shared/shared-config-gateway.js"; @@ -324,7 +324,7 @@ export class HermesagentHooks extends ToolHooks { setFileContent(fileContent: string): void { this.fileContent = applySharedConfigPatch({ - fileKey: HERMES_CONFIG_SHARED_FILE_KEY, + fileKey: getHermesagentConfigSharedFileKey({ global: this.global }), feature: "hooks", existingContent: fileContent, patch: parseSharedConfig({ format: "yaml", fileContent: this.fileContent }), diff --git a/src/features/hooks/kimi-code-hooks.ts b/src/features/hooks/kimi-code-hooks.ts index 12d632d8c..d750976af 100644 --- a/src/features/hooks/kimi-code-hooks.ts +++ b/src/features/hooks/kimi-code-hooks.ts @@ -5,6 +5,7 @@ import { RULESYNC_HOOKS_FILE_NAME, RULESYNC_RELATIVE_DIR_PATH, } from "../../constants/rulesync-paths.js"; +import type { SharedWritePath } from "../../lib/shared-file-derive.js"; import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; import { CANONICAL_TO_KIMI_CODE_EVENT_NAMES, @@ -16,13 +17,14 @@ import { } from "../../types/hooks.js"; import { readFileContent } from "../../utils/file.js"; import { + getKimiCodeConfigSharedFileKey, getKimiCodeRelativeDirPath, + getKimiCodeSharedConfigWritePaths, getKimiCodeRulesyncOutputRoot, } from "../../utils/kimi-code.js"; import type { Logger } from "../../utils/logger.js"; import { applySharedConfigPatch, - KIMI_CODE_CONFIG_SHARED_FILE_KEY, parseSharedConfig, stringifySharedConfig, } from "../shared/shared-config-gateway.js"; @@ -190,6 +192,14 @@ export class KimiCodeHooks extends ToolHooks { return false; } + /** + * `config.toml` under both spellings its directory can take. + * @see getKimiCodeSharedConfigWritePaths + */ + static getExtraSharedWritePaths(): SharedWritePath[] { + return getKimiCodeSharedConfigWritePaths(); + } + shouldMergeExistingFileContent(): boolean { return true; } @@ -197,7 +207,7 @@ export class KimiCodeHooks extends ToolHooks { setFileContent(fileContent: string): void { const paths = KimiCodeHooks.getSettablePaths({ global: this.global }); this.fileContent = applySharedConfigPatch({ - fileKey: KIMI_CODE_CONFIG_SHARED_FILE_KEY, + fileKey: getKimiCodeConfigSharedFileKey(), feature: "hooks", existingContent: fileContent, patch: parseSharedConfig({ format: "toml", fileContent: this.fileContent }), diff --git a/src/features/mcp/hermesagent-mcp.ts b/src/features/mcp/hermesagent-mcp.ts index db170e885..c69ee367a 100644 --- a/src/features/mcp/hermesagent-mcp.ts +++ b/src/features/mcp/hermesagent-mcp.ts @@ -9,6 +9,7 @@ import { ValidationResult } from "../../types/ai-file.js"; import { McpServers } from "../../types/mcp.js"; import { readFileContentOrNull } from "../../utils/file.js"; import { + getHermesagentConfigSharedFileKey, getHermesagentRelativeDirPath, getHermesagentRulesyncOutputRoot, getHermesagentSharedConfigWritePaths, @@ -18,11 +19,7 @@ import { PROTOTYPE_POLLUTION_KEYS, } from "../../utils/prototype-pollution.js"; import { isPlainObject, isRecord, isStringArray } from "../../utils/type-guards.js"; -import { - applySharedConfigPatch, - HERMES_CONFIG_SHARED_FILE_KEY, - parseSharedConfig, -} from "../shared/shared-config-gateway.js"; +import { applySharedConfigPatch, parseSharedConfig } from "../shared/shared-config-gateway.js"; import { RulesyncMcp } from "./rulesync-mcp.js"; import { ToolMcp, @@ -337,7 +334,7 @@ export class HermesagentMcp extends ToolMcp { this.config = merged; super.setFileContent( applySharedConfigPatch({ - fileKey: HERMES_CONFIG_SHARED_FILE_KEY, + fileKey: getHermesagentConfigSharedFileKey({ global: this.global }), feature: "mcp", existingContent: fileContent, patch: { mcp_servers: merged.mcp_servers }, @@ -420,7 +417,7 @@ export class HermesagentMcp extends ToolMcp { relativeDirPath: paths.relativeDirPath, relativeFilePath: paths.relativeFilePath, fileContent: applySharedConfigPatch({ - fileKey: HERMES_CONFIG_SHARED_FILE_KEY, + fileKey: getHermesagentConfigSharedFileKey({ global }), feature: "mcp", existingContent: fileContent, patch: { mcp_servers: merged.mcp_servers }, diff --git a/src/features/mcp/kimi-code-mcp.ts b/src/features/mcp/kimi-code-mcp.ts index e67eed4ad..13b44f5eb 100644 --- a/src/features/mcp/kimi-code-mcp.ts +++ b/src/features/mcp/kimi-code-mcp.ts @@ -15,16 +15,14 @@ import { ToolFile } from "../../types/tool-file.js"; import { formatError } from "../../utils/error.js"; import { readFileContentOrNull } from "../../utils/file.js"; import { + getKimiCodeConfigSharedFileKey, getKimiCodeRelativeDirPath, + getKimiCodeSharedConfigWritePaths, getKimiCodeRulesyncOutputRoot, } from "../../utils/kimi-code.js"; import { type Logger, warnWithFallback } from "../../utils/logger.js"; import { isRecord } from "../../utils/type-guards.js"; -import { - applySharedConfigPatch, - KIMI_CODE_CONFIG_SHARED_FILE_KEY, - parseSharedConfig, -} from "../shared/shared-config-gateway.js"; +import { applySharedConfigPatch, parseSharedConfig } from "../shared/shared-config-gateway.js"; import { RulesyncMcp } from "./rulesync-mcp.js"; import { ToolMcp, @@ -275,7 +273,7 @@ export class KimiCodeMcpConfigToml extends ToolFile { const existingContent = existing.content; const existingSection = existing.mcp; const fileContent = applySharedConfigPatch({ - fileKey: KIMI_CODE_CONFIG_SHARED_FILE_KEY, + fileKey: getKimiCodeConfigSharedFileKey(), feature: "mcp", existingContent, patch: { @@ -403,17 +401,8 @@ export class KimiCodeMcp extends ToolMcp { * derivation sees this feature as one of that file's writers — it is not a * settable path, since the servers themselves live in `mcp.json`. */ - static getExtraSharedWritePaths({ - global = false, - }: { global?: boolean } = {}): SharedWritePath[] { - return global - ? [ - { - relativeDirPath: getKimiCodeRelativeDirPath({ global: true }), - relativeFilePath: KIMI_CODE_CONFIG_FILE_NAME, - }, - ] - : []; + static getExtraSharedWritePaths(): SharedWritePath[] { + return getKimiCodeSharedConfigWritePaths(); } /** diff --git a/src/features/permissions/hermesagent-permissions.ts b/src/features/permissions/hermesagent-permissions.ts index b662621e5..e918e5269 100644 --- a/src/features/permissions/hermesagent-permissions.ts +++ b/src/features/permissions/hermesagent-permissions.ts @@ -17,6 +17,7 @@ import { } from "../../types/permissions.js"; import { readFileContent } from "../../utils/file.js"; import { + getHermesagentConfigSharedFileKey, getHermesagentRelativeDirPath, getHermesagentRulesyncOutputRoot, getHermesagentSharedConfigWritePaths, @@ -24,7 +25,6 @@ import { import { isRecord, isStringArray } from "../../utils/type-guards.js"; import { applySharedConfigPatch, - HERMES_CONFIG_SHARED_FILE_KEY, mergeSharedConfigDeep, parseSharedConfig, stringifySharedConfig, @@ -242,7 +242,7 @@ export class HermesagentPermissions extends ToolPermissions { setFileContent(fileContent: string): void { this.fileContent = applySharedConfigPatch({ - fileKey: HERMES_CONFIG_SHARED_FILE_KEY, + fileKey: getHermesagentConfigSharedFileKey({ global: this.global }), feature: "permissions", existingContent: fileContent, patch: parseSharedConfig({ format: "yaml", fileContent: this.fileContent }), diff --git a/src/features/permissions/kimi-code-permissions.ts b/src/features/permissions/kimi-code-permissions.ts index 4042b9e61..7007958fb 100644 --- a/src/features/permissions/kimi-code-permissions.ts +++ b/src/features/permissions/kimi-code-permissions.ts @@ -5,12 +5,15 @@ import { RULESYNC_PERMISSIONS_FILE_NAME, RULESYNC_RELATIVE_DIR_PATH, } from "../../constants/rulesync-paths.js"; +import type { SharedWritePath } from "../../lib/shared-file-derive.js"; import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; import type { PermissionAction, PermissionsConfig } from "../../types/permissions.js"; import { formatError } from "../../utils/error.js"; import { readFileContent } from "../../utils/file.js"; import { + getKimiCodeConfigSharedFileKey, getKimiCodeRelativeDirPath, + getKimiCodeSharedConfigWritePaths, getKimiCodeRulesyncOutputRoot, } from "../../utils/kimi-code.js"; import { type Logger, warnWithFallback } from "../../utils/logger.js"; @@ -329,6 +332,14 @@ export class KimiCodePermissions extends ToolPermissions { return false; } + /** + * `config.toml` under both spellings its directory can take. + * @see getKimiCodeSharedConfigWritePaths + */ + static getExtraSharedWritePaths(): SharedWritePath[] { + return getKimiCodeSharedConfigWritePaths(); + } + shouldMergeExistingFileContent(): boolean { return true; } @@ -341,7 +352,7 @@ export class KimiCodePermissions extends ToolPermissions { // not delete a hand-written `disabled` list. const mergedTools = mergeKimiCodeToolsSection({ existingContent: fileContent, patch }); this.fileContent = applySharedConfigPatch({ - fileKey: KIMI_CODE_CONFIG_SHARED_FILE_KEY, + fileKey: getKimiCodeConfigSharedFileKey(), feature: "permissions", existingContent: fileContent, patch: { ...patch, ...(mergedTools && { tools: mergedTools }) }, diff --git a/src/features/shared/shared-config-gateway.ts b/src/features/shared/shared-config-gateway.ts index 10dc7d9d4..ef00c9f0d 100644 --- a/src/features/shared/shared-config-gateway.ts +++ b/src/features/shared/shared-config-gateway.ts @@ -269,6 +269,7 @@ export const CODEXCLI_CONFIG_SHARED_FILE_KEY = ".codex/config.toml"; export const GROKCLI_CONFIG_SHARED_FILE_KEY = ".grok/config.toml"; export const VIBE_CONFIG_SHARED_FILE_KEY = ".vibe/config.toml"; export const KIMI_CODE_CONFIG_SHARED_FILE_KEY = ".kimi-code/config.toml"; +export const KIMI_CODE_HOME_CONFIG_SHARED_FILE_KEY = "config.toml"; export const REASONIX_PROJECT_CONFIG_SHARED_FILE_KEY = "reasonix.toml"; export const REASONIX_GLOBAL_CONFIG_SHARED_FILE_KEY = ".reasonix/config.toml"; @@ -300,6 +301,52 @@ export const sharedConfigFileKey = ({ * lock-step with the writers derived from the processor registry, so an * undeclared writer fails CI instead of merging by accident. */ +/** + * Hermes writes one `config.yaml`, but its global profile root has three + * spellings (`~/.hermes`, the win32 `%LOCALAPPDATA%\hermes`, and `HERMES_HOME` + * itself). They are the same file with the same owners, so the declaration is + * written once and shared — a policy edit cannot land on one spelling only. + */ +const HERMES_CONFIG_DECLARATION: SharedConfigFileDeclaration = { + format: "yaml", + features: { + // The plugins block is recomputed from the existing file (enabled list + // appended) before being applied, so the whole key is owned here. + commands: { kind: "replace-owned-keys", ownedKeys: ["plugins"] }, + subagents: { kind: "replace-owned-keys", ownedKeys: ["plugins"] }, + mcp: { kind: "replace-owned-keys", ownedKeys: ["mcp_servers"] }, + hooks: { kind: "replace-owned-keys", ownedKeys: ["hooks"] }, + // Deep-merged so `approvals.mode`-style user keys coexist with generated + // `approvals.deny`; the `permissions` round-trip blob is an authoritative + // snapshot and must not resurrect deleted rules. + permissions: { kind: "deep-merge", replaceKeys: ["permissions"] }, + }, +}; + +/** + * Kimi Code's user config: hooks owns the flat `hooks` array; permissions owns + * the ordered rule list and optional coarse default mode. `KIMI_CODE_HOME` can + * name the profile directory itself, so the file has two spellings that share + * one declaration — a policy edit cannot land on only one of them. + */ +const KIMI_CODE_CONFIG_DECLARATION: SharedConfigFileDeclaration = { + format: "toml", + invalidRootPolicy: "error", + features: { + hooks: { kind: "replace-owned-keys", ownedKeys: ["hooks"] }, + // `mcp` holds the global default MCP timeouts; the servers themselves + // live in `mcp.json`, so this feature reaches the file as an auxiliary + // writer (same shape as vibe hooks above). + mcp: { kind: "replace-owned-keys", ownedKeys: ["mcp"] }, + permissions: { + kind: "replace-owned-keys", + // `tools` is Kimi's global tool allow/deny switch, a second enforcement + // layer alongside `permission.rules`. + ownedKeys: ["permission", "default_permission_mode", "tools"], + }, + }, +}; + export const SHARED_CONFIG_OWNERSHIP: Readonly> = { [CLAUDE_SETTINGS_SHARED_FILE_KEY]: { format: "json", @@ -312,56 +359,9 @@ export const SHARED_CONFIG_OWNERSHIP: Readonly { "mcp", "permissions", ], + "config.toml": [ + "hooks", + "mcp", + "permissions", + ], "config.yaml": [ "commands", "hooks", diff --git a/src/utils/hermesagent.ts b/src/utils/hermesagent.ts index 165aa2563..bea64b4dd 100644 --- a/src/utils/hermesagent.ts +++ b/src/utils/hermesagent.ts @@ -5,6 +5,7 @@ import { HERMESAGENT_GLOBAL_DIR, HERMESAGENT_GLOBAL_WIN32_DIR, } from "../constants/hermesagent-paths.js"; +import { sharedConfigFileKey } from "../features/shared/shared-config-gateway.js"; import type { SharedWritePath } from "../lib/shared-file-derive.js"; import { checkPathTraversal, getHomeDirectory } from "./file.js"; @@ -112,6 +113,22 @@ export function getHermesagentSharedConfigWritePaths(): SharedWritePath[] { ]; } +/** + * The `SHARED_CONFIG_OWNERSHIP` key of the `config.yaml` this scope actually + * writes. All three spellings carry the same declaration, but passing the key of + * the file being written keeps the write path and the drift guards reading the + * same entry. + */ +export function getHermesagentConfigSharedFileKey({ global }: { global: boolean }): string { + return sharedConfigFileKey({ + relativeDirPath: getHermesagentRelativeDirPath({ + global, + relativeDirPath: HERMESAGENT_GLOBAL_DIR, + }), + relativeFilePath: HERMESAGENT_CONFIG_FILE_NAME, + }); +} + export function getHermesagentRulesyncOutputRoot({ nativeOutputRoot, global, diff --git a/src/utils/kimi-code.ts b/src/utils/kimi-code.ts index 4ac0c3a9f..26a008a00 100644 --- a/src/utils/kimi-code.ts +++ b/src/utils/kimi-code.ts @@ -1,6 +1,8 @@ import { join, resolve } from "node:path"; -import { KIMI_CODE_DIR } from "../constants/kimi-code-paths.js"; +import { KIMI_CODE_CONFIG_FILE_NAME, KIMI_CODE_DIR } from "../constants/kimi-code-paths.js"; +import { sharedConfigFileKey } from "../features/shared/shared-config-gateway.js"; +import type { SharedWritePath } from "../lib/shared-file-derive.js"; import { getHomeDirectory } from "./file.js"; export function getKimiCodeHome(): string | undefined { @@ -18,6 +20,27 @@ export function getKimiCodeRelativeDirPath({ return global && getKimiCodeHome() ? relativeDirPath : join(KIMI_CODE_DIR, relativeDirPath); } +/** + * Both spellings the shared user `config.toml` can take: under `.kimi-code/`, + * or at the root of `KIMI_CODE_HOME` when that override names the profile dir. + * Declared unconditionally so the derived shared-file keys — and the drift + * guards checked against them — do not depend on the ambient environment. + */ +export function getKimiCodeSharedConfigWritePaths(): SharedWritePath[] { + return [ + { relativeDirPath: KIMI_CODE_DIR, relativeFilePath: KIMI_CODE_CONFIG_FILE_NAME }, + { relativeDirPath: ".", relativeFilePath: KIMI_CODE_CONFIG_FILE_NAME }, + ]; +} + +/** The `SHARED_CONFIG_OWNERSHIP` key of the `config.toml` actually being written. */ +export function getKimiCodeConfigSharedFileKey(): string { + return sharedConfigFileKey({ + relativeDirPath: getKimiCodeRelativeDirPath({ global: true }), + relativeFilePath: KIMI_CODE_CONFIG_FILE_NAME, + }); +} + export function getKimiCodeRulesyncOutputRoot({ nativeOutputRoot, global, diff --git a/src/utils/tool-output-root.test.ts b/src/utils/tool-output-root.test.ts new file mode 100644 index 000000000..493035a91 --- /dev/null +++ b/src/utils/tool-output-root.test.ts @@ -0,0 +1,66 @@ +import { resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { resolveToolOutputRoot } from "./tool-output-root.js"; + +describe("resolveToolOutputRoot", () => { + const originalHermesHome = process.env.HERMES_HOME; + const originalKimiHome = process.env.KIMI_CODE_HOME; + + afterEach(() => { + if (originalHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = originalHermesHome; + if (originalKimiHome === undefined) delete process.env.KIMI_CODE_HOME; + else process.env.KIMI_CODE_HOME = originalKimiHome; + }); + + it("keeps the caller's output root in project scope and for tools without an override", () => { + process.env.HERMES_HOME = "/hermes-profile"; + + expect( + resolveToolOutputRoot({ outputRoot: "/project", toolTarget: "hermesagent", global: false }), + ).toBe("/project"); + expect( + resolveToolOutputRoot({ outputRoot: "/home", toolTarget: "claudecode", global: true }), + ).toBe("/home"); + }); + + it("substitutes the tool home override in global scope", () => { + process.env.HERMES_HOME = "/hermes-profile"; + process.env.KIMI_CODE_HOME = "/kimi-profile"; + + expect( + resolveToolOutputRoot({ outputRoot: "/home", toolTarget: "hermesagent", global: true }), + ).toBe(resolve("/hermes-profile")); + expect( + resolveToolOutputRoot({ outputRoot: "/home", toolTarget: "kimi-code", global: true }), + ).toBe(resolve("/kimi-profile")); + }); + + it("falls back to the caller's output root when the override is unset", () => { + delete process.env.HERMES_HOME; + delete process.env.KIMI_CODE_HOME; + + expect( + resolveToolOutputRoot({ outputRoot: "/home", toolTarget: "hermesagent", global: true }), + ).toBe("/home"); + expect( + resolveToolOutputRoot({ outputRoot: "/home", toolTarget: "kimi-code", global: true }), + ).toBe("/home"); + }); + + it("rejects a home override that is the filesystem root, naming the variable", () => { + // The env-derived root used to bypass validateOutputRoot entirely, so `/` + // became the output root verbatim. + process.env.HERMES_HOME = "/"; + expect(() => + resolveToolOutputRoot({ outputRoot: "/home", toolTarget: "hermesagent", global: true }), + ).toThrow("HERMES_HOME is not a usable output root"); + + process.env.KIMI_CODE_HOME = "/"; + expect(() => + resolveToolOutputRoot({ outputRoot: "/home", toolTarget: "kimi-code", global: true }), + ).toThrow("KIMI_CODE_HOME is not a usable output root"); + }); +}); diff --git a/src/utils/tool-output-root.ts b/src/utils/tool-output-root.ts index 7e5d04cc1..a44c656f7 100644 --- a/src/utils/tool-output-root.ts +++ b/src/utils/tool-output-root.ts @@ -1,8 +1,15 @@ import type { ToolTarget } from "../types/tool-targets.js"; +import { formatError } from "./error.js"; import { validateOutputRoot } from "./file.js"; import { resolveHermesagentOutputRoot } from "./hermesagent.js"; import { getKimiCodeHome } from "./kimi-code.js"; +/** The environment variable each tool reads for its profile root. */ +const TOOL_HOME_ENV_VARS: Partial> = { + hermesagent: "HERMES_HOME", + "kimi-code": "KIMI_CODE_HOME", +}; + /** * Substitute a tool's home override (`HERMES_HOME`, `KIMI_CODE_HOME`) for the * output root in global scope. @@ -10,9 +17,10 @@ import { getKimiCodeHome } from "./kimi-code.js"; * The override wins over `--output-roots`: it names where the tool itself reads * its profile, so writing anywhere else would produce files the tool ignores. * - * The substituted value goes through the same `validateOutputRoot` the CLI and + * A substituted value goes through the same `validateOutputRoot` the CLI and * config paths use, so an override of `/` or an unnormalized path is rejected - * instead of silently becoming the output root. + * instead of silently becoming the output root. The rejection is re-thrown + * naming the variable, since the user never passed an `--output-roots` flag. */ export function resolveToolOutputRoot({ outputRoot, @@ -30,8 +38,15 @@ export function resolveToolOutputRoot({ : toolTarget === "kimi-code" ? (getKimiCodeHome() ?? outputRoot) : outputRoot; - if (resolved !== outputRoot) { + if (resolved === outputRoot) return resolved; + + try { validateOutputRoot(resolved); + } catch (error) { + throw new Error( + `${TOOL_HOME_ENV_VARS[toolTarget] ?? "The tool home override"} is not a usable output root: ${formatError(error)}`, + { cause: error }, + ); } return resolved; } diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 4bb96d98d..36a7c8d50 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -5,6 +5,14 @@ export default defineConfig({ globals: true, environment: "node", include: ["src/e2e/**/*.spec.ts"], + // E2E helpers spread `process.env` into the CLI child, so an exported + // HERMES_HOME/KIMI_CODE_HOME would redirect global output away from the + // pseudo-home the specs assert against. Specs that exercise the overrides + // pass them explicitly per invocation. + env: { + HERMES_HOME: "", + KIMI_CODE_HOME: "", + }, testTimeout: 60000, // E2E tests may take longer hookTimeout: 60000, watch: false, From 7d300368b951781a81d1bfc63f0a6dcc093a6e5b Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Wed, 29 Jul 2026 11:34:31 -0700 Subject: [PATCH 3/3] test(utils): pin the derived shared-config keys and align the kimi helper signature Review round two (all low): - getKimiCodeConfigSharedFileKey takes { global } like its hermes counterpart instead of hardcoding true, so the write-time key cannot drift from the settable path the day kimi-code gains a project scope. - Direct unit coverage for both key helpers: they exist precisely because the answer depends on the home override and the platform, and the previous protection was indirect (the gateway throwing on an undeclared key in e2e). Co-Authored-By: Claude Fable 5 --- src/features/hooks/kimi-code-hooks.ts | 2 +- src/features/mcp/kimi-code-mcp.ts | 2 +- .../permissions/kimi-code-permissions.ts | 2 +- src/utils/hermesagent.test.ts | 24 +++++++++ src/utils/kimi-code.test.ts | 49 +++++++++++++++++++ src/utils/kimi-code.ts | 10 ++-- 6 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 src/utils/kimi-code.test.ts diff --git a/src/features/hooks/kimi-code-hooks.ts b/src/features/hooks/kimi-code-hooks.ts index d750976af..13ac20131 100644 --- a/src/features/hooks/kimi-code-hooks.ts +++ b/src/features/hooks/kimi-code-hooks.ts @@ -207,7 +207,7 @@ export class KimiCodeHooks extends ToolHooks { setFileContent(fileContent: string): void { const paths = KimiCodeHooks.getSettablePaths({ global: this.global }); this.fileContent = applySharedConfigPatch({ - fileKey: getKimiCodeConfigSharedFileKey(), + fileKey: getKimiCodeConfigSharedFileKey({ global: this.global }), feature: "hooks", existingContent: fileContent, patch: parseSharedConfig({ format: "toml", fileContent: this.fileContent }), diff --git a/src/features/mcp/kimi-code-mcp.ts b/src/features/mcp/kimi-code-mcp.ts index 13b44f5eb..3633ab20b 100644 --- a/src/features/mcp/kimi-code-mcp.ts +++ b/src/features/mcp/kimi-code-mcp.ts @@ -273,7 +273,7 @@ export class KimiCodeMcpConfigToml extends ToolFile { const existingContent = existing.content; const existingSection = existing.mcp; const fileContent = applySharedConfigPatch({ - fileKey: getKimiCodeConfigSharedFileKey(), + fileKey: getKimiCodeConfigSharedFileKey({ global: true }), feature: "mcp", existingContent, patch: { diff --git a/src/features/permissions/kimi-code-permissions.ts b/src/features/permissions/kimi-code-permissions.ts index 7007958fb..3ac4397ee 100644 --- a/src/features/permissions/kimi-code-permissions.ts +++ b/src/features/permissions/kimi-code-permissions.ts @@ -352,7 +352,7 @@ export class KimiCodePermissions extends ToolPermissions { // not delete a hand-written `disabled` list. const mergedTools = mergeKimiCodeToolsSection({ existingContent: fileContent, patch }); this.fileContent = applySharedConfigPatch({ - fileKey: getKimiCodeConfigSharedFileKey(), + fileKey: getKimiCodeConfigSharedFileKey({ global: this.global }), feature: "permissions", existingContent: fileContent, patch: { ...patch, ...(mergedTools && { tools: mergedTools }) }, diff --git a/src/utils/hermesagent.test.ts b/src/utils/hermesagent.test.ts index 8cddacb01..59957da5a 100644 --- a/src/utils/hermesagent.test.ts +++ b/src/utils/hermesagent.test.ts @@ -2,11 +2,13 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { toPosixPath } from "./file.js"; import { getHermesagentGlobalDir, getHermesagentHome, getHermesagentRelativeDirPath, getHermesagentRelativeFilePath, + getHermesagentConfigSharedFileKey, getHermesagentRulesyncOutputRoot, getHermesagentSharedConfigWritePaths, resolveHermesagentOutputRoot, @@ -103,6 +105,28 @@ describe("Hermes Agent profile paths", () => { ).toBe(true); }); + it("keys the shared config by the file the current scope actually writes", () => { + // The key must name the file being written, not a fixed spelling: with + // HERMES_HOME set the config sits at the profile root, and on win32 it sits + // under the platform default directory. + delete process.env.HERMES_HOME; + expect(getHermesagentConfigSharedFileKey({ global: true })).toBe( + `${toPosixPath(getHermesagentGlobalDir())}/config.yaml`, + ); + expect(getHermesagentConfigSharedFileKey({ global: false })).toBe(".hermes/config.yaml"); + + process.env.HERMES_HOME = "/custom-hermes"; + expect(getHermesagentConfigSharedFileKey({ global: true })).toBe("config.yaml"); + + // Whatever it resolves to must be one of the declared write paths, or the + // gateway would reject the write for an undeclared key. + const declaredKeys = getHermesagentSharedConfigWritePaths().map( + (path) => + `${path.relativeDirPath === "." ? "" : `${toPosixPath(path.relativeDirPath)}/`}config.yaml`, + ); + expect(declaredKeys).toContain(getHermesagentConfigSharedFileKey({ global: true })); + }); + it("keeps project paths and the global RuleSync source root separate", () => { process.env.HERMES_HOME = "/custom-hermes"; process.env.HOME_DIR = "/rulesync-home"; diff --git a/src/utils/kimi-code.test.ts b/src/utils/kimi-code.test.ts new file mode 100644 index 000000000..cac58279e --- /dev/null +++ b/src/utils/kimi-code.test.ts @@ -0,0 +1,49 @@ +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + getKimiCodeConfigSharedFileKey, + getKimiCodeHome, + getKimiCodeRelativeDirPath, + getKimiCodeSharedConfigWritePaths, +} from "./kimi-code.js"; + +describe("Kimi Code profile paths", () => { + const originalKimiHome = process.env.KIMI_CODE_HOME; + + afterEach(() => { + if (originalKimiHome === undefined) delete process.env.KIMI_CODE_HOME; + else process.env.KIMI_CODE_HOME = originalKimiHome; + }); + + it("treats KIMI_CODE_HOME as the profile root itself", () => { + delete process.env.KIMI_CODE_HOME; + expect(getKimiCodeHome()).toBeUndefined(); + expect(getKimiCodeRelativeDirPath({ global: true })).toBe(join(".kimi-code", ".")); + + process.env.KIMI_CODE_HOME = " /custom-kimi "; + expect(getKimiCodeHome()).toBe("/custom-kimi"); + expect(getKimiCodeRelativeDirPath({ global: true })).toBe("."); + }); + + it("declares both config.toml spellings regardless of KIMI_CODE_HOME", () => { + // The shared-write derivation runs at module load, so the declared set must + // not depend on the ambient environment or the drift guards go blind. + const declared = getKimiCodeSharedConfigWritePaths().map((path) => path.relativeDirPath); + process.env.KIMI_CODE_HOME = "/custom-kimi"; + + expect(declared).toEqual([".kimi-code", "."]); + expect(getKimiCodeSharedConfigWritePaths().map((path) => path.relativeDirPath)).toEqual( + declared, + ); + }); + + it("keys the shared config by the file the current scope actually writes", () => { + delete process.env.KIMI_CODE_HOME; + expect(getKimiCodeConfigSharedFileKey({ global: true })).toBe(".kimi-code/config.toml"); + + process.env.KIMI_CODE_HOME = "/custom-kimi"; + expect(getKimiCodeConfigSharedFileKey({ global: true })).toBe("config.toml"); + }); +}); diff --git a/src/utils/kimi-code.ts b/src/utils/kimi-code.ts index 26a008a00..eb32b3030 100644 --- a/src/utils/kimi-code.ts +++ b/src/utils/kimi-code.ts @@ -33,10 +33,14 @@ export function getKimiCodeSharedConfigWritePaths(): SharedWritePath[] { ]; } -/** The `SHARED_CONFIG_OWNERSHIP` key of the `config.toml` actually being written. */ -export function getKimiCodeConfigSharedFileKey(): string { +/** + * The `SHARED_CONFIG_OWNERSHIP` key of the `config.toml` actually being written. + * Both spellings carry the same declaration, but passing the key of the file + * being written keeps the write path and the drift guards on the same entry. + */ +export function getKimiCodeConfigSharedFileKey({ global }: { global: boolean }): string { return sharedConfigFileKey({ - relativeDirPath: getKimiCodeRelativeDirPath({ global: true }), + relativeDirPath: getKimiCodeRelativeDirPath({ global }), relativeFilePath: KIMI_CODE_CONFIG_FILE_NAME, }); }