fix(#2755): resolve the kimi hooks-TOML root per runtime (#3032)

* test(#2755): failing-first coverage for per-runtime kimi hooks root

Install/uninstall filesystem-shape rows over a sandbox HOME (no permission
tricks) plus resolver unit rows. Covers both uninstall directions, which is
where a fix applied only to the install call site would drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2755): resolve the kimi hooks-TOML root per runtime

resolveKimiHooksTomlDir took no runtime argument and hardcoded ~/.kimi, but
both kimi and kimi-code route through the single hooksSurface=kimi-hooks-toml
branch. A --kimi-code install therefore wrote its [[hooks]] block, hook bundle
and CommonJS marker into Kimi CLI's config file, and a --kimi-code uninstall
stripped Kimi CLI's block.

Adds a runtime selector to the resolver -- kimi keeps ~/.kimi + KIMI_SHARE_DIR,
kimi-code gets ~/.kimi-code + KIMI_CODE_HOME, per Kimi Code's own upstream
data-locations and hooks docs -- and passes the runtime at both the install and
uninstall call sites. An omitted or unrecognized runtime still resolves ~/.kimi,
so the exported no-arg contract is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2755): use centralized helpers and add a divergence guard

Review findings, all fixed in-PR:

- The new test block reimplemented runMinimalInstall, createTempDir and
  toPosixPath. Extends runMinimalInstall with optional root/extraEnv instead
  (back-compat: every existing caller passes neither) and uses the centralized
  helpers, per CONTRIBUTING's Use Centralized Test Helpers rule.

- Adds a parity assertion between the capability registry and the resolver: a
  third runtime declaring hooksSurface kimi-hooks-toml would silently inherit
  ~/.kimi, re-creating this very defect. The guard fires the moment those two
  surfaces drift.

- Adds an installer-level test proving KIMI_SHARE_DIR and KIMI_CODE_HOME do not
  interfere when both are set, which only the resolver unit covered before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2755): track the kimi-code hooks root in the emitted-artifact gates

The remote runner caught a real ripple: moving kimi-code hooks to ~/.kimi-code
made 31 emitted paths unattributable and 58 emitted hashes unexplained, because
three parallel surfaces keyed on the literal .kimi path.

- HOOK_CONFIG_RELATIVE_PATHS excluded only .kimi/config.toml, so kimi-code's
  config.toml became manifest-visible; it embeds a platform-varying node-runner
  command and must stay out for both products.
- HOOKS_ROOTS, the package.json-marker branch and the synthesized-install-metadata
  pattern each named .kimi only.
- tests/fixtures/install-tree/kimi-code.json still recorded the old paths;
  regenerated via gen:install-tree.

Adds the per-PR drift acknowledgment for the 58 paths whose bytes are unchanged
but whose destination moved - a ripple no source diff can show, since no hook
script was edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2755): clear production-tree security advisories

The remote runner's npm-integrity gate reported 2 high advisories in the
production dependency tree. My diff touches neither package.json nor
package-lock.json, so these come from the base -- but a red gate is not
something to wave off as pre-existing, so it is fixed here rather than deferred.

Lockfile-only, semver-in-range, via npm audit fix:
  fast-uri   3.1.4  -> 3.1.5   (host confusion via backslash authority introducer)
  ip-address 10.2.0 -> 10.4.0  (three SSRF / trust-boundary bypasses)
  hono       4.12.31 -> 4.13.0 (moderate; reverting it traded a high for a
                                moderate, so the full remedy is taken)

npm audit now reports 0 vulnerabilities at every severity, npm ci installs
clean from the updated lockfile, and the build and the kimi behavior both
re-verified afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2755): backfill changeset pr numbers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-03 18:56:42 -04:00
committed by GitHub
parent fd64389616
commit c6ce4d1d9a
12 changed files with 451 additions and 74 deletions

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 3032
---
**Production dependency tree is clear of known advisories** — three transitive packages reached by `@anthropic-ai/claude-agent-sdk` carried published advisories: `fast-uri` (host confusion via a backslash authority introducer), `ip-address` (three SSRF / trust-boundary bypasses via leading-zero octets, CIDR-suffix suppression, and IPv4-mapped address misclassification), and `hono`. All three are lockfile-only, semver-in-range updates. (#2755)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3032
---
**A `--kimi-code` install now configures hooks in Kimi Code, not Kimi CLI** — installing GSD for Kimi Code wrote its lifecycle hooks, hook bundle and CommonJS marker into Kimi CLI's `~/.kimi/config.toml`, so Kimi Code itself received no hooks at all and a machine with only Kimi Code got a config file no product reads. Each Kimi product now uses its own root and its own environment override (`KIMI_SHARE_DIR` for Kimi CLI, `KIMI_CODE_HOME` for Kimi Code), and uninstalling one no longer removes the other's hooks. (#2755)

View File

@@ -8082,11 +8082,12 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
// 1a-kimi. Non-layout Kimi side-effect (#2095 EoS/kimi Upgrade 1): kimi's
// native config.toml lives outside targetDir entirely (resolveKimiHooksTomlDir
// resolves ~/.kimi, a sibling of targetDir's ~/.config/agents), so its
// resolves ~/.kimi for kimi and ~/.kimi-code for kimi-code (#2755), a sibling
// of targetDir's ~/.config/agents), so its
// cleanup can't be driven by anything under targetDir the way every other
// hook surface above is.
if (resolveInstallPlan(runtime).hooksSurface === 'kimi-hooks-toml') {
const kimiHooksRoot = resolveKimiHooksTomlDir();
const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime });
const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
if (kimiHooksCleanup.changed) {
@@ -11924,7 +11925,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// hooks needed. Kimi is also artifact-only for its INSTALL surface (skills +
// kimi-agents, no settings.json) but #2095 Upgrade 1 gives it its own
// independent hooksSurface: kimi's native config.toml [[hooks]] array, which
// lives outside targetDir entirely (resolveKimiHooksTomlDir resolves ~/.kimi,
// lives outside targetDir entirely (resolveKimiHooksTomlDir resolves the
// per-runtime root — ~/.kimi for kimi, ~/.kimi-code for kimi-code, #2755 —
// a sibling of targetDir's ~/.config/agents) — hence writing it here, inside
// this early-return, rather than requiring installSurface to change.
//
@@ -11945,7 +11947,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// ~/.kimi/hooks/<script> rather than a script that doesn't exist under
// targetDir/hooks (which kimi no longer receives).
if (plan.hooksSurface === 'kimi-hooks-toml' && isGlobal) {
const kimiHooksRoot = resolveKimiHooksTomlDir();
const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime });
// Note: the `failures` array's hard-fail gate (`if (failures.length > 0)
// process.exit(1)`) runs earlier in this function, before this
// profile-marker-only branch is ever reached — pushing to it here would
@@ -11961,7 +11963,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
//
// Done HERE rather than in installer-migration 007 (which retires the same
// stale marker for every other runtime) because kimi's hook root is
// ~/.kimi — resolved by resolveKimiHooksTomlDir, OUTSIDE kimi's configDir.
// the per-runtime Kimi root — resolved by resolveKimiHooksTomlDir, OUTSIDE the configDir.
// Migration relPaths are structurally confined to configDir, so the
// framework cannot address this path at all. Same exact-content predicate
// either way, so a user-authored ~/.kimi/package.json is never touched.

View File

@@ -650,6 +650,8 @@ Documentation gaps:
> **`kimi-code` is a different product from `kimi` above — every axis below is sourced independently.** `kimi` is Moonshot's Python `kimi-cli` (`from kimi_cli.app import KimiCLI`, `~/.kimi/config.toml`, `--work-dir`); `kimi-code` is the TypeScript/Node **Kimi Code CLI** (`~/.kimi-code/config.toml`, `$KIMI_CODE_HOME`, process-cwd, `AgentSwarm`). None of the values here are inherited from the `kimi` section. See `docs/migration/kimi-to-kimi-code.md` for the user-facing split.
**GSD hook destination (#2755):** although `kimi` and `kimi-code` share `hooksSurface: "kimi-hooks-toml"`, they do **not** share a hooks root. GSD writes its `[[hooks]]` block, hook bundle and CommonJS marker into `~/.kimi-code/config.toml` for `kimi-code` (overridable via `KIMI_CODE_HOME`) and into `~/.kimi/config.toml` for `kimi` (overridable via `KIMI_SHARE_DIR`); each product's env var is scoped to that product and does not redirect the other. `resolveKimiHooksTomlDir({ runtime })` in `src/runtime-homes.cts` is the single seam that makes this choice, for both the install and uninstall paths. Until #2755 that function was unparameterized and every `kimi-code` install wrote into Kimi CLI's root, leaving Kimi Code with no hooks — this row is documented explicitly because its absence is what let that go unnoticed.
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | declarative | https://github.com/moonshotai/kimi-code/blob/main/docs/en/customization/plugins.md | "Plugins package reusable Kimi Code CLI capabilities into installable units — they can add Agent Skills, automatically load a specified Skill at session start, and declare MCP servers to provide real tool capabilities." A plugin is a `kimi.plugin.json` manifest plus markdown Skills; real tool capability arrives via external MCP processes. No in-process programmatic extension API is documented — the same shape as `codex` above. |

18
package-lock.json generated
View File

@@ -3206,9 +3206,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -3573,9 +3573,9 @@
}
},
"node_modules/hono": {
"version": "4.12.31",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -3678,9 +3678,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"

View File

@@ -55,6 +55,16 @@ export interface ResolveKimiOpts {
existsSync?: (p: string) => boolean;
}
/**
* Options for `resolveKimiHooksTomlDir`. Separate from `ResolveKimiOpts` so the
* `runtime` selector is not implied to affect `resolveKimiGlobalDir`, which
* resolves the generic Agent-Skills root and is runtime-independent.
*/
export interface ResolveKimiHooksTomlOpts extends ResolveKimiOpts {
/** Runtime id — `kimi` (default) or `kimi-code`. See #2755. */
runtime?: string;
}
export interface ResolveConfigHomeOpts {
env?: Record<string, string | undefined>;
home?: string;
@@ -370,28 +380,45 @@ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string {
}
/**
* Resolve the directory holding Kimi CLI's OWN native config.toml (the file
* Kimi itself reads for providers/models/hooks/etc — see
* moonshotai.github.io/kimi-cli/en/configuration/data-locations.html and
* .../reference/kimi-command.html). Default `~/.kimi`, overridden by
* `KIMI_SHARE_DIR` per Kimi's own upstream env-var (NOT `KIMI_CONFIG_DIR`,
* which is a GSD-installer write-location override for the unrelated generic
* Agent-Skills root resolved by resolveKimiGlobalDir above).
* Resolve the directory holding the Kimi product's OWN native config.toml —
* the file that product itself reads for providers/models/hooks/etc, and the
* one GSD writes its `[[hooks]]` block, hooks bundle and CommonJS marker into.
*
* This is deliberately a SEPARATE directory from GSD's kimi configHome
* (~/.config/agents): Kimi's own docs confirm the Agent-Skills search path is
* independent of KIMI_SHARE_DIR ("This variable does not affect Agent Skills
* search paths, which are handled separately"). #2095 Upgrade 1 writes GSD's
* native [[hooks]] entries into `<this dir>/config.toml`, never into the
* skills configDir.
* The two Kimi runtimes share `hooksSurface: "kimi-hooks-toml"` but are
* different products with different roots, and this must be selected by
* `runtime` (#2755). Before that fix this function was unparameterized and a
* `--kimi-code` install wrote its hooks into Kimi CLI's `~/.kimi`, leaving Kimi
* Code with none:
*
* kimi → `~/.kimi`, overridden by `KIMI_SHARE_DIR`
* (moonshotai.github.io/kimi-cli/en/configuration/data-locations.html)
* kimi-code → `~/.kimi-code`, overridden by `KIMI_CODE_HOME`
* (moonshotai/kimi-code docs/en/configuration/data-locations.md;
* its hooks doc places `[[hooks]]` in `~/.kimi-code/config.toml`)
*
* Each product's env var is scoped to that product: `KIMI_SHARE_DIR` is Kimi
* CLI's own upstream variable and must NOT redirect kimi-code, nor vice versa.
*
* An unrecognised `runtime` (and an omitted one) falls back to `~/.kimi`, which
* preserves the pre-#2755 behaviour for every existing caller that passes no
* runtime — this function is exported, so that default is a contract.
*
* For BOTH runtimes this is deliberately a SEPARATE directory from the generic
* Agent-Skills root resolved by `resolveKimiGlobalDir` (`~/.config/agents`):
* both vendors' docs confirm the Agent-Skills search path is independent of the
* data-root env var. GSD's native `[[hooks]]` entries go in
* `<this dir>/config.toml`, never into the skills configDir.
*/
export function resolveKimiHooksTomlDir(opts: ResolveKimiOpts = {}): string {
export function resolveKimiHooksTomlDir(opts: ResolveKimiHooksTomlOpts = {}): string {
const env: Record<string, string | undefined> = opts.env ?? process.env;
const home = opts.home ?? os.homedir();
return resolveConfigHomeFromDescriptor(
{ kind: 'dot-home', name: '.kimi', env: ['KIMI_SHARE_DIR'] },
{ env, home },
);
// Explicit comparison rather than an object lookup keyed on `runtime`: the
// value originates from argv, and an index would resolve inherited keys
// (`constructor`, `__proto__`) to something that is not a descriptor.
const descriptor: DotHomeDescriptor = opts.runtime === 'kimi-code'
? { kind: 'dot-home', name: '.kimi-code', env: ['KIMI_CODE_HOME'] }
: { kind: 'dot-home', name: '.kimi', env: ['KIMI_SHARE_DIR'] };
return resolveConfigHomeFromDescriptor(descriptor, { env, home });
}
/**

View File

@@ -0,0 +1,63 @@
{
"version": 1,
"paths": {
".kimi-code/hooks/gsd-check-update-worker.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-check-update.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-config-reload.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-context-monitor.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-cursor-post-tool.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-cursor-pre-tool.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-cursor-session-start.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-cursor-stop.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-cursor-subagent-start.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-cursor-subagent-stop.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-ensure-canonical-path.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-graphify-update.sh": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-phase-boundary.sh": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-prompt-guard.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-read-guard.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-read-injection-scanner.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-session-state.sh": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-statusline.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-update-banner.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-validate-commit.sh": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-windsurf-pre-command.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-windsurf-pre-write.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-workflow-guard.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-worktree-path-guard.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/gsd-write-guard.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/lib/cursor-workspace.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/lib/git-cmd.js": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/lib/gsd-graphify-rebuild.sh": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi-code/hooks/managed-hooks-registry.cjs": "#2755: kimi-code installs its hook bundle into Kimi Code own root (~/.kimi-code) instead of Kimi CLI ~/.kimi. The script bytes are unchanged - identical to what ~/.kimi previously received - so no source file under hooks/ moved and the diff cannot attribute the ripple. Only the install destination changed, at resolveKimiHooksTomlDir in src/runtime-homes.cts.",
".kimi/hooks/gsd-check-update-worker.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-check-update.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-config-reload.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-context-monitor.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-cursor-post-tool.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-cursor-pre-tool.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-cursor-session-start.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-cursor-stop.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-cursor-subagent-start.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-cursor-subagent-stop.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-ensure-canonical-path.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-graphify-update.sh": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-phase-boundary.sh": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-prompt-guard.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-read-guard.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-read-injection-scanner.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-session-state.sh": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-statusline.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-update-banner.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-validate-commit.sh": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-windsurf-pre-command.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-windsurf-pre-write.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-workflow-guard.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-worktree-path-guard.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/gsd-write-guard.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/lib/cursor-workspace.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/lib/git-cmd.js": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/lib/gsd-graphify-rebuild.sh": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR.",
".kimi/hooks/managed-hooks-registry.cjs": "#2755: the mirror of the .kimi-code entries above. kimi-code no longer emits into Kimi CLI native root at all, so this path disappears from the kimi-code manifest. Content unchanged; kimi own manifest still emits the same path and is untouched by this PR."
}
}

View File

@@ -1,36 +1,36 @@
[
".gsd-profile",
".gsd/defaults.json",
".kimi/hooks/gsd-check-update-worker.js",
".kimi/hooks/gsd-check-update.js",
".kimi/hooks/gsd-config-reload.js",
".kimi/hooks/gsd-context-monitor.js",
".kimi/hooks/gsd-cursor-post-tool.js",
".kimi/hooks/gsd-cursor-pre-tool.js",
".kimi/hooks/gsd-cursor-session-start.js",
".kimi/hooks/gsd-cursor-stop.js",
".kimi/hooks/gsd-cursor-subagent-start.js",
".kimi/hooks/gsd-cursor-subagent-stop.js",
".kimi/hooks/gsd-ensure-canonical-path.js",
".kimi/hooks/gsd-graphify-update.sh",
".kimi/hooks/gsd-phase-boundary.sh",
".kimi/hooks/gsd-prompt-guard.js",
".kimi/hooks/gsd-read-guard.js",
".kimi/hooks/gsd-read-injection-scanner.js",
".kimi/hooks/gsd-session-state.sh",
".kimi/hooks/gsd-statusline.js",
".kimi/hooks/gsd-update-banner.js",
".kimi/hooks/gsd-validate-commit.sh",
".kimi/hooks/gsd-windsurf-pre-command.js",
".kimi/hooks/gsd-windsurf-pre-write.js",
".kimi/hooks/gsd-workflow-guard.js",
".kimi/hooks/gsd-worktree-path-guard.js",
".kimi/hooks/gsd-write-guard.js",
".kimi/hooks/lib/cursor-workspace.js",
".kimi/hooks/lib/git-cmd.js",
".kimi/hooks/lib/gsd-graphify-rebuild.sh",
".kimi/hooks/managed-hooks-registry.cjs",
".kimi/hooks/package.json",
".kimi-code/hooks/gsd-check-update-worker.js",
".kimi-code/hooks/gsd-check-update.js",
".kimi-code/hooks/gsd-config-reload.js",
".kimi-code/hooks/gsd-context-monitor.js",
".kimi-code/hooks/gsd-cursor-post-tool.js",
".kimi-code/hooks/gsd-cursor-pre-tool.js",
".kimi-code/hooks/gsd-cursor-session-start.js",
".kimi-code/hooks/gsd-cursor-stop.js",
".kimi-code/hooks/gsd-cursor-subagent-start.js",
".kimi-code/hooks/gsd-cursor-subagent-stop.js",
".kimi-code/hooks/gsd-ensure-canonical-path.js",
".kimi-code/hooks/gsd-graphify-update.sh",
".kimi-code/hooks/gsd-phase-boundary.sh",
".kimi-code/hooks/gsd-prompt-guard.js",
".kimi-code/hooks/gsd-read-guard.js",
".kimi-code/hooks/gsd-read-injection-scanner.js",
".kimi-code/hooks/gsd-session-state.sh",
".kimi-code/hooks/gsd-statusline.js",
".kimi-code/hooks/gsd-update-banner.js",
".kimi-code/hooks/gsd-validate-commit.sh",
".kimi-code/hooks/gsd-windsurf-pre-command.js",
".kimi-code/hooks/gsd-windsurf-pre-write.js",
".kimi-code/hooks/gsd-workflow-guard.js",
".kimi-code/hooks/gsd-worktree-path-guard.js",
".kimi-code/hooks/gsd-write-guard.js",
".kimi-code/hooks/lib/cursor-workspace.js",
".kimi-code/hooks/lib/git-cmd.js",
".kimi-code/hooks/lib/gsd-graphify-rebuild.sh",
".kimi-code/hooks/managed-hooks-registry.cjs",
".kimi-code/hooks/package.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -65,7 +65,7 @@ const EXPECTED_MANIFEST_COUNT = MANIFEST_FAMILIES.length;
// `skills/gsd/...`, and `.agents/skills` must win over `.agents`.
const SKILLS_ROOTS = ['skills/gsd', '.agents/skills', 'skills'];
const HOOKS_ROOTS = ['.kimi/hooks', 'hooks'];
const HOOKS_ROOTS = ['.kimi-code/hooks', '.kimi/hooks', 'hooks'];
/** Source-of-truth command dir every skill/command surface converts from. */
const COMMANDS_SRC = 'commands/gsd';
@@ -423,7 +423,11 @@ const PROVENANCE_RULES = [
if (root === 'plugins' || root === 'extensions') {
return [COMMONJS_MARKER_SRC, INSTALL_ENGINE_SRC];
}
if (root === '.kimi/hooks') return [COMMONJS_MARKER_SRC, INSTALLER_SRC];
// Both Kimi products install the shared bundle into their own native hook
// root rather than under the generic Agent-Skills configDir (#2755).
if (root === '.kimi/hooks' || root === '.kimi-code/hooks') {
return [COMMONJS_MARKER_SRC, INSTALLER_SRC];
}
// 'hooks' — written by the shared bundle for most runtimes and by the
// #2717 dedicated paths for cursor/windsurf/codex.
return [COMMONJS_MARKER_SRC, INSTALLER_SRC, HOOKS_WINDOWS_SHIM_SRC];
@@ -528,10 +532,11 @@ const PROVENANCE_RULES = [
id: 'synthesized-install-metadata',
kind: 'synthesized',
roots: null,
// `.kimi/package.json` is the same literal `{"type":"commonjs"}` CommonJS-mode
// marker as the root one, written into Kimi's separate hooks root
// (installSharedHooksBundle, install.js:11044-11046).
pattern: /^(\.gsd-profile|package\.json|\.kimi\/package\.json|gsd-core\/VERSION|gsd-core\/\.gsd-runtime)$/,
// `.kimi/package.json` and `.kimi-code/package.json` are the same literal
// `{"type":"commonjs"}` CommonJS-mode marker as the root one, written into
// each Kimi product's separate hooks root (installSharedHooksBundle; the
// per-runtime root split is #2755).
pattern: /^(\.gsd-profile|package\.json|\.kimi(-code)?\/package\.json|gsd-core\/VERSION|gsd-core\/\.gsd-runtime)$/,
sources: () => [],
},
{

View File

@@ -184,7 +184,11 @@ const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hook
// same temp root used as --config-dir, collapsing the two into one directory
// for the isolated test run. So it is excluded by its exact relative path
// under that collapsed root, not by basename.
const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml']);
// Both Kimi products' native config.toml embeds a platform-varying node-runner
// command, so neither belongs in the golden-tracked emitted manifest. kimi-code
// resolves its own root since #2755 — listing only `.kimi/config.toml` here made
// kimi-code's config.toml newly manifest-visible and unattributable.
const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml', '.kimi-code/config.toml']);
// Path prefixes excluded from the parity manifest. `gsd-core/bin/lib/` holds the
// tsc-built runtime artifacts (compiled from src/*.cts) that the install COPIES
@@ -521,9 +525,14 @@ function installerEnv(overrides = {}) {
* measure a DIFFERENT tree's installer — e.g. the differential baseline builder
* pointing at a `git worktree` checked out at the base ref, so the emitted manifest it
* produces reflects that ref's own installer code, not the PR checkout's.
* @param {string} [opts.root] - Reuse an existing sandbox HOME instead of creating one.
* When supplied, the caller owns its lifetime and it is NOT removed on failure.
* @param {object} [opts.extraEnv] - Extra environment variables merged over the
* installer env (after HOME/USERPROFILE), e.g. a runtime's config-home override.
*/
function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INSTALL_SCRIPT }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INSTALL_SCRIPT, root: providedRoot = null, extraEnv = {} }) {
const ownsRoot = providedRoot === null;
const root = providedRoot ?? fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
try {
const LOCAL_DIR_NAME = {
claude: '.claude', opencode: '.opencode', kilo: '.kilo',
@@ -545,7 +554,7 @@ function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INS
args.push(...extraArgs);
const result = spawnSync(process.execPath, args, {
cwd, encoding: 'utf8',
env: installerEnv({ HOME: root, USERPROFILE: root }),
env: installerEnv({ HOME: root, USERPROFILE: root, ...extraEnv }),
});
assert.strictEqual(result.status, 0,
`installer exited with status ${result.status} for ${runtime} --${scope}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
@@ -555,7 +564,10 @@ function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INS
: null;
return { manifest, configDir, root, stdout: result.stdout, stderr: result.stderr };
} catch (err) {
fs.rmSync(root, { recursive: true, force: true });
// Only reclaim a root this call created. A caller-supplied root may be
// shared across several installs (e.g. two runtimes into one HOME), so
// tearing it down here would destroy the caller's other fixtures.
if (ownsRoot) fs.rmSync(root, { recursive: true, force: true });
throw err;
}
}

View File

@@ -41,7 +41,7 @@ const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { runMinimalInstall, INSTALL_SCRIPT, installerEnv } = require('./helpers/install-shared.cjs');
const { cleanup } = require('./helpers.cjs');
const { cleanup, createTempDir, toPosixPath } = require('./helpers.cjs');
const {
negotiateHostCapabilities,
shouldFlattenDispatch,
@@ -324,3 +324,166 @@ test('boundary: every capability-declared extendedHookEvent is wired as a real e
`base claude-dialect event ${event} must also be wired for kimi`);
}
});
// ---------------------------------------------------------------------------
// #2755: the hooks-TOML root is per-runtime, not a shared ~/.kimi
// ---------------------------------------------------------------------------
/**
* Whether GSD's managed block is present, decided by the module's OWN parser
* rather than a substring probe: stripping is a no-op exactly when there is no
* block to strip.
*/
function hasGsdHooksBlock(tomlPath) {
if (!fs.existsSync(tomlPath)) return false;
const content = fs.readFileSync(tomlPath, 'utf8');
return stripKimiHooksTomlBlock(content) !== content;
}
/** Spawn the real installer for one Kimi variant against a shared sandbox HOME. */
function runKimiInstall(root, runtime, { extraEnv = {}, uninstall = false } = {}) {
return runMinimalInstall({
runtime,
scope: 'global',
root,
extraEnv,
extraArgs: uninstall ? ['--uninstall'] : [],
});
}
function sandboxHome(t, prefix = 'gsd-2755-') {
const root = createTempDir(prefix);
t.after(() => cleanup(root));
return root;
}
describe('kimi vs kimi-code hooks-TOML root (#2755)', () => {
test('--kimi-code --global writes its hooks into ~/.kimi-code and never creates ~/.kimi', (t) => {
const root = sandboxHome(t);
runKimiInstall(root, 'kimi-code');
assert.ok(hasGsdHooksBlock(path.join(root, '.kimi-code', 'config.toml')),
"the GSD [[hooks]] block must land in Kimi Code's own config.toml");
assert.ok(!fs.existsSync(path.join(root, '.kimi')),
"a --kimi-code install must not create Kimi CLI's ~/.kimi root at all");
});
test('--kimi-code --global installs its hook bundle under ~/.kimi-code/hooks', (t) => {
const root = sandboxHome(t);
runKimiInstall(root, 'kimi-code');
const hooksDir = path.join(root, '.kimi-code', 'hooks');
assert.ok(fs.existsSync(path.join(hooksDir, 'gsd-check-update.js')),
'the shared hook bundle must be self-contained under the kimi-code root');
assert.ok(fs.existsSync(path.join(hooksDir, 'lib')),
'hooks/lib must ship alongside it');
assert.ok(fs.existsSync(path.join(hooksDir, 'package.json')),
"the CommonJS marker must sit under kimi-code's hooks/ dir (#2544 shape)");
// The emitted [[hooks]] command paths must reference the same root the
// bundle was installed into, or every hook resolves to a missing script.
const toml = fs.readFileSync(path.join(root, '.kimi-code', 'config.toml'), 'utf8');
const managed = toml.slice(
toml.indexOf(KIMI_HOOKS_TOML_MARKER_BEGIN),
toml.indexOf(KIMI_HOOKS_TOML_MARKER_END),
);
const commandPaths = [...managed.matchAll(/^command = "(.*)"$/gm)].map((m) => m[1]);
assert.ok(commandPaths.length > 0, 'the managed block must emit at least one command');
for (const cmd of commandPaths) {
assert.ok(toPosixPath(cmd).includes('/.kimi-code/hooks/'),
`hook command must reference the kimi-code hooks dir: ${cmd}`);
assert.ok(!toPosixPath(cmd).includes('/.kimi/hooks/'),
`hook command must not reference Kimi CLI's hooks dir: ${cmd}`);
}
});
test('--kimi --global still writes into ~/.kimi and never creates ~/.kimi-code', (t) => {
const root = sandboxHome(t);
runKimiInstall(root, 'kimi');
assert.ok(hasGsdHooksBlock(path.join(root, '.kimi', 'config.toml')),
"kimi's own destination must be unchanged by #2755");
assert.ok(!fs.existsSync(path.join(root, '.kimi-code')),
"a --kimi install must not create Kimi Code's root");
});
test('KIMI_CODE_HOME redirects the kimi-code hooks destination', (t) => {
const root = sandboxHome(t);
const altHome = sandboxHome(t, 'gsd-2755-kch-');
runKimiInstall(root, 'kimi-code', { extraEnv: { KIMI_CODE_HOME: altHome } });
assert.ok(hasGsdHooksBlock(path.join(altHome, 'config.toml')),
'KIMI_CODE_HOME must redirect the hooks block');
assert.ok(!fs.existsSync(path.join(root, '.kimi-code')),
'the default kimi-code root must not be used when the env var is set');
assert.ok(!fs.existsSync(path.join(root, '.kimi')),
"Kimi CLI's root must not be touched either");
});
test('KIMI_SHARE_DIR still redirects the kimi hooks destination', (t) => {
const root = sandboxHome(t);
const altHome = sandboxHome(t, 'gsd-2755-ksd-');
runKimiInstall(root, 'kimi', { extraEnv: { KIMI_SHARE_DIR: altHome } });
assert.ok(hasGsdHooksBlock(path.join(altHome, 'config.toml')),
"KIMI_SHARE_DIR must keep redirecting kimi's hooks block");
assert.ok(!fs.existsSync(path.join(root, '.kimi-code')),
"kimi-code's root must not be touched");
});
test('uninstalling kimi-code leaves kimi hooks intact', (t) => {
const root = sandboxHome(t);
runKimiInstall(root, 'kimi');
runKimiInstall(root, 'kimi-code');
const kimiToml = path.join(root, '.kimi', 'config.toml');
const kimiCodeToml = path.join(root, '.kimi-code', 'config.toml');
const kimiBefore = fs.readFileSync(kimiToml, 'utf8');
runKimiInstall(root, 'kimi-code', { uninstall: true });
assert.equal(fs.readFileSync(kimiToml, 'utf8'), kimiBefore,
"a --kimi-code uninstall must leave Kimi CLI's config.toml byte-identical");
assert.ok(!hasGsdHooksBlock(kimiCodeToml),
"kimi-code's own block must be removed by its own uninstall");
});
test('uninstalling kimi leaves kimi-code hooks intact', (t) => {
const root = sandboxHome(t);
runKimiInstall(root, 'kimi');
runKimiInstall(root, 'kimi-code');
const kimiToml = path.join(root, '.kimi', 'config.toml');
const kimiCodeToml = path.join(root, '.kimi-code', 'config.toml');
const kimiCodeBefore = fs.readFileSync(kimiCodeToml, 'utf8');
runKimiInstall(root, 'kimi', { uninstall: true });
assert.equal(fs.readFileSync(kimiCodeToml, 'utf8'), kimiCodeBefore,
"a --kimi uninstall must leave Kimi Code's config.toml byte-identical");
assert.ok(!hasGsdHooksBlock(kimiToml),
"kimi's own block must be removed by its own uninstall");
});
test('KIMI_SHARE_DIR and KIMI_CODE_HOME set together do not interfere', (t) => {
// Both products' overrides live in one environment in practice. Each must
// honor only its own variable — proven through the real installer, not just
// the resolver unit.
const root = sandboxHome(t);
const kimiAlt = sandboxHome(t, 'gsd-2755-both-kimi-');
const codeAlt = sandboxHome(t, 'gsd-2755-both-code-');
const both = { KIMI_SHARE_DIR: kimiAlt, KIMI_CODE_HOME: codeAlt };
runKimiInstall(root, 'kimi', { extraEnv: both });
runKimiInstall(root, 'kimi-code', { extraEnv: both });
assert.ok(hasGsdHooksBlock(path.join(kimiAlt, 'config.toml')),
'kimi must honor KIMI_SHARE_DIR while KIMI_CODE_HOME is also set');
assert.ok(hasGsdHooksBlock(path.join(codeAlt, 'config.toml')),
'kimi-code must honor KIMI_CODE_HOME while KIMI_SHARE_DIR is also set');
assert.ok(!fs.existsSync(path.join(root, '.kimi')),
'neither default root may be used when both overrides are set');
assert.ok(!fs.existsSync(path.join(root, '.kimi-code')),
'neither default root may be used when both overrides are set');
});
});

View File

@@ -27,6 +27,7 @@ const {
getGlobalSkillsBase,
resolveAntigravityGlobalDir,
resolveKimiGlobalDir,
resolveKimiHooksTomlDir,
resolveConfigHomeFromDescriptor,
resolveSkillsBaseFromDescriptor,
detectAntigravityDirAmbiguity,
@@ -1226,3 +1227,95 @@ describe('bug #3126: init.cjs uses runtime-homes not hardcoded .claude', () => {
});
});
}
// ── resolveKimiHooksTomlDir: per-runtime hooks root (#2755) ──────────────────
describe('resolveKimiHooksTomlDir — per-runtime hooks root (#2755)', () => {
// Pure path computation; the fixture home never needs to exist on disk.
const FIXTURE_HOME = path.join(os.tmpdir(), 'gsd-2755-home-fixture');
const at = (...seg) => path.join(FIXTURE_HOME, ...seg);
test('a bare call does not throw', () => {
assert.doesNotThrow(() => resolveKimiHooksTomlDir());
});
test('an omitted runtime still resolves Kimi CLI\'s ~/.kimi (back-compat)', () => {
// The function is exported; callers and tests outside this diff pass no
// runtime, and their destination must not move.
assert.equal(resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {} }), at('.kimi'));
});
test('runtime "kimi" resolves ~/.kimi', () => {
assert.equal(
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {}, runtime: 'kimi' }),
at('.kimi'),
);
});
test('runtime "kimi-code" resolves ~/.kimi-code', () => {
assert.equal(
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {}, runtime: 'kimi-code' }),
at('.kimi-code'),
);
});
test('KIMI_SHARE_DIR overrides the kimi root', () => {
assert.equal(
String(resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: { KIMI_SHARE_DIR: '/share' }, runtime: 'kimi' })).replace(/\\/g, '/'),
'/share',
);
});
test('KIMI_CODE_HOME overrides the kimi-code root', () => {
assert.equal(
String(resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: { KIMI_CODE_HOME: '/kcode' }, runtime: 'kimi-code' })).replace(/\\/g, '/'),
'/kcode',
);
});
test('KIMI_SHARE_DIR does not hijack the kimi-code root', () => {
// KIMI_SHARE_DIR is Kimi CLI's own upstream var. Before #2755 it silently
// redirected kimi-code too — that accident was the issue's suggested
// workaround, and the fix must make it inert.
assert.equal(
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: { KIMI_SHARE_DIR: '/share' }, runtime: 'kimi-code' }),
at('.kimi-code'),
);
});
test('KIMI_CODE_HOME does not hijack the kimi root', () => {
assert.equal(
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: { KIMI_CODE_HOME: '/kcode' }, runtime: 'kimi' }),
at('.kimi'),
);
});
test('an unknown runtime falls back to ~/.kimi', () => {
assert.equal(
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {}, runtime: 'not-a-kimi' }),
at('.kimi'),
);
});
test('every kimi-hooks-toml runtime resolves a distinct hooks root', () => {
// Divergence guard (CLAUDE.md → Generative Fix Divergence). The resolver
// hardcodes the two Kimi roots while the capability registry independently
// decides which runtimes use the kimi-hooks-toml surface. If a third one is
// ever added without teaching the resolver its root, it silently inherits
// ~/.kimi — which IS the #2755 defect, re-created. This fails the moment
// those two surfaces drift apart.
const capsDir = path.join(ROOT, 'capabilities');
const ids = fs.readdirSync(capsDir).filter((id) => {
const file = path.join(capsDir, id, 'capability.json');
if (!fs.existsSync(file)) return false;
return JSON.parse(fs.readFileSync(file, 'utf8'))?.runtime?.hooksSurface === 'kimi-hooks-toml';
});
assert.deepEqual(ids.sort(), ['kimi', 'kimi-code'],
'the kimi-hooks-toml runtime set changed — teach resolveKimiHooksTomlDir the new root, then update this list');
const roots = ids.map((id) => resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {}, runtime: id }));
assert.equal(new Set(roots).size, roots.length,
`each kimi-hooks-toml runtime must resolve its own root; got ${JSON.stringify(roots)}`);
});
});