Files
Ken Jo 9f34c6f11b Add GitHub Copilot CLI + Antigravity CLI (agy) support (#787)
* feat(adapters): add Antigravity CLI (agy) + GitHub Copilot CLI support

Add two agentic CLI adapters onto next's existing adapter registration —
without the abandoned PR's setup subcommand / consolidated registry.

Antigravity CLI (agy):
- MCP + capture-only PostToolUse hook adapter (agy honors no stdout veto in
  auto-run mode; verified against agy 1.0.5). The agy hook payload
  {conversationId, toolCall, workspacePaths} is mapped onto the shared
  capture pipeline.
- Ships a Claude-layout plugin bundle (configs/antigravity-cli/) installed via
  `npm run install:agy` (mirrors install:openclaw), with a version-skew
  capture-hook probe in the installer.

GitHub Copilot CLI (1.0.59):
- json-stdio hook adapter with six events: PreToolUse, PostToolUse, PreCompact,
  SessionStart, UserPromptSubmit, Stop. Overrides CopilotBaseAdapter to emit the
  FLAT {type,command} + top-level "version": 1 hook config Copilot CLI requires.
- MCP install via `copilot mcp add context-mode -- context-mode`.
- Fix a latent Stop-hook bug: a session_end event with no `data` threw inside
  insertEvent (createHash(undefined)) and was silently dropped.

Cross-cutting:
- #774: probe agy/copilot config markers before the generic ~/.claude check.
  The copilot marker is narrowed to context-mode-written files
  (~/.copilot/mcp-config.json | hooks/context-mode.json), not a bare ~/.copilot/
  dir, so a co-installed-but-unconfigured Copilot CLI cannot steal detection
  from a Claude Code user.
- Dispatcher fails OPEN (exit 0) on a missing hook script: GitHub Copilot CLI
  treats an exit-1 PreToolUse hook as DENY, so a version skew (a newer adapter's
  hook command on an older global) would otherwise brick the agent.

Fixes #774. Fixes #775.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: regenerate bundles for antigravity-cli + copilot-cli support

Picks up the new HOOK_MAP entries, client-map keys, validPlatforms,
getSessionDirSegments cases, and the fail-open dispatcher into the
esbuild-generated runtime bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(platform-support): sync support docs to 18 platforms + fix stale Kiro classification

Make README.md and docs/platform-support.md internally consistent and aligned
with the adapter source of truth.

Header sync (18 platforms everywhere):
- The Main Comparison Table (was 11 cols), the Capability Matrix (was 11), and
  the README Platform Compatibility table (was 17, missing Kimi Code) now list
  the SAME 18 platforms in one shared order. Adds the two branch-new platforms
  (GitHub Copilot CLI, Antigravity CLI `agy`) plus previously-omitted Qwen Code,
  KiloCode, OpenClaw, Zed, Pi as columns. Each cell sourced from the per-platform
  detail sections / adapter source and independently verified.
- Fix five ragged rows in the Main Comparison Table (a dropped trailing OMP cell)
  and add CLI Hook Dispatcher rows for qwen-code + copilot-cli.
- GitHub Copilot CLI section: normalize the `**Hook Names:**` label and add the
  missing `**Output Modification:**` field for json-stdio-family parity.

Fix stale Kiro classification (code is the source of truth):
- The kiro adapter is json-stdio with working preToolUse/postToolUse hooks
  (hooks/kiro/{pretooluse,posttooluse}.mjs + a kiro HOOK_MAP entry), yet the docs
  called it "MCP-only (Phase 2 — not implemented)" and the README contradicted
  itself ("no hook support" in one place, "native preToolUse/postToolUse" in two
  others).
- Reclassify Kiro as json-stdio with PreToolUse + PostToolUse + exit-code-2
  blocking across the Overview paradigm table, both wide tables, the dispatcher
  table, and the Kiro detail section; document that agentSpawn (SessionStart) and
  stop are not yet wired, so session restore after compaction is unavailable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): drop vestigial .mcp.json dependency that broke fresh clones

The agy plugin-bundle test asserted configs/antigravity-cli/.mcp.json, but
.mcp.json is gitignored repo-wide and was never committed — so the test passed
on the dev machine (file present locally) yet failed on a fresh clone with
ENOENT. Committing the file is the wrong fix: the .gitignore comment documents
that shipping .mcp.json has silently broken fresh installs before (#253/#531).

- The bundle declares MCP the Claude way via .claude-plugin/plugin.json
  mcpServers (committed — the mechanism agy reads on `agy plugin install`),
  mirrored by the agy-native mcp_config.json (committed). Remove the vestigial
  bundle .mcp.json and stop the test + docs from requiring it. Every file the
  plugin test reads is now git-tracked, so a fresh clone passes.
- README: Kiro was still grouped under "Non-hook platforms" in the routing-
  enforcement note. Kiro has native preToolUse/postToolUse hooks; it needs the
  manual KIRO.md copy only because agentSpawn/SessionStart is not yet wired.
  Reword to say so.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(adapters): cross-platform agy installer + copilot-cli COPILOT_HOME parity

Windows fix (real): replace the bash-only agy plugin installer with a
cross-platform Node script so `npm run install:agy` runs natively on Windows
(PowerShell/cmd), not just Git Bash/WSL. agy runs on Windows, so its installer
must too — the old `node -e` wrapper hard-exited 1 on win32. openclaw stays
bash-only (it is genuinely POSIX-only). Removes
scripts/install-antigravity-cli-plugin.sh in favor of
scripts/install-antigravity-cli-plugin.mjs (same preflight + version-skew probe).

copilot-cli hardening (COPILOT_HOME edge case only — the default ~/.copilot
install was and remains correct):
- CopilotCliAdapter.getSessionDir() now roots at getConfigDir() (COPILOT_HOME-
  aware), mirroring codex/kimi, so the TS server reads sessions from the same
  place the hook runtime (COPILOT_OPTS configDirEnv: COPILOT_HOME) writes them.
  Previously a relocated COPILOT_HOME split hook writes ($COPILOT_HOME/...) from
  server reads (~/.copilot/...), making sessions appear empty.
- detect.ts copilot-cli marker honors COPILOT_HOME, not just ~/.copilot.

No change to the default (COPILOT_HOME-unset) behavior; a regression test pins
both the ~/.copilot default and the COPILOT_HOME-rooted path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: regenerate bundles for copilot-cli COPILOT_HOME parity

Picks up CopilotCliAdapter.getSessionDir() and the COPILOT_HOME-aware detect.ts
marker into the esbuild runtime bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): installer registers the MCP server (agy plugin install skips it)

`npm run install:agy` ran only `agy plugin install`, which — verified against
agy 1.0.5 — processes a bundle's skills + hooks but logs "mcpServers : skipped
(not found)" and registers NO MCP server. agy reads a plugin's MCP only from a
bundle `.mcp.json` (intentionally not shipped — gitignored repo-wide after
#253/#531) and has no `agy mcp add` command, so context-mode's MCP server was
never registered: users had to add it to ~/.gemini/config/mcp_config.json by hand
(reported on Windows; reproduced on Linux: `mcpServers : skipped (not found)`).

The installer now also writes context-mode into agy's GLOBAL MCP profile
~/.gemini/config/mcp_config.json (idempotent JSON merge, preserves other servers,
tolerates a malformed file) — the file agy actually loads and `context-mode
doctor` checks. Verified end-to-end on agy 1.0.5: `npm run install:agy` →
mcp_config.json gains context-mode → `agy -p "... ctx_execute ... 7 + 5"` → 12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): emit Gemini-safe tool schemas so agy/Gemini CLI expose ctx_* tools

Antigravity CLI (agy) and Gemini CLI use Gemini's function-calling API, which
rejects JSON Schema `const` and `additionalProperties`. When a tool's parameter
schema contains either, the host SILENTLY DROPS that tool from the model's
function list — so agy never sees the ctx_* tools and works around them by
hand-rolling the MCP protocol through its Bash tool (verified on Windows: agy
wrote scratch/call_ctx_stats.js + list_mcp_tools.js MCP clients instead of
calling the tools natively). That defeats the point of context-mode — bash
output floods the context window instead of staying in the sandbox.

context-mode builds schemas with Zod, which emits `const` (from coerce/preprocess
constructs) and `additionalProperties`, with no Gemini sanitization. Wrap the
SDK's tools/list handler to rewrite the EMITTED schema:
  - `const: X` -> `enum: [X]`   (an identical single-value constraint)
  - drop `additionalProperties` (advisory-only; every ctx_* handler parses args
    with Zod, which strips unknown keys server-side regardless)

Both transforms are behavior-preserving for every other client (Claude Code,
Copilot, Cursor): const and a one-value enum are equivalent, and no model sends
undeclared properties — only the wire schema changes, never validation or how a
tool is called. Best-effort: if the MCP SDK internals shift, the original handler
is left untouched (no regression). Verified on the real tools/list: all 11 ctx_*
tools now emit 0 `const` / 0 `additionalProperties`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: regenerate bundles for Gemini-safe tool schema sanitizer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): clear agy's stale MCP tool-schema cache on install

agy caches each MCP server's tool schemas under
~/.gemini/antigravity-cli/mcp/<server>/ and does NOT refresh them on reconnect
(verified on agy 1.0.6 against a live Windows install). A cache captured by a
context-mode older than the Gemini-safe-schema fix (ae6e7d3) keeps the
`const` / `additionalProperties` schemas that make Antigravity CLI silently drop
the ctx_* tools from the model's function list — so the schema fix never reaches
the model and the agent keeps working around the tools via shell scripts.

The installer now clears that cache after registering the MCP server, so agy
re-fetches the current Gemini-safe tools/list on its next launch. Verified on
Windows: clearing the cache + reconnecting makes agy re-store ctx_execute.json
with 0 `const` / 0 `additionalProperties`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document agy Gemini-safe schemas + installer cache-clear + copilot COPILOT_HOME

Reflect this branch's recent behavior changes in the support docs:
- agy: context-mode emits Gemini-safe tool schemas (const->enum, additionalProperties
  stripped) so Antigravity CLI exposes the ctx_* tools instead of silently dropping
  them; agy caches tool schemas and never refreshes them, so `npm run install:agy`
  clears that cache. Added to the agy Known Issues + install steps (platform-support.md
  + README).
- copilot-cli: COPILOT_HOME now relocates the session-DB root too (getSessionDir honors
  it), and the detection marker honors COPILOT_HOME.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: correct GitHub Copilot CLI plugin capability (plugins DO support MCP + hooks)

The README + platform-support docs claimed Copilot CLI plugins register only
skills/agents — not MCP servers or hooks. That's wrong: `copilot plugin --help`
and `copilot mcp --help` (Copilot CLI 1.x) confirm a plugin can register MCP
servers (a `.mcp.json` in the plugin root or `.github/mcp.json`) and hooks
(`hooks.json`), installed in one command via `copilot plugin install owner/repo:path`
(from a GitHub repo subdirectory, no clone). The "direct installs deprecated for
plugin@marketplace" note was also inaccurate (all source forms are current).

Corrected both docs. context-mode still registers via `copilot mcp add` +
`context-mode upgrade` today; a shippable Copilot plugin bundle
(configs/copilot-cli/) is noted as a planned follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(copilot-cli): ship a GitHub Copilot CLI plugin bundle (MCP + skill, phase 1)

`copilot plugin install mksglu/context-mode:configs/copilot-cli` registers the
context-mode MCP server + routing skill in one command — no `context-mode
upgrade` / agent call.

The bundle's .mcp.json pins CONTEXT_MODE_PLATFORM=copilot-cli so the server
self-identifies as Copilot. This fixes the detection trap where a co-installed
Claude Code (~/.claude/plugins/installed_plugins.json) makes standalone
`context-mode upgrade` — and even ctx_upgrade — resolve claude-code and write
Claude's config instead of Copilot's.

Real Copilot plugins discover MCP from a root `.mcp.json`, so this is the one
bundle whose .mcp.json is committed: .gitignore un-ignores exactly this path
(the repo-wide ignore from #253/#531 guards the repo-ROOT dev file, not a
plugin's own config).

Phase 2 (capture hooks via the plugin's hooks.json) follows once its format is
verified on Windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(copilot-cli): add capture hooks to the Copilot CLI plugin bundle (phase 2)

configs/copilot-cli/hooks.json registers all six Copilot hook events
(PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop, PreCompact),
each dispatching `context-mode hook copilot-cli <event>` against the global
binary. It is byte-equivalent to what `context-mode upgrade` writes to
~/.copilot/hooks/context-mode.json (the format verified against the
@github/copilot binary), so `copilot plugin install …:configs/copilot-cli` now
registers MCP + skill + capture hooks in one command — no `upgrade` / agent call.

Verified on Windows: with the plugin's env-pinned MCP config + a current global
context-mode, Copilot calls ctx_execute (→ 12) and ctx_upgrade resolves
copilot-cli (writes the Copilot hook, leaves Claude Code's config untouched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(copilot-cli): document the plugin bundle as the recommended install

README + platform-support now lead with `copilot plugin install
mksglu/context-mode:configs/copilot-cli` (one command: MCP + hooks + skill, no
upgrade/agent call), keeping `copilot mcp add` + `context-mode upgrade` as the
manual no-plugin path. Notes the .mcp.json env pin (CONTEXT_MODE_PLATFORM=
copilot-cli) that fixes detection under a co-installed Claude Code, the
.gitignore un-ignore for the bundle's .mcp.json, and the `copilot --plugin-dir`
local-test path. Drops the earlier "planned follow-up" wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(antigravity-cli): ship .mcp.json so `agy plugin install` registers MCP directly

The agy bundle declared MCP in two places that nothing consumed — a `mcpServers`
block in .claude-plugin/plugin.json (which `agy plugin install` SKIPS) and a dead
mcp_config.json (read by no code) — and relied on the installer writing agy's
GLOBAL ~/.gemini/config/mcp_config.json as a workaround for not shipping .mcp.json.

agy's plugin system is Claude-compatible and reads MCP from a bundle `.mcp.json`,
exactly like the Copilot bundle. Verified on agy 1.0.6: `agy plugin install` with
a bundle .mcp.json logs "mcpServers : 1 processed" and registers the server (env
preserved) into ~/.gemini/config/plugins/<name>/mcp_config.json. So:

- ship configs/antigravity-cli/.mcp.json (un-ignored via a .gitignore negation),
  pinning CONTEXT_MODE_PLATFORM=antigravity-cli so the server self-identifies as
  agy — fixing the #774 mis-detection at the MCP level, not only via dir markers;
- drop the dead mcp_config.json and the manifest's redundant mcpServers;
- simplify the installer: `agy plugin install` now registers MCP + skill + hook;
  it keeps the stale tool-schema cache-clear + version-skew probe, and now
  self-verifies the plugin-scoped MCP registration (one-line manual fallback if a
  future agy skips it) instead of blindly writing the global profile.

Both CLI plugin bundles (copilot-cli, antigravity-cli) are now consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): doctor recognizes the plugin-scoped MCP + hook registration

After the bundle moved to `.mcp.json` (so `agy plugin install` registers MCP +
the capture hook into agy's plugin profile ~/.gemini/config/plugins/context-mode/),
doctor still only checked the global ~/.gemini/config/{mcp_config,hooks}.json and
warned "context-mode not found" / "capture hook not configured" on a working install.

- checkPluginRegistration + validateHooks now accept the plugin profile (the
  canonical `agy plugin install` location) OR the global path (manual fallback).
- getInstalledVersion reads the installed plugin.json version so the version line
  shows a real semver (PASS when current) instead of the bogus "vconfigured".
- fix hints point to `npm run install:agy`.

Unit-tested (plugin-scoped PASS for both MCP + hook). Runtime already confirmed on
agy 1.0.6: `npm run install:agy` + `agy -p "...ctx_execute...7+5..."` → 12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: clarify supported client count

* fix(copilot-cli): fail-open PreToolUse hook + gate debug logs (#787 review)

A thrown PreToolUse hook exited non-zero with empty stdout, which GitHub
Copilot CLI 1.0.59 treats as "Denied by preToolUse hook (hook errored)" and
uses to block EVERY tool — bricking the agent. parseStdin runs JSON.parse, so
a malformed payload alone triggers it. Wrap the hook body in a fail-open
try/catch: a legitimate veto is a normal stdout write + return (never a
throw), so only real errors are swallowed (empty stdout + exit 0 => ALLOW).
Adds a regression test that spawns the hook with a throwing payload.

Also gate the per-invocation debug logs (posttooluse/precompact/sessionstart)
behind CONTEXT_MODE_DEBUG, matching the kimi hooks — the PostToolUse log grew
on every tool call under the user's config dir.

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

* fix(util/jsonc): string-aware trailing-comma strip (#787 review)

stripJsonComments stripped trailing commas with a regex over the whole string,
silently eating commas INSIDE string values (e.g. "[1, ]" -> "[1 ]") on the
comment-strip path (reached whenever strict JSON.parse fails). Move the
trailing-comma removal into a second string-aware pass over the comment-free
output: in-string commas are preserved while real trailing commas — including
those separated from } or ] by a comment — are still stripped. Regenerated
bundles (jsonc is bundled into cli/server.bundle.mjs).

The identical duplicates in src/server.ts and src/adapters/opencode/index.ts
are left for a follow-up consolidation PR (they parse third-party configs;
wider blast radius).

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

* test: consolidate per-adapter test files per CONTRIBUTING (#787 review)

CONTRIBUTING.md ("Test file organization") keeps one test file per adapter /
core module. Merge the standalone bundle-guard + schema files into their
canonical homes and delete the standalones — zero net-new test files:
  - copilot-cli-plugin.test.ts    -> adapters/copilot-cli.test.ts
  - antigravity-cli-plugin.test.ts -> adapters/antigravity.test.ts
  - strict-client-schema.test.ts  -> core/server.test.ts (sanitizeSchemaForStrictClients)

Also add the jsonc string-aware regression test to core/server.test.ts (its
home per the domain table; jsonc.ts has no test file of its own).

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

* test: rename copilot capture hooks file to the <platform>-hooks convention (#787 review)

The repo's per-platform hook test files are named tests/hooks/<platform>-hooks.test.ts
(cursor-hooks, gemini-hooks, vscode-hooks, jetbrains-hooks, kiro-hooks, kimi-hooks).
copilot-cli's was the lone deviation (copilot-cli-capture.test.ts). Rename it to
copilot-cli-hooks.test.ts and add the matching row to the CONTRIBUTING.md test-file
table. (antigravity-cli stays folded into antigravity.test.ts — capture-only single
hook, mirroring the GUI variant in the same family file, per the repo's precedent.)

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

* fix(version-sync): register the Copilot CLI bundle manifest (#787 review)

configs/copilot-cli/.github/plugin/plugin.json carries a pinned "version" but,
unlike the antigravity-cli bundle, was missing from version-sync — so it would
freeze on the next `npm version` bump (the .cursor-plugin v1.0.111 drift class
the version-sync test guards against). Add it to scripts/version-sync.mjs targets,
the package.json `version` git-add list, and the version-sync test (targets + pkg
list + SHIPPED lockstep + end-to-end), mirroring the agy bundle.

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

* feat(antigravity-cli): bounded PreToolUse enforcement via agy's native decision contract

agy honors a top-level PreToolUse decision `{"decision":"deny"|"ask",reason}`
(verified on agy 1.0.6) — not Claude's permissionDecision/additionalContext — so
context-mode can ENFORCE routing on agy, not just capture.

- PreToolUse routing hook (hooks/antigravity-cli/pretooluse.mjs) emits agy's
  native decision; deny/ask enforce, context/modify collapse to an enforceable
  deny (agy ignores additionalContext). Fail-open.
- Shared agy payload mapper (hooks/antigravity-cli/payload.mjs) used by
  pre/post/stop; posttooluse refactored onto it. New capture-only Stop hook
  (best-effort — agy Stop firing unconfirmed, so it's excluded from doctor health).
- Native root bundle: ships plugin.json + mcp_config.json + hooks.json +
  rules/context-mode.md (agy reads bundle-ROOT files); .mcp.json and
  .claude-plugin/plugin.json removed. hooks/hooks.json kept as the validate/install
  mirror — agy runtime fires from root hooks.json, but `agy plugin validate/install`
  only REPORTS hooks when the subdir hooks/hooks.json also exists.
- routing.mjs agy aliases (run_command->Bash, view_file->Read, ...) + CommandLine/
  AbsolutePath/URL extractors; tool-naming.mjs maps agy to context-mode/<tool>.
- adapter: capabilities preToolUse/postToolUse true, paradigm json-stdio, native
  decision formatter, doctor; cli.ts HOOK_MAP pretooluse/posttooluse/stop;
  version-sync tracks the bundle plugin.json.

Fixes a marker-handoff bug: pretooluse keyed rejected/redirect markers on
conversationId while posttooluse reads via getSessionId (which prefers the
transcript UUID) — both now use getSessionId, with a <uuid>.jsonl round-trip
regression test. Also corrects a stale core-routing assertion to agy's
context-mode/<tool> surface, adds the CONTRIBUTING test-file row, and includes
incidental CODEX_* test-env isolation hardening.

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

* refactor(antigravity-cli): review polish — modify guidance, ask fallback, sync comments, test placement

- formatters: agy `modify` now surfaces routing's per-tool redirect guidance
  (curl/build-tool/inline-HTTP) extracted from the echo payload instead of one
  generic line; `ask` carries a fallback reason so a security-policy confirmation
  prompt is never bare. Adapter formatPreToolUseResponse ask branch mirrored.
- comments: cross-reference the three agy tool-name maps (payload.mjs /
  routing.mjs / extract.ts) and the two agyContextReason copies (formatters.mjs /
  adapter) so they don't silently drift (single shared table = follow-up).
- tests: move the agy formatter tests to the canonical tests/hooks/formatters.test.ts
  (formatDecision wrapper style, beside the other per-platform blocks); assert the
  surfaced modify guidance + the ask fallback. Update the run_command deny test to
  the specific (non-generic) guidance.

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

* fix(antigravity-cli): default exec timeout under agy + anti-dump rules

Two agy-specific hardening fixes surfaced by interactive testing:

- ctx_execute / ctx_execute_file / ctx_batch_execute apply a default execution
  timeout (120s, tunable via CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS) ONLY under agy.
  agy does not enforce an MCP RPC timeout, so a runaway/blocking script hung
  forever and had to be interrupted; every other host keeps the unbounded
  behavior (Issue #406). resolveExecTimeout() centralizes this; timed-out
  messages now report the effective timeout (was "undefinedms"). Unit-tested +
  e2e-verified (runaway ctx_execute killed at the bound instead of hanging).
- rules/context-mode.md: add a prominent "Do not dump — derive" section. agy
  artifacts each MCP tool's stdout to a step file the model then reads back, so
  a whole-file dump costs the context window twice; steer the model to
  value/match/known-slice extraction instead.

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

* fix(copilot-cli): use camelCase hook event names so hooks actually fire

GitHub Copilot CLI (verified against the @github/copilot 1.0.60 binary)
dispatches hooks by camelCase event names ONLY — preToolUse / postToolUse /
sessionStart / userPromptSubmitted / agentStop / preCompact. The adapter
shipped PascalCase keys (PreToolUse / ...), which the binary silently ignores,
so context-mode's PreToolUse routing enforcement and PostToolUse capture never
fired on Copilot CLI. MCP tool exposure (.mcp.json auto-discovery) was
unaffected, which masked the regression.

- HOOK_TYPES values -> Copilot's camelCase. UserPromptSubmit->userPromptSubmitted
  and Stop->agentStop are NAME changes, not just casing.
- Decouple the CLI dispatch token from the event name: buildHookCommand now
  derives the token from the .mjs script base (pretooluse, ...), so the event
  KEY can be camelCase while the dispatcher and cli.ts hook handler stay stable.
- Update configs/copilot-cli/hooks.json keys, README, index.ts comments, tests.

Verified e2e on real Copilot CLI 1.0.60 via the documented plugin install:
PreToolUse denied a raw `curl` and redirected to ctx_fetch_and_index (the model
obeyed); PostToolUse fired (posttooluse-debug.log advanced under
CONTEXT_MODE_DEBUG). The internal DB event-type labels in hooks/copilot-cli/*.mjs
are context-mode's cross-adapter taxonomy and are intentionally unchanged.

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

* Keep Copilot CLI plugin MCP config loadable on older CLI

Mac smoke testing found that Copilot CLI 1.0.44 rejects the plugin MCP entry before startup unless the no-argument server still declares an explicit empty args array.

Constraint: Copilot CLI 1.0.44 requires an explicit args array for plugin stdio MCP entries

Rejected: Omit args because context-mode takes no arguments | older Copilot CLI rejects the plugin config before MCP startup

Confidence: high

Scope-risk: narrow

Directive: Keep args: [] in the Copilot plugin .mcp.json unless Copilot documents it as optional across supported versions

Tested: vitest copilot-cli adapter and hook suites; real Copilot CLI 1.0.44 loaded context-mode MCP after patch; real agy prompt returned 12

Not-tested: Copilot prompt completion, because local Copilot CLI fails to list models even without this plugin

Co-authored-by: OmX <omx@oh-my-codex.dev>

* Ship the agy installer in the npm package

Clean-install testing exposed that the package declared npm run install:agy but omitted the installer file from package.json files, so the installed tarball failed before agy plugin install could run.

Constraint: npm tarball contents are limited by package.json files

Rejected: Rely on repository-local installer presence | npm install -g ships only allowlisted files

Confidence: high

Scope-risk: narrow

Directive: Keep package scripts and package.json files in lockstep for shipped install commands

Tested: vitest antigravity and copilot adapter hook suites; npm pack includes scripts/install-antigravity-cli-plugin.mjs; npm uninstall -g context-mode then npm install -g tarball; npm --prefix installed package run install:agy; real agy prompt returned 12; Copilot loaded installed plugin MCP

Not-tested: Copilot prompt completion, because local Copilot CLI fails to list models after MCP startup

Co-authored-by: OmX <omx@oh-my-codex.dev>

* test(server): use valid tsc option for on-demand build

* fix(copilot-cli): validate plugin runtime hooks

* docs(copilot-cli,antigravity-cli): correct hook comments + fields to match upstream refs

Ground the new Copilot CLI / Antigravity CLI adapters against the real
upstream sources (refs/platforms) and fix misleading comments + one
contradicted field. No runtime behavior change to working paths.

Copilot CLI:
- version:1 is OPTIONAL, not mandatory — the CLI accepts hook configs
  that omit the version field (copilot-cli changelog.md:1109). Keep
  emitting version:1 (harmless, self-documenting); fix the comments,
  README, and docs that claimed hooks never fire without it.
- PascalCase event names are ACCEPTED and fire — the CLI loads configs
  across VS Code / Claude Code / CLI by accepting PascalCase alongside
  camelCase (changelog.md:1065, :811, :1081). Drop the 'silently
  ignored / never fires' claim; we use camelCase as the native naming.
- session_id (snake_case) is the documented payload field
  (changelog.md:811). Read it first; keep sessionId (camelCase) as a
  defensive, undocumented fallback.

Antigravity CLI:
- The only refs-backed payload field is workspace.current_dir, an object
  field (examples/title/title.sh:10, examples/title/README.md:11). Read
  workspace.current_dir FIRST for the project dir, falling back to the
  empirically-derived workspacePaths[0]. Annotate conversationId /
  workspacePaths as unverified. Stop stays best-effort/unverified on
  agy 1.0.6.

Docs: platform-support table + README continuity matrix now show
Antigravity CLI Stop as best-effort/unverified and the corrected
session-id / project-dir fields; 17-platform count unchanged (correct).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mert Köseoğlu <bm.ksglu@gmail.com>
Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-06-21 16:06:25 +03:00

3959 lines
182 KiB
TypeScript

/**
* Consolidated CLI tests
*
* Combines:
* - cli-bundle.test.ts (marketplace install support)
* - cli-hook-path.test.ts (forward-slash hook paths)
* - package-exports.test.ts (public API surface)
*/
import { describe, it, test, expect, beforeEach, afterEach } from "vitest";
import { strict as assert } from "node:assert";
import { readFileSync, existsSync, accessSync, constants, mkdirSync, writeFileSync, rmSync, readdirSync, cpSync, symlinkSync, lstatSync } from "node:fs";
import { resolve, join, dirname, sep } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { execSync, spawnSync } from "node:child_process";
import { toUnixPath } from "../../src/cli.js";
import { findMissingLaunchFiles } from "../../src/util/plugin-cache-integrity.js";
const ROOT = resolve(import.meta.dirname, "../..");
describe("hook dispatch fails OPEN on a missing hook (version-skew brick fix)", () => {
// A non-zero exit + empty stdout makes GitHub Copilot CLI DENY the tool
// ("Denied by preToolUse hook (hook errored)"), bricking the agent when a
// newer adapter's hook command runs against an older global that predates it.
// context-mode has no hook for an unknown platform/event, so it MUST exit 0
// (allow). Locks src/cli.ts hookDispatch's missing-script branch.
const CLI = resolve(ROOT, "cli.bundle.mjs");
it("exits 0 for an unknown platform (does not block the host's tool)", () => {
const r = spawnSync("node", [CLI, "hook", "__no_such_platform__", "pretooluse"], {
input: "{}",
encoding: "utf-8",
});
expect(r.status).toBe(0);
});
it("exits 0 for a known platform with an unmapped event", () => {
// antigravity-cli maps PreToolUse/PostToolUse/Stop, but not PreCompact.
const r = spawnSync("node", [CLI, "hook", "antigravity-cli", "precompact"], {
input: "{}",
encoding: "utf-8",
});
expect(r.status).toBe(0);
});
});
// ── cli.bundle.mjs — marketplace install support ──────────────────────
describe("cli.bundle.mjs — marketplace install support", () => {
// ── Package configuration ─────────────────────────────────
it("package.json files field includes cli.bundle.mjs", () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
expect(pkg.files).toContain("cli.bundle.mjs");
});
it("package.json files field includes statusline bin", () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
expect(pkg.files).toContain("bin");
});
it("package.json files field includes Codex plugin files", () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
expect(pkg.files).toContain(".codex-plugin");
});
it("Codex plugin MCP manifest approves context-mode tools by default", () => {
const mcp = JSON.parse(readFileSync(resolve(ROOT, ".codex-plugin", "mcp.json"), "utf-8"));
expect(mcp.mcpServers["context-mode"].default_tools_approval_mode).toBe("approve");
});
it("package.json bundle script builds cli.bundle.mjs", () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
expect(pkg.scripts.bundle).toContain("cli.bundle.mjs");
expect(pkg.scripts.bundle).toContain("src/cli.ts");
});
// ── Bundle artifact ────────────────────────────────────────
it("cli.bundle.mjs exists after npm run bundle", () => {
expect(existsSync(resolve(ROOT, "cli.bundle.mjs"))).toBe(true);
});
it("cli.bundle.mjs is readable", () => {
expect(() => accessSync(resolve(ROOT, "cli.bundle.mjs"), constants.R_OK)).not.toThrow();
});
it("cli.bundle.mjs has shebang only on line 1 (Node.js strips it)", () => {
const content = readFileSync(resolve(ROOT, "cli.bundle.mjs"), "utf-8");
const lines = content.split("\n");
expect(lines[0].startsWith("#!")).toBe(true);
// No shebang on any other line (would cause SyntaxError)
const shebangsAfterLine1 = lines.slice(1).filter(l => l.startsWith("#!"));
expect(shebangsAfterLine1).toHaveLength(0);
});
// ── Source code contracts ──────────────────────────────────
it("cli.ts getPluginRoot handles both build/ and root locations", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
// Must detect build/ subdirectory and go up, or stay at root
expect(src).toContain('endsWith("/build")');
expect(src).toContain('endsWith("\\\\build")');
});
it("cli.ts upgrade copies cli.bundle.mjs to target", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
expect(src).toContain('"cli.bundle.mjs"');
// Must be in the items array for in-place update
expect(src).toMatch(/items\s*=\s*\[[\s\S]*?"cli\.bundle\.mjs"/);
});
it("cli.ts exposes index/search commands for terminal knowledge-base access", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
expect(src).toContain("context-mode index <path>");
expect(src).toContain("context-mode search <query...>");
expect(src).toContain('args[0] === "index"');
expect(src).toContain('args[0] === "search"');
expect(src).toContain("resolveContentStorePath");
expect(src).toContain("searchWithFallback");
});
it("cli.ts upgrade doctor call prefers cli.bundle.mjs with fallback", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
expect(src).toContain("cli.bundle.mjs");
expect(src).toContain("build");
expect(src).toContain("cli.js");
// Must use existsSync for fallback
expect(src).toContain("existsSync");
});
it("cli.ts upgrade refreshes better-sqlite3 native addon after deps install", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
// Extract only the upgrade function body (starts with "async function upgrade")
const upgradeStart = src.indexOf("async function upgrade");
expect(upgradeStart).toBeGreaterThan(-1);
const upgradeSrc = src.slice(upgradeStart);
// Must refresh native addons between production deps and global install.
// Compatibility must be delegated to hooks/ensure-deps.mjs so stale ABI
// binaries are repaired before upgrade declares native addons healthy.
const depsIdx = upgradeSrc.indexOf('"install", "--production"');
const refreshIdx = upgradeSrc.indexOf('"ensure-deps.mjs"');
const globalIdx = upgradeSrc.indexOf('"install", "-g"');
expect(depsIdx).toBeGreaterThan(-1);
expect(refreshIdx).toBeGreaterThan(-1);
expect(globalIdx).toBeGreaterThan(-1);
// refresh step must come after deps and before global install
expect(refreshIdx).toBeGreaterThan(depsIdx);
expect(refreshIdx).toBeLessThan(globalIdx);
});
it("cli.ts upgrade chmod handles both cli binaries", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
// Must chmod both build/cli.js and cli.bundle.mjs
expect(src).toMatch(/for\s*\(.*\["build\/cli\.js",\s*"cli\.bundle\.mjs"\]/);
});
// ── Skill files ────────────────────────────────────────────
it("ctx-upgrade skill uses cli.bundle.mjs with fallback", () => {
const skill = readFileSync(resolve(ROOT, "skills", "ctx-upgrade", "SKILL.md"), "utf-8");
expect(skill).toContain("cli.bundle.mjs");
expect(skill).toContain("build/cli.js");
// Fallback pattern: try bundle first, then build
expect(skill).toMatch(/CLI=.*cli\.bundle\.mjs.*\[ ! -f.*\].*build\/cli\.js/);
});
it("ctx-doctor skill uses cli.bundle.mjs with fallback", () => {
const skill = readFileSync(resolve(ROOT, "skills", "ctx-doctor", "SKILL.md"), "utf-8");
expect(skill).toContain("cli.bundle.mjs");
expect(skill).toContain("build/cli.js");
expect(skill).toMatch(/CLI=.*cli\.bundle\.mjs.*\[ ! -f.*\].*build\/cli\.js/);
});
it("ctx-index and ctx-search skills expose slash-style triggers", () => {
const indexSkill = readFileSync(resolve(ROOT, "skills", "ctx-index", "SKILL.md"), "utf-8");
const searchSkill = readFileSync(resolve(ROOT, "skills", "ctx-search", "SKILL.md"), "utf-8");
expect(indexSkill).toContain("Trigger: /context-mode:ctx-index");
expect(indexSkill).toContain("user-invocable: true");
expect(indexSkill).toContain("context-mode index");
expect(searchSkill).toContain("Trigger: /context-mode:ctx-search");
expect(searchSkill).toContain("user-invocable: true");
expect(searchSkill).toContain("context-mode search");
});
// ── .gitignore ─────────────────────────────────────────────
it(".gitignore excludes bundle files (CI uses git add -f)", () => {
const gitignore = readFileSync(resolve(ROOT, ".gitignore"), "utf-8");
expect(gitignore).toContain("server.bundle.mjs");
expect(gitignore).toContain("cli.bundle.mjs");
});
});
// ── .mcp.json — MCP server config ────────────────────────────────────
describe(".mcp.json — MCP server config", () => {
it("upgrade MUST NOT write `.mcp.json` into the plugin cache dir (Issue #609 architectural lock)", () => {
// Bug-class history this lock protects:
// #411 introduced a `.mcp.json` write here with a CLAUDE_PLUGIN_ROOT
// placeholder. That solved an absolute-path-bake symptom but kept the
// write itself in place. Every /ctx-upgrade since then re-baked a
// per-version .mcp.json. When Claude Code's native plugin auto-update
// later copies the previous version's .mcp.json forward into the new
// cache dir, the path goes stale → MODULE_NOT_FOUND on every MCP boot,
// while ctx-doctor stays green (#609).
//
// Architectural fix: STOP writing `.mcp.json` from cli.ts entirely. The
// canonical MCP source is `.claude-plugin/plugin.json.mcpServers`
// (Claude Code upstream: mcpPluginIntegration.ts:131-212 reads it first).
// The post-bump `sweepStaleMcpJson` call removes any pre-existing files
// so the carry-forward vector cannot replay.
//
// This test enforces the architectural decision. Re-introducing the write
// would re-open the bug class regardless of which path shape is used
// (placeholder OR absolute) — the write itself is the surface area.
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
const upgradeStart = src.indexOf("async function upgrade");
const upgradeSrc = src.slice(upgradeStart);
// The items[] copy list must still NOT include .mcp.json (kept from #531).
const itemsMatch = upgradeSrc.match(/const items\s*=\s*\[([\s\S]*?)\];/);
expect(itemsMatch).not.toBeNull();
expect(itemsMatch![1]).not.toContain(".mcp.json");
// cli.ts upgrade() MUST NOT write `.mcp.json` to pluginRoot. Any
// resolve(pluginRoot, ".mcp.json") + writeFileSync chain is forbidden.
expect(upgradeSrc).not.toMatch(/writeFileSync\(\s*resolve\(\s*pluginRoot\s*,\s*["']\.mcp\.json["']/);
});
it("upgrade MUST sweep stale .mcp.json files post-bump (Issue #609)", () => {
// Belt-and-braces partner to the no-write lock above: cli.ts MUST call
// `sweepStaleMcpJson` after `updatePluginRegistry` so any pre-existing
// copies left by prior versions (or carried forward by Claude Code's
// auto-update) are removed before the upgrade is declared successful.
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
// Import from the shared heal module — single source of truth.
expect(src).toMatch(
/sweepStaleMcpJson[^;]*from\s+["']\.\.\/scripts\/heal-installed-plugins\.mjs["']/,
);
const upgradeStart = src.indexOf("async function upgrade");
// Slice the whole upgrade region rather than a fixed-width window so
// pattern lookups stay valid as the function grows under feature work.
const upgradeSrc = src.slice(upgradeStart);
// Order constraint: sweep runs AFTER updatePluginRegistry so the
// cleanup operates against the final on-disk shape.
const updateIdx = upgradeSrc.indexOf("updatePluginRegistry");
const sweepIdx = upgradeSrc.indexOf("sweepStaleMcpJson");
expect(updateIdx).toBeGreaterThan(-1);
expect(sweepIdx).toBeGreaterThan(updateIdx);
// Belt-and-braces second-call assertion: if first sweep removed files,
// a second pass MUST report removed:[] or upgrade() throws.
const block = upgradeSrc.slice(sweepIdx, sweepIdx + 1500);
expect(block).toMatch(/sweep drift|sweep check failed/i);
expect(block).toMatch(/throw new Error/);
});
it("plugin manifest keeps ${CLAUDE_PLUGIN_ROOT} for marketplace compatibility", () => {
// Marketplace installs read .claude-plugin/plugin.json, not repo-root
// .mcp.json. The plugin manifest is the one that must retain the
// ${CLAUDE_PLUGIN_ROOT} placeholder so installed plugins resolve their
// bundled server path. Repo-root .mcp.json is for contributors opening
// the repo as a regular project and uses a relative path to avoid the
// "Missing environment variable: CLAUDE_PLUGIN_ROOT" warning.
//
// We read the COMMITTED file via `git show HEAD:` rather than the
// working-tree copy because hooks/normalize-hooks.mjs intentionally
// rewrites the on-disk plugin.json to absolute paths on Windows after
// postinstall (#378 — MSYS path mangling). The marketplace assertion
// is about what we SHIP, not about what local install scripts mutate.
const committed = execSync("git show HEAD:.claude-plugin/plugin.json", {
cwd: ROOT,
encoding: "utf-8",
});
const plugin = JSON.parse(committed);
const args = plugin.mcpServers["context-mode"].args;
expect(args[0]).toContain("CLAUDE_PLUGIN_ROOT");
});
it(".mcp.json.example template MUST use ${CLAUDE_PLUGIN_ROOT} placeholder (closes #531)", () => {
// Architectural lock-in after PR #253 (aea633c) regression:
// .mcp.json is no longer tracked in source. The canonical template lives
// at .mcp.json.example so contributors who copy it locally still get
// the marketplace-correct placeholder form. End-user MCP launch flows
// through `.claude-plugin/plugin.json.mcpServers` only — cli.ts no longer
// writes `.mcp.json` into the plugin cache (Issue #609 fix).
const example = JSON.parse(
readFileSync(resolve(ROOT, ".mcp.json.example"), "utf-8"),
);
const args = example.mcpServers["context-mode"].args;
expect(args[0]).toContain("${CLAUDE_PLUGIN_ROOT}");
expect(args[0]).toContain("start.mjs");
});
it("package.json files[] MUST NOT ship .mcp.json (architectural lock — closes #531)", () => {
// PR #253 flip-flop: source .mcp.json kept switching between the
// placeholder form (correct for end-users via marketplace install) and
// the relative form (correct for contributors opening the repo as a
// regular project). Stop shipping it in the tarball so the two roles
// never collide again. End users get MCP via .claude-plugin/plugin.json
// — cli.ts no longer writes `.mcp.json` to the plugin cache (#609).
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
expect(pkg.files).toBeDefined();
expect(pkg.files).not.toContain(".mcp.json");
});
});
// ── CLI Hook Path Tests ───────────────────────────────────────────────
describe("CLI Hook Path Tests", () => {
test("toUnixPath: converts backslashes to forward slashes", () => {
const input = "C:\\Users\\xxx\\AppData\\Local\\npm-cache\\_npx\\hooks\\pretooluse.mjs";
const result = toUnixPath(input);
assert.ok(
!result.includes("\\"),
`Expected no backslashes, got: ${result}`,
);
assert.equal(
result,
"C:/Users/xxx/AppData/Local/npm-cache/_npx/hooks/pretooluse.mjs",
);
});
test("toUnixPath: leaves forward-slash paths unchanged", () => {
const input = "/home/user/.claude/plugins/context-mode/hooks/pretooluse.mjs";
const result = toUnixPath(input);
assert.equal(result, input);
});
test("toUnixPath: handles mixed slashes", () => {
const input = "C:/Users\\xxx/AppData\\Local\\hooks/pretooluse.mjs";
const result = toUnixPath(input);
assert.ok(!result.includes("\\"), `Expected no backslashes, got: ${result}`);
});
test("toUnixPath: hook command string has no backslashes", () => {
// Simulate what upgrade() does: "node " + resolve(...)
// On Windows, resolve() returns backslashes — toUnixPath must normalize them
const windowsPath = "C:\\Users\\xxx\\.claude\\plugins\\cache\\context-mode\\hooks\\pretooluse.mjs";
const command = "node " + toUnixPath(windowsPath);
assert.ok(
!command.includes("\\"),
`Hook command must not contain backslashes: ${command}`,
);
});
test("toUnixPath: sessionstart path has no backslashes", () => {
const windowsPath = "C:\\Users\\xxx\\.claude\\plugins\\cache\\context-mode\\hooks\\sessionstart.mjs";
const command = "node " + toUnixPath(windowsPath);
assert.ok(
!command.includes("\\"),
`SessionStart command must not contain backslashes: ${command}`,
);
});
});
// ── ABI-aware native binary caching (#148) ────────────────────────────
/**
* Extract ensureNativeCompat from hooks/ensure-deps.mjs at test time.
* ensure-deps.mjs is the shared bootstrap with side effects (auto-runs on import),
* so we extract the function source via regex, wrap it as a temp ESM module,
* and dynamically import it — tests always run against the real code.
*/
async function loadEnsureNativeCompat(): Promise<(pluginRoot: string) => void> {
const src = readFileSync(resolve(ROOT, "hooks", "ensure-deps.mjs"), "utf-8");
const match = src.match(/^export function ensureNativeCompat\b[\s\S]*?^}/m);
if (!match) throw new Error("ensureNativeCompat not found in hooks/ensure-deps.mjs");
// Also extract hasModernSqlite if ensureNativeCompat calls it (#331)
const helperMatch = src.match(/^function hasModernSqlite\b[\s\S]*?^}/m);
const helpers = helperMatch ? helperMatch[0] + "\n" : "";
// Extract codesignBinary and probeNativeInChildProcess helpers if present
const replaceBinaryMatch = src.match(/^function replaceActiveNativeBinaryFromCache\b[\s\S]*?^}/m);
const codesignMatch = src.match(/^(?:export\s+)?function codesignBinary\b[\s\S]*?^}/m);
const probeMatch = src.match(/^function probeNativeInChildProcess\b[\s\S]*?^}/m);
const replaceBinary = replaceBinaryMatch ? replaceBinaryMatch[0] + "\n" : "";
const codesign = codesignMatch ? codesignMatch[0] + "\n" : "";
const probe = probeMatch ? probeMatch[0] + "\n" : "";
const tmpFile = join(tmpdir(), `abi-test-${Date.now()}.mjs`);
writeFileSync(tmpFile, [
'import { existsSync, copyFileSync, renameSync, unlinkSync } from "node:fs";',
'import { resolve } from "node:path";',
'import { createRequire } from "node:module";',
'import { execSync } from "node:child_process";',
helpers,
codesign,
replaceBinary,
probe,
`${match[0]}`,
].join("\n"));
try {
const mod = await import(tmpFile);
return mod.ensureNativeCompat;
} finally {
rmSync(tmpFile, { force: true });
}
}
describe("ABI-aware native binary caching (#148)", () => {
let tempDir: string;
let releaseDir: string;
let binaryPath: string;
const currentAbi = process.versions.modules;
function abiCachePath(abi: string = currentAbi): string {
return join(releaseDir, `better_sqlite3.abi${abi}.node`);
}
function createFakeBinary(path: string, content: string = "fake-binary"): void {
writeFileSync(path, content);
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "abi-test-"));
releaseDir = join(tempDir, "node_modules", "better-sqlite3", "build", "Release");
binaryPath = join(releaseDir, "better_sqlite3.node");
mkdirSync(releaseDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("ensure-deps.mjs contains ensureNativeCompat function", () => {
const src = readFileSync(resolve(ROOT, "hooks", "ensure-deps.mjs"), "utf-8");
expect(src).toContain("function ensureNativeCompat");
// ensure-deps.mjs auto-runs the function with root on import
expect(src).toContain("ensureNativeCompat(root)");
});
test("cache hit: copies cached ABI binary to active path", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
createFakeBinary(abiCachePath(), "abi-cached-binary");
createFakeBinary(binaryPath, "old-binary");
ensureNativeCompat(tempDir);
expect(readFileSync(binaryPath, "utf-8")).toBe("abi-cached-binary");
});
test("missing release directory: does not throw", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
rmSync(releaseDir, { recursive: true });
expect(() => ensureNativeCompat(tempDir)).not.toThrow();
});
test("missing binary + no cache: does not throw", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
expect(() => ensureNativeCompat(tempDir)).not.toThrow();
});
test("cache hit does not trigger rebuild", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
createFakeBinary(abiCachePath(), "cached");
createFakeBinary(binaryPath, "old");
ensureNativeCompat(tempDir);
expect(readFileSync(binaryPath, "utf-8")).toBe("cached");
});
test("cross-platform: ABI cache filename uses correct format", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
createFakeBinary(binaryPath, "binary");
// Trigger probe — will fail (fake binary) but outer catch swallows it
ensureNativeCompat(tempDir);
const files = readdirSync(releaseDir);
const cacheFiles = files.filter(f => f.match(/^better_sqlite3\.abi\d+\.node$/));
// Probe fails on fake binary, so no cache file is created — that's correct behavior
expect(cacheFiles.length).toBeLessThanOrEqual(1);
});
test("multiple ABI caches coexist without interference", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
createFakeBinary(join(releaseDir, "better_sqlite3.abi115.node"), "node20-binary");
createFakeBinary(join(releaseDir, "better_sqlite3.abi137.node"), "node24-binary");
createFakeBinary(binaryPath, "old");
ensureNativeCompat(tempDir);
const expected = currentAbi === "115" ? "node20-binary" : currentAbi === "137" ? "node24-binary" : undefined;
if (expected) {
expect(readFileSync(binaryPath, "utf-8")).toBe(expected);
}
expect(existsSync(join(releaseDir, "better_sqlite3.abi115.node"))).toBe(true);
expect(existsSync(join(releaseDir, "better_sqlite3.abi137.node"))).toBe(true);
});
// ── Bun ABI cache seeding (#543) ────────────────────────────
//
// When ensureNativeCompat runs under Bun, it early-returns BEFORE writing
// better_sqlite3.abi${N}.node — so the very next /ctx-upgrade run (which
// checks for that file as the success marker) prints a spurious
// "Native addon ABI cache missing" warning.
//
// Bun spoofs process.versions.modules to the Node ABI (e.g. 137 on
// Darwin/Bun-1.2+, matching Node 24), so a plain file-copy of the active
// better_sqlite3.node to the ABI-tagged path produces the CORRECT
// filename for any subsequent Node boot at the same ABI level.
//
// The fix lives BEFORE the existing `if (typeof globalThis.Bun !== "undefined") return;`
// guard inside ensureNativeCompat: if the active binary exists AND the
// ABI cache file does NOT, copy active → cache, then early-return.
//
// @see https://github.com/mksglu/context-mode/issues/543
describe("Bun ABI cache seeding (#543)", () => {
let bunBackup: unknown;
const hadBun = "Bun" in globalThis;
beforeEach(() => {
bunBackup = (globalThis as any).Bun;
// Simulate Bun runtime without actually running under Bun.
(globalThis as any).Bun = { version: "test-shim" };
});
afterEach(() => {
if (hadBun) {
(globalThis as any).Bun = bunBackup;
} else {
delete (globalThis as any).Bun;
}
});
test("under Bun, with active .node but no abi cache: seeds the cache via copy", async () => {
// Load AFTER the Bun shim is installed so the function captures it.
const ensureNativeCompat = await loadEnsureNativeCompat();
createFakeBinary(binaryPath, "active-binary-from-postinstall");
// Cache file is intentionally absent — this is the #543 scenario.
expect(existsSync(abiCachePath())).toBe(false);
ensureNativeCompat(tempDir);
// The fix: copy active → abi-tagged so the next /ctx-upgrade boot
// (under Node) finds the marker file and reports "ABI cache present".
expect(existsSync(abiCachePath())).toBe(true);
expect(readFileSync(abiCachePath(), "utf-8")).toBe("active-binary-from-postinstall");
// Active binary remains untouched.
expect(readFileSync(binaryPath, "utf-8")).toBe("active-binary-from-postinstall");
});
test("under Bun, without active .node source: does not throw and does not create cache", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
// No active binary present — Bun-only install or pre-postinstall state.
expect(existsSync(binaryPath)).toBe(false);
expect(() => ensureNativeCompat(tempDir)).not.toThrow();
// No cache should be invented out of thin air.
expect(existsSync(abiCachePath())).toBe(false);
});
test("under Bun, when abi cache already exists: does not overwrite", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
createFakeBinary(binaryPath, "fresh-active");
createFakeBinary(abiCachePath(), "preexisting-cache");
ensureNativeCompat(tempDir);
// Idempotent: existing cache must be preserved untouched.
expect(readFileSync(abiCachePath(), "utf-8")).toBe("preexisting-cache");
});
test("under Bun, when nativeDir is missing entirely: does not throw and does not create cache", async () => {
const ensureNativeCompat = await loadEnsureNativeCompat();
// Remove the release directory before invocation.
rmSync(releaseDir, { recursive: true, force: true });
expect(existsSync(releaseDir)).toBe(false);
expect(() => ensureNativeCompat(tempDir)).not.toThrow();
// Cache MUST NOT be created because the source dir doesn't exist —
// creating files inside a deleted directory would either throw or
// re-materialize state the user explicitly removed.
expect(existsSync(abiCachePath())).toBe(false);
});
test("under Bun, cache filename uses cross-platform resolve() (no hard-coded separators)", () => {
// Source-contract guard: the fix must reuse the existing resolve()-based
// path construction (nativeDir / binaryPath / abiCachePath are already
// resolve()'d at the top of ensureNativeCompat). A future maintainer
// adding hard-coded "/" or "\\" inside the Bun branch would break
// Windows. We assert the fix lives inside ensureNativeCompat AND that
// no new path concatenation with hard-coded separators appears in
// the Bun branch.
const src = readFileSync(resolve(ROOT, "hooks", "ensure-deps.mjs"), "utf-8");
const fnMatch = src.match(/^export function ensureNativeCompat\b[\s\S]*?^}/m);
expect(fnMatch).not.toBeNull();
const body = fnMatch![0];
// The fix must reference both source and destination paths.
expect(body).toMatch(/binaryPath/);
expect(body).toMatch(/abiCachePath/);
// Cross-platform safety: no path string built with "\\" or "/" literals
// inside the Bun gate region. We anchor on the Bun gate comment and
// scan the surrounding region for forbidden hard-coded separators.
const bunGateIdx = body.indexOf("Bun ships bun:sqlite");
expect(bunGateIdx).toBeGreaterThan(-1);
const bunRegion = body.slice(Math.max(0, bunGateIdx - 200), bunGateIdx + 600);
// No string concatenation with hard-coded path separators in the Bun region.
expect(bunRegion).not.toMatch(/["'][^"']*\\\\better_sqlite3/);
expect(bunRegion).not.toMatch(/["']\/[^"']*better_sqlite3\.node["']/);
});
});
});
// ── bun:sqlite adapter (#45) ──────────────────────────────────────────
describe("bun:sqlite adapter (#45)", () => {
/**
* Helper: create an in-memory SQLite db that behaves like bun:sqlite.
* Uses better-sqlite3 as engine but strips/alters methods to match bun:sqlite API:
* - NO .pragma() method
* - .get() returns null instead of undefined
* - .exec() is alias for single-statement .run()
*/
async function createBunLikeFake(dbPath?: string) {
const { loadDatabase } = await import("../../src/db-base.js");
const Database = loadDatabase();
const real = new Database(dbPath ?? ":memory:");
const wrapStatement = (stmt: any) => ({
run: (...args: any[]) => stmt.run(...args),
get: (...args: any[]) => {
const r = stmt.get(...args);
return r === undefined ? null : r; // bun returns null
},
all: (...args: any[]) => stmt.all(...args),
iterate: (...args: any[]) => stmt.iterate(...args),
columns: () => stmt.columns(),
});
return {
prepare: (sql: string) => wrapStatement(real.prepare(sql)),
exec: (sql: string) => real.exec(sql),
transaction: (fn: any) => real.transaction(fn),
close: () => real.close(),
// NO .pragma() — bun:sqlite doesn't have it
};
}
test("pragma: adapter.pragma() returns scalar for assignment", async () => {
const { BunSQLiteAdapter } = await import("../../src/db-base.js");
const dbFile = join(mkdtempSync(join(tmpdir(), "bun-adapter-")), "test.db");
const fake = await createBunLikeFake(dbFile);
const db = new BunSQLiteAdapter(fake);
const result = db.pragma("journal_mode = WAL");
expect(result).toBe("wal");
db.close();
rmSync(dbFile, { force: true });
});
test("pragma: adapter.pragma() returns rows for table_xinfo", async () => {
const { BunSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createBunLikeFake();
const db = new BunSQLiteAdapter(fake);
fake.exec("CREATE TABLE test_tbl (id INTEGER PRIMARY KEY, name TEXT)");
const rows = db.pragma("table_xinfo(test_tbl)");
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBe(2);
expect(rows[0].name).toBe("id");
expect(rows[1].name).toBe("name");
db.close();
});
test("exec: adapter.exec() handles multi-statement SQL", async () => {
const { BunSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createBunLikeFake();
const db = new BunSQLiteAdapter(fake);
db.exec(`
CREATE TABLE t1 (id INTEGER PRIMARY KEY);
CREATE TABLE t2 (id INTEGER PRIMARY KEY);
INSERT INTO t1 VALUES (1);
INSERT INTO t2 VALUES (2);
`);
const r1 = db.prepare("SELECT * FROM t1").all();
const r2 = db.prepare("SELECT * FROM t2").all();
expect(r1).toHaveLength(1);
expect(r2).toHaveLength(1);
db.close();
});
test("exec: adapter.exec() handles semicolons inside string literals", async () => {
const { BunSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createBunLikeFake();
const db = new BunSQLiteAdapter(fake);
db.exec(`
CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT);
INSERT INTO t VALUES (1, 'hello; world');
INSERT INTO t VALUES (2, 'foo "bar; baz" qux');
`);
const rows = db.prepare("SELECT * FROM t ORDER BY id").all() as any[];
expect(rows).toHaveLength(2);
expect(rows[0].val).toBe("hello; world");
expect(rows[1].val).toBe('foo "bar; baz" qux');
db.close();
});
test("get: adapter.prepare().get() returns undefined not null for missing row", async () => {
const { BunSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createBunLikeFake();
const db = new BunSQLiteAdapter(fake);
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
const result = db.prepare("SELECT * FROM t WHERE id = 999").get();
expect(result).toBeUndefined(); // not null
expect(result).not.toBeNull();
db.close();
});
test("run: adapter.prepare().run() returns {changes, lastInsertRowid}", async () => {
const { BunSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createBunLikeFake();
const db = new BunSQLiteAdapter(fake);
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
const info = db.prepare("INSERT INTO t (name) VALUES (?)").run("test");
expect(info.changes).toBe(1);
expect(info.lastInsertRowid).toBe(1);
db.close();
});
test("transaction: adapter.transaction() works", async () => {
const { BunSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createBunLikeFake();
const db = new BunSQLiteAdapter(fake);
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)");
const insertMany = db.transaction((items: string[]) => {
for (const item of items) {
db.prepare("INSERT INTO t (val) VALUES (?)").run(item);
}
});
insertMany(["a", "b", "c"]);
const rows = db.prepare("SELECT * FROM t").all();
expect(rows).toHaveLength(3);
db.close();
});
test("loadDatabase: checks globalThis.Bun before choosing driver (#163)", () => {
// Bun's require("better-sqlite3") returns a non-functional stub.
// loadDatabase() must check globalThis.Bun FIRST and use bun:sqlite directly.
const src = readFileSync(resolve(ROOT, "src", "db-base.ts"), "utf-8");
const loadDbSection = src.slice(src.indexOf("function loadDatabase"), src.indexOf("return _Database"));
// Must check Bun runtime before loading any driver
expect(loadDbSection).toContain("globalThis");
expect(loadDbSection).toContain("Bun");
// Bun path must use bun:sqlite via BunSQLiteAdapter
expect(loadDbSection).toContain("BunSQLiteAdapter");
// Node path uses better-sqlite3
expect(loadDbSection).toContain("better-sqlite3");
});
test("loadDatabase: falls back to BunSQLiteAdapter when better-sqlite3 unavailable", async () => {
const { BunSQLiteAdapter } = await import("../../src/db-base.js");
// BunSQLiteAdapter should be a class/constructor
expect(typeof BunSQLiteAdapter).toBe("function");
// Verify it provides the full better-sqlite3 interface
const fake = await createBunLikeFake();
const db = new BunSQLiteAdapter(fake);
expect(typeof db.pragma).toBe("function");
expect(typeof db.exec).toBe("function");
expect(typeof db.prepare).toBe("function");
expect(typeof db.transaction).toBe("function");
expect(typeof db.close).toBe("function");
db.close();
});
});
// ── node:sqlite adapter (#228) ──────────────────────────────────────────
describe("node:sqlite adapter (#228)", () => {
/**
* Helper: create an in-memory SQLite db that behaves like node:sqlite's DatabaseSync.
* Uses better-sqlite3 as engine but strips/alters methods to match node:sqlite API:
* - NO .pragma() method
* - NO .transaction() method
* - .exec() supports multi-statement natively (better-sqlite3 already does)
* - .get() returns undefined (same as better-sqlite3)
* - .prepare() returns StatementSync-like objects
*/
async function createNodeSQLiteFake(dbPath?: string) {
const { loadDatabase } = await import("../../src/db-base.js");
// Reset cached _Database to get fresh better-sqlite3
const Database = loadDatabase();
const real = new Database(dbPath ?? ":memory:");
return {
prepare: (sql: string) => {
const stmt = real.prepare(sql);
return {
run: (...args: any[]) => stmt.run(...args),
get: (...args: any[]) => stmt.get(...args),
all: (...args: any[]) => stmt.all(...args),
// node:sqlite doesn't have iterate() — has Symbol.iterator
[Symbol.iterator]: (...args: any[]) => stmt.iterate(...args),
};
},
exec: (sql: string) => real.exec(sql),
// NO .pragma() — node:sqlite doesn't have it
// NO .transaction() — node:sqlite doesn't have it
close: () => real.close(),
};
}
test("pragma: adapter.pragma() returns scalar for assignment", async () => {
const { NodeSQLiteAdapter } = await import("../../src/db-base.js");
const dbFile = join(mkdtempSync(join(tmpdir(), "node-adapter-")), "test.db");
const fake = await createNodeSQLiteFake(dbFile);
const db = new NodeSQLiteAdapter(fake);
const result = db.pragma("journal_mode = WAL");
expect(result).toBe("wal");
db.close();
rmSync(dbFile, { force: true });
});
test("pragma: adapter.pragma() returns rows for table_xinfo", async () => {
const { NodeSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createNodeSQLiteFake();
const db = new NodeSQLiteAdapter(fake);
fake.exec("CREATE TABLE test_tbl (id INTEGER PRIMARY KEY, name TEXT)");
const rows = db.pragma("table_xinfo(test_tbl)");
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBe(2);
expect(rows[0].name).toBe("id");
expect(rows[1].name).toBe("name");
db.close();
});
test("exec: adapter.exec() handles multi-statement SQL", async () => {
const { NodeSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createNodeSQLiteFake();
const db = new NodeSQLiteAdapter(fake);
db.exec(`
CREATE TABLE t1 (id INTEGER PRIMARY KEY);
CREATE TABLE t2 (id INTEGER PRIMARY KEY);
INSERT INTO t1 VALUES (1);
INSERT INTO t2 VALUES (2);
`);
const r1 = db.prepare("SELECT * FROM t1").all();
const r2 = db.prepare("SELECT * FROM t2").all();
expect(r1).toHaveLength(1);
expect(r2).toHaveLength(1);
db.close();
});
test("get: adapter.prepare().get() returns undefined for missing row", async () => {
const { NodeSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createNodeSQLiteFake();
const db = new NodeSQLiteAdapter(fake);
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
const result = db.prepare("SELECT * FROM t WHERE id = 999").get();
expect(result).toBeUndefined();
db.close();
});
test("run: adapter.prepare().run() returns {changes, lastInsertRowid}", async () => {
const { NodeSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createNodeSQLiteFake();
const db = new NodeSQLiteAdapter(fake);
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
const info = db.prepare("INSERT INTO t (name) VALUES (?)").run("test");
expect(info.changes).toBe(1);
expect(info.lastInsertRowid).toBe(1);
db.close();
});
test("transaction: adapter.transaction() commits on success", async () => {
const { NodeSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createNodeSQLiteFake();
const db = new NodeSQLiteAdapter(fake);
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)");
const insertMany = db.transaction((items: string[]) => {
for (const item of items) {
db.prepare("INSERT INTO t (val) VALUES (?)").run(item);
}
});
insertMany(["a", "b", "c"]);
const rows = db.prepare("SELECT * FROM t").all();
expect(rows).toHaveLength(3);
db.close();
});
test("transaction: adapter.transaction() rolls back on error", async () => {
const { NodeSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createNodeSQLiteFake();
const db = new NodeSQLiteAdapter(fake);
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)");
const failingTx = db.transaction(() => {
db.prepare("INSERT INTO t (val) VALUES (?)").run("should-rollback");
throw new Error("intentional");
});
expect(() => failingTx()).toThrow("intentional");
const rows = db.prepare("SELECT * FROM t").all();
expect(rows).toHaveLength(0); // rolled back
db.close();
});
test("loadDatabase: source uses hasModernSqlite() before choosing node:sqlite (#228, #551)", () => {
const src = readFileSync(resolve(ROOT, "src", "db-base.ts"), "utf-8");
const loadDbSection = src.slice(src.indexOf("function loadDatabase"), src.indexOf("return _Database"));
// #551: gate widened from `process.platform === "linux"` to
// hasModernSqlite() — Node 26 broke better-sqlite3 native compile on
// macOS arm64, so we prefer node:sqlite on every platform that has it.
expect(loadDbSection).toContain("hasModernSqlite()");
expect(loadDbSection).not.toMatch(/process\.platform\s*===\s*"linux"/);
// Must reference NodeSQLiteAdapter
expect(loadDbSection).toContain("NodeSQLiteAdapter");
// Must still have better-sqlite3 fallback
expect(loadDbSection).toContain("better-sqlite3");
});
test("NodeSQLiteAdapter provides full better-sqlite3 interface", async () => {
const { NodeSQLiteAdapter } = await import("../../src/db-base.js");
const fake = await createNodeSQLiteFake();
const db = new NodeSQLiteAdapter(fake);
expect(typeof db.pragma).toBe("function");
expect(typeof db.exec).toBe("function");
expect(typeof db.prepare).toBe("function");
expect(typeof db.transaction).toBe("function");
expect(typeof db.close).toBe("function");
db.close();
});
});
// ── Shared dep bootstrap (#172) ──────────────────────────────────────
describe("hooks/ensure-deps.mjs — shared bootstrap", () => {
it("ensure-deps.mjs exists and exports ensureDeps function", async () => {
expect(existsSync(resolve(ROOT, "hooks", "ensure-deps.mjs"))).toBe(true);
const mod = await import("../../hooks/ensure-deps.mjs");
expect(typeof mod.ensureDeps).toBe("function");
});
it("start.mjs uses ensure-deps.mjs for native deps", () => {
const src = readFileSync(resolve(ROOT, "start.mjs"), "utf-8");
expect(src).toContain("ensure-deps.mjs");
// better-sqlite3 should NOT be in start.mjs inline loop (handled by ensure-deps)
expect(src).not.toMatch(/for.*\[.*"better-sqlite3"/s);
});
it("all session hooks bootstrap ensure-deps via runHook wrapper (#414)", () => {
// After #414 fix: top-level static `import "./ensure-deps.mjs"` was a
// parse-time crash vector on Windows (loader:1479 bypassed try/catch).
// ensure-deps is now dynamic-imported inside hooks/run-hook.mjs.
const runHookSrc = readFileSync(resolve(ROOT, "hooks", "run-hook.mjs"), "utf-8");
expect(runHookSrc).toContain("ensure-deps.mjs");
const sessionHooks = [
"hooks/sessionstart.mjs",
"hooks/posttooluse.mjs",
"hooks/precompact.mjs",
"hooks/userpromptsubmit.mjs",
"hooks/pretooluse.mjs",
];
for (const hook of sessionHooks) {
const src = readFileSync(resolve(ROOT, hook), "utf-8");
expect(src).toContain("run-hook.mjs");
// Top-level static side-effect import must NOT remain (would bypass uncaughtException).
expect(src).not.toMatch(/^import\s+["']\.\/ensure-deps\.mjs["'];?$/m);
}
});
});
// ── Cross-OS compatibility ────────────────────────────────────────────
describe("Cross-OS compatibility", () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
it("build script does not shell out to POSIX chmod command", () => {
// Shell `chmod +x` is not available on Windows cmd.exe
// Node.js fs.chmodSync is cross-platform and acceptable
expect(pkg.scripts.build).not.toMatch(/\bchmod\s+\+x\b/);
});
it("postinstall script uses node for cross-platform compatibility", () => {
// POSIX [ -n ... ] && printf || true fails on Windows cmd.exe
expect(pkg.scripts.postinstall).not.toMatch(/\[ -n/);
expect(pkg.scripts.postinstall).not.toContain("printf");
expect(pkg.scripts.postinstall).toMatch(/^node /);
// postinstall.mjs must be in files array for npm publish
expect(pkg.files).toContain("scripts/postinstall.mjs");
});
it("install:openclaw gracefully handles missing bash on Windows", () => {
// Direct 'bash' invocation fails on Windows without Git Bash
expect(pkg.scripts["install:openclaw"]).not.toMatch(/^bash /);
});
it("cli.ts chmodSync in setup/upgrade is guarded by platform check", () => {
// chmodSync must only run on non-Windows
const chmodIdx = src.indexOf('chmodSync(binPath');
expect(chmodIdx).toBeGreaterThan(-1);
// Must have a platform guard before the chmodSync call
const contextBefore = src.slice(Math.max(0, chmodIdx - 500), chmodIdx);
expect(contextBefore).toMatch(/process\.platform\s*!==\s*["']win32["']/);
});
});
// ── Bin entry: cli.bundle.mjs ─────────────────────────────────────────
describe("Bin entry uses cli.bundle.mjs", () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
it("package.json bin points to cli.bundle.mjs, not build/cli.js", () => {
expect(pkg.bin["context-mode"]).toBe("./cli.bundle.mjs");
});
it("package.json exports ./cli points to cli.bundle.mjs", () => {
expect(pkg.exports["./cli"]).toBe("./cli.bundle.mjs");
});
it("server.ts ctx_doctor runs diagnostics in-process (no CLI dependency)", () => {
const src = readFileSync(resolve(ROOT, "src", "server.ts"), "utf-8");
const doctorSection = src.slice(src.indexOf("ctx_doctor"), src.indexOf("ctx_upgrade"));
// Must NOT delegate to CLI — runs server-side
expect(doctorSection).not.toContain('node "');
// Must run actual checks
expect(doctorSection).toContain("PolyglotExecutor");
expect(doctorSection).toContain("FTS5");
});
it("doctor hook script discovery supports flat and nested hook config entries", () => {
const helperSrc = readFileSync(resolve(ROOT, "src", "util", "hook-config.ts"), "utf-8");
expect(helperSrc).toContain("export function getCommandsFromHookEntry");
expect(helperSrc).toContain("(entry as { command?: unknown }).command");
expect(helperSrc).toContain("Array.isArray(hooks)");
expect(helperSrc).toContain("getCommandsFromHookEntry(entry)");
expect(helperSrc).not.toContain("entry.hooks");
for (const rel of ["src/cli.ts", "src/server.ts"]) {
const src = readFileSync(resolve(ROOT, rel), "utf-8");
expect(src).toContain('import { getHookScriptPaths } from "./util/hook-config.js";');
expect(src).not.toContain("function getCommandsFromHookEntry");
expect(src).not.toContain("function getHookScriptPaths");
}
});
// ── Algo-D1: doctor consumes adapter.getHealthChecks ──
//
// The HookAdapter contract grew an OPTIONAL `getHealthChecks(pluginRoot)`
// returning HealthCheck[] (src/adapters/types.ts). Doctor must iterate
// `adapter.getHealthChecks?.(pluginRoot) ?? []` so claude-code's
// direct-existsSync hook checks are surfaced WITHOUT going back through
// the regex round-trip that produced the #548 doubled-path FAIL.
// Adapters that don't override the optional method get nothing — they
// don't have this class of check today.
it("cli doctor invokes adapter.getHealthChecks(pluginRoot) (Algo-D1)", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
const doctorStart = src.indexOf("async function doctor");
const doctorBody = src.slice(doctorStart, doctorStart + 8000);
// Wiring: doctor must call the optional method via the safe-call
// operator so adapters that don't override it are untouched.
expect(doctorBody).toMatch(/adapter\.getHealthChecks\?\.\(pluginRoot\)/);
// The result must be iterated and rendered with status branches —
// not silently dropped. Match the same `result.status === "OK"`
// shape the HealthCheck contract uses.
expect(doctorBody).toContain('result.status === "OK"');
});
it("cli doctor renders hook warnings as WARN instead of FAIL", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
const loopStart = src.indexOf("for (const result of hookResults)");
const loopEnd = src.indexOf("// Hook scripts exist", loopStart);
const loop = src.slice(loopStart, loopEnd);
expect(loop).toContain('result.status === "warn"');
expect(loop).toContain("p.log.warn");
expect(loop).toContain(": WARN");
});
it("cli doctor treats standalone adapters as not version-comparable", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
const versionStart = src.indexOf("Checking versions");
const versionBlock = src.slice(versionStart, versionStart + 1800);
expect(versionBlock).toContain('installedVersion === "standalone"');
expect(versionBlock).toContain("standalone MCP mode");
expect(versionBlock).toContain("no platform plugin version to compare");
expect(versionBlock.indexOf('installedVersion === "standalone"'))
.toBeLessThan(versionBlock.indexOf('installedVersion === "not installed"'));
});
it("upgrade still reaches hook configuration when already on latest", () => {
const src = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
const alreadyLatestIdx = src.indexOf("Already on latest");
const configureIdx = src.indexOf("Configuring ${adapter.name} hooks");
expect(alreadyLatestIdx).toBeGreaterThan(-1);
expect(configureIdx).toBeGreaterThan(alreadyLatestIdx);
const alreadyLatestBlock = src.slice(alreadyLatestIdx, configureIdx);
expect(alreadyLatestBlock).not.toContain("return;");
});
it("server.ts ctx_upgrade uses cli.bundle.mjs with fallback", () => {
const src = readFileSync(resolve(ROOT, "src", "server.ts"), "utf-8");
// ctx_upgrade handler must prefer cli.bundle.mjs
const upgradeStart = src.indexOf('server.registerTool(\n "ctx_upgrade"');
const upgradeEnd = src.indexOf("// ── ctx-purge", upgradeStart);
const upgradeSection = src.slice(upgradeStart, upgradeEnd);
expect(upgradeSection).toContain("cli.bundle.mjs");
});
it("server.ts registers empty prompts/resources handlers to avoid -32601 (#168)", () => {
const src = readFileSync(resolve(ROOT, "src", "server.ts"), "utf-8");
// Must register prompts capability so clients don't get Method not found
expect(src).toContain("ListPromptsRequestSchema");
// Must register resources capability
expect(src).toContain("ListResourcesRequestSchema");
// Must return empty arrays
expect(src).toContain("prompts: []");
expect(src).toContain("resources: []");
});
it("openclaw-plugin.ts doctor/upgrade use cli.bundle.mjs with fallback", () => {
const src = readFileSync(resolve(ROOT, "src", "adapters", "openclaw", "plugin.ts"), "utf-8");
expect(src).toContain("cli.bundle.mjs");
// Find the registerCommand blocks, not comments
const doctorIdx = src.indexOf('name: "ctx-doctor"');
const upgradeIdx = src.indexOf('name: "ctx-upgrade"');
expect(doctorIdx).toBeGreaterThan(-1);
expect(upgradeIdx).toBeGreaterThan(-1);
const doctorSection = src.slice(doctorIdx, doctorIdx + 500);
const upgradeSection = src.slice(upgradeIdx, upgradeIdx + 500);
expect(doctorSection).toContain("cli.bundle.mjs");
expect(upgradeSection).toContain("cli.bundle.mjs");
});
});
// ── start.mjs CLI self-heal ───────────────────────────────────────────
describe("start.mjs CLI self-heal", () => {
test("start.mjs self-heals cli.bundle.mjs when missing", () => {
const src = readFileSync(resolve(ROOT, "start.mjs"), "utf-8");
// Must check for cli.bundle.mjs existence
expect(src).toContain("cli.bundle.mjs");
// Must reference build/cli.js as fallback source
expect(src).toContain("build");
expect(src).toContain("cli.js");
// Must write a shim
expect(src).toContain("writeFileSync");
});
test("start.mjs CLI self-heal is after ensure-deps import and before server import", () => {
const src = readFileSync(resolve(ROOT, "start.mjs"), "utf-8");
// Anchor on actual statements, not raw filename mentions. The Linux Bun
// re-exec change in commit f985afa added a documentation comment that
// names `server.bundle.mjs` near the top of the file; `indexOf` matched
// THAT comment instead of the actual `await import("./server.bundle.mjs")`,
// pushing the assertion to compare selfHealIdx=16381 < commentIdx=2347 →
// FAIL on otherwise-correct ordering. Use uniquely-shaped statement
// fragments so a future comment cannot shift the test's compass.
const ensureDepsIdx = src.indexOf('import "./hooks/ensure-deps.mjs"');
const selfHealIdx = src.indexOf('if (!existsSync(resolve(__dirname, "cli.bundle.mjs"))');
const serverImportIdx = src.indexOf('await import("./server.bundle.mjs")');
expect(ensureDepsIdx, "ensure-deps import statement missing").toBeGreaterThan(-1);
expect(selfHealIdx, "cli.bundle.mjs self-heal block missing").toBeGreaterThan(-1);
expect(serverImportIdx, "server.bundle.mjs await-import missing").toBeGreaterThan(-1);
// Self-heal must be between ensure-deps import and server import.
expect(selfHealIdx).toBeGreaterThan(ensureDepsIdx);
expect(selfHealIdx).toBeLessThan(serverImportIdx);
});
// ── Algo-D4: plugin-cache integrity from package.json files[] ──
//
// #550: partial install (e.g. interrupted npm install, broken
// marketplace pull) leaves start.mjs spawnable but server.bundle.mjs
// missing. The MCP child then dies silently downstream — user sees
// "MCP server failed to start" with no actionable signal. D4 derives
// the expected sibling tree from package.json files[] (the npm publish
// source of truth) and exits 2 with a structured stderr block listing
// the missing files. Algorithmic: adding a new entry to files[] auto-
// extends the integrity check — no parallel hardcoded list to
// maintain.
test("scripts/plugin-cache-integrity.mjs derives expected files from package.json files[]", async () => {
const { derivePluginManifest } = await import(
"../../scripts/plugin-cache-integrity.mjs"
);
// Synthetic package.json: directories recurse; files are kept as-is.
const pkg = {
files: ["server.bundle.mjs", "cli.bundle.mjs", "hooks", "start.mjs"],
};
const helperRoot = mkdtempSync(join(tmpdir(), "ctx-mode-pcim-"));
try {
// Build a tree shaped like the npm tarball
writeFileSync(join(helperRoot, "server.bundle.mjs"), "");
writeFileSync(join(helperRoot, "cli.bundle.mjs"), "");
writeFileSync(join(helperRoot, "start.mjs"), "");
mkdirSync(join(helperRoot, "hooks"));
writeFileSync(join(helperRoot, "hooks", "pretooluse.mjs"), "");
writeFileSync(join(helperRoot, "hooks", "sessionstart.mjs"), "");
const manifest = derivePluginManifest({ pkg, pluginRoot: helperRoot });
// Files in files[] kept as-is; directories recurse to enumerate
// every file inside.
expect(manifest).toContain("server.bundle.mjs");
expect(manifest).toContain("cli.bundle.mjs");
expect(manifest).toContain("start.mjs");
expect(manifest).toContain(join("hooks", "pretooluse.mjs"));
expect(manifest).toContain(join("hooks", "sessionstart.mjs"));
} finally {
rmSync(helperRoot, { recursive: true, force: true });
}
});
test("scripts/plugin-cache-integrity.mjs returns OK when all expected siblings exist", async () => {
const { assertPluginCacheIntegrity } = await import(
"../../scripts/plugin-cache-integrity.mjs"
);
// The actual repo root has every entry from package.json files[]
// present (it's the source of truth that gets published). So a
// probe against ROOT must return ok=true.
const result = assertPluginCacheIntegrity({ pluginRoot: ROOT });
expect(result.ok).toBe(true);
expect(result.missing).toEqual([]);
});
test("scripts/plugin-cache-integrity.mjs flags missing files as not-ok", async () => {
const { assertPluginCacheIntegrity } = await import(
"../../scripts/plugin-cache-integrity.mjs"
);
// Empty pluginRoot → every required file is missing.
const emptyRoot = mkdtempSync(join(tmpdir(), "ctx-mode-pcim-empty-"));
try {
const result = assertPluginCacheIntegrity({ pluginRoot: emptyRoot });
expect(result.ok).toBe(false);
expect(result.missing.length).toBeGreaterThan(0);
// The check must surface the absolute path so users can see
// exactly where it looked — not a relative segment.
for (const m of result.missing) {
expect(m.startsWith(emptyRoot)).toBe(true);
}
} finally {
rmSync(emptyRoot, { recursive: true, force: true });
}
});
test("start.mjs invokes assertPluginCacheIntegrity with stderr + exit 2 on failure (Algo-D4)", () => {
// Wiring check on the bootstrapper itself: the start.mjs body must
// import the helper, call it, and on `!ok` write a structured stderr
// block (CONTEXT_MODE_PARTIAL_INSTALL) then process.exit(2). The
// structured marker lets external monitoring grep for the exact
// failure mode without parsing free-form text.
const src = readFileSync(resolve(ROOT, "start.mjs"), "utf-8");
expect(src).toContain("plugin-cache-integrity.mjs");
expect(src).toContain("assertPluginCacheIntegrity");
expect(src).toContain("CONTEXT_MODE_PARTIAL_INSTALL");
expect(src).toMatch(/process\.exit\(\s*2\s*\)/);
});
test("scripts/plugin-cache-integrity.mjs ships in npm tarball (package.json files[])", () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
expect(pkg.files).toContain("scripts/plugin-cache-integrity.mjs");
});
// ── findMissingLaunchFiles — partial-install masking regression (PR #689) ──
//
// When an interrupted /ctx-upgrade swap leaves the active plugin-cache dir
// half-populated, the integrity helper itself
// (scripts/plugin-cache-integrity.mjs, shipped in package.json files[]) can
// be among the missing files. The doctor then reported only "integrity
// helper unavailable", masking the real breakage: the MCP launch entrypoint
// (start.mjs / server bundle) was also gone, so
// `node ${CLAUDE_PLUGIN_ROOT}/start.mjs` failed and the MCP server never
// started.
//
// findMissingLaunchFiles is a dependency-free (fs-only) check that surfaces
// exactly which launch files are absent, so the diagnostic stays useful even
// when the integrity helper module cannot load. Folded from the original
// tests/util/plugin-cache-launch-files.test.ts in PR #689 per CONTRIBUTING
// L282 (no new test files — extend the existing file for the domain).
describe("findMissingLaunchFiles (PR #689)", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "ctx-launch-files-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
const touch = (rel: string): void => {
const abs = join(root, rel);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, "");
};
it("returns [] when start.mjs and server.bundle.mjs are present", () => {
touch("start.mjs");
touch("server.bundle.mjs");
expect(findMissingLaunchFiles(root)).toEqual([]);
});
it("flags a missing start.mjs (the no-fallback command entrypoint)", () => {
touch("server.bundle.mjs");
expect(findMissingLaunchFiles(root)).toEqual(["start.mjs"]);
});
it("accepts build/server.js as the server fallback (no false positive)", () => {
touch("start.mjs");
touch("build/server.js"); // server.bundle.mjs absent, but fallback present
expect(findMissingLaunchFiles(root)).toEqual([]);
});
it("flags the server only when BOTH server.bundle.mjs and build/server.js are absent", () => {
touch("start.mjs");
expect(findMissingLaunchFiles(root)).toEqual([
"server.bundle.mjs (or build/server.js)",
]);
});
it("reports every missing launch file for a fully empty (partial) install", () => {
// Reproduces the observed broken cache: neither the entrypoint nor the
// server bundle was copied by the interrupted swap.
expect(findMissingLaunchFiles(root)).toEqual([
"start.mjs",
"server.bundle.mjs (or build/server.js)",
]);
});
});
// ── Algo-D4 — algorithmic runtime-sibling derivation (#558) ──────────
//
// v1.0.126 shipped Algo-D4 with a HARDCODED `REQUIRED_RUNTIME_SIBLINGS`
// array that omitted `hooks/security.bundle.mjs` (and would omit any
// future runtime-critical bundle). Result: the integrity check returns
// `{ ok: true }` on a marketplace install where `hooks/security.bundle.mjs`
// is missing — exactly the silent fail-open #558 reports. The fix is
// algorithmic: derive the required-sibling set from `derivePluginManifest`
// (which itself reads `package.json files[]`), filtered to a runtime-
// critical pattern. Adding `hooks/security.bundle.mjs` (or any future
// hooks/*.bundle.mjs) to files[] auto-extends the integrity check.
test("Algo-D4 algorithmically requires hooks/security.bundle.mjs (#558)", async () => {
// Synthesize a marketplace-install scenario: every hardcoded boot
// sibling is present, but hooks/security.bundle.mjs is NOT. The pre-
// 558 hardcoded check passes vacuously here — that's the regression.
const { assertPluginCacheIntegrity } = await import(
"../../scripts/plugin-cache-integrity.mjs"
);
const fakeRoot = mkdtempSync(join(tmpdir(), "ctx-mode-d4-algo-"));
try {
// Stage every legacy-hardcoded sibling so the test isolates the
// new requirement: only hooks/security.bundle.mjs is missing.
writeFileSync(join(fakeRoot, "server.bundle.mjs"), "");
writeFileSync(join(fakeRoot, "cli.bundle.mjs"), "");
writeFileSync(join(fakeRoot, "start.mjs"), "");
mkdirSync(join(fakeRoot, "hooks"));
writeFileSync(join(fakeRoot, "hooks", "pretooluse.mjs"), "");
writeFileSync(join(fakeRoot, "hooks", "posttooluse.mjs"), "");
writeFileSync(join(fakeRoot, "hooks", "precompact.mjs"), "");
writeFileSync(join(fakeRoot, "hooks", "sessionstart.mjs"), "");
writeFileSync(join(fakeRoot, "hooks", "userpromptsubmit.mjs"), "");
// Copy the real package.json so the algorithm reads the same
// files[] the npm tarball ships with.
const pkgSrc = readFileSync(resolve(ROOT, "package.json"), "utf-8");
writeFileSync(join(fakeRoot, "package.json"), pkgSrc);
const result = assertPluginCacheIntegrity({ pluginRoot: fakeRoot });
expect(result.ok).toBe(false);
// Must surface the missing security bundle path so the doctor /
// boot-fail block tells users exactly what's broken.
const missingStr = result.missing.join("\n");
expect(missingStr).toContain("security.bundle.mjs");
} finally {
rmSync(fakeRoot, { recursive: true, force: true });
}
});
test("Algo-D4 derivation reads scripts.bundle outfiles — pure-data, no parallel hardcoded list", async () => {
// Algorithmic guarantee: a future runtime bundle added to
// `scripts.bundle` (with `--outfile=hooks/foo.bundle.mjs`) is
// auto-gated by Algo-D4 — no parallel REQUIRED_RUNTIME_SIBLINGS
// edit needed. Soft-fallback bundles (session-* with
// bundle-first/build-fallback in session-loaders.mjs) are
// explicitly whitelisted out.
const mod: any = await import("../../scripts/plugin-cache-integrity.mjs");
// The new public surface — algorithm must be inspectable from tests.
expect(typeof mod.getRequiredRuntimeSiblings).toBe("function");
const fakeRoot = mkdtempSync(join(tmpdir(), "ctx-mode-d4-pkg-"));
try {
// Synthetic package.json: scripts.bundle produces 3 outfiles.
// - hooks/security.bundle.mjs is runtime-critical → required.
// - hooks/session-db.bundle.mjs is soft-fallback → NOT required.
// - hooks/foo.bundle.mjs is a hypothetical future bundle → required
// (proves the gate auto-extends without code changes).
const pkg = {
files: ["server.bundle.mjs", "cli.bundle.mjs", "hooks", "start.mjs"],
scripts: {
bundle:
"esbuild src/security.ts --outfile=hooks/security.bundle.mjs && " +
"esbuild src/session/db.ts --outfile=hooks/session-db.bundle.mjs && " +
"esbuild src/foo.ts --outfile=hooks/foo.bundle.mjs",
},
};
writeFileSync(join(fakeRoot, "package.json"), JSON.stringify(pkg));
const required: string[] = mod.getRequiredRuntimeSiblings(fakeRoot);
// Runtime-critical bundle must be in the set:
expect(required.some((p) => p.endsWith("security.bundle.mjs"))).toBe(true);
// Hypothetical future bundle auto-included (the algorithmic win):
expect(required.some((p) => p.endsWith("foo.bundle.mjs"))).toBe(true);
// Soft-fallback bundle MUST be excluded — its absence is gracefully
// handled by session-loaders.mjs's bundle-first/build-fallback.
expect(required.some((p) => p.endsWith("session-db.bundle.mjs"))).toBe(false);
} finally {
rmSync(fakeRoot, { recursive: true, force: true });
}
});
test("Algo-D4 preserves the legacy hardcoded contract (no regression on existing required siblings)", async () => {
// Backward-compat guarantee: every entry that used to be in the
// hardcoded REQUIRED_RUNTIME_SIBLINGS array must still be flagged
// as required by the algorithmic derivation. This pins the
// pre-558 contract so the algorithmic refactor is purely additive.
const mod: any = await import("../../scripts/plugin-cache-integrity.mjs");
const required: string[] = mod.getRequiredRuntimeSiblings(ROOT);
const legacy = [
"server.bundle.mjs",
"cli.bundle.mjs",
join("hooks", "pretooluse.mjs"),
join("hooks", "posttooluse.mjs"),
join("hooks", "precompact.mjs"),
join("hooks", "sessionstart.mjs"),
join("hooks", "userpromptsubmit.mjs"),
];
for (const entry of legacy) {
expect(
required.some((p) => p === entry || p.endsWith(entry)),
`legacy required sibling missing from algorithmic set: ${entry}`,
).toBe(true);
}
});
// ── hooks/heal-partial-install.mjs (partial-install auto-recovery) ──
//
// Claude Code's native plugin manager occasionally produces a partial
// install when it creates a new cache version directory: hooks/ and
// .claude-plugin/ make it across, but cli.bundle.mjs, server.bundle.mjs,
// start.mjs, package.json, src/, bin/, scripts/, and skills/ don't. The
// .claude-plugin/plugin.json carries forward from the previous version
// with a stale absolute mcpServers.args[0]; once the old version dir is
// age-gate-cleaned, every MCP spawn ENOENTs.
//
// The existing #550 boot gate and the #604 stale-cache-version ratchet
// both fire from start.mjs, the very file that's missing. The heal
// module lives in hooks/ since that's the directory that reliably
// survives the partial copy, and runs from hooks/sessionstart.mjs (the
// primary trigger) and start.mjs (belt-and-braces, before the Algo-D4
// gate so a fixable install is repaired rather than just reported).
describe("hooks/heal-partial-install.mjs (partial-install auto-recovery)", () => {
const heallessCleanups: string[] = [];
afterEach(() => {
while (heallessCleanups.length) {
const dir = heallessCleanups.pop();
if (dir) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* best effort */
}
}
}
});
function makeTmp(prefix = "ctx-heal-partial-"): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
heallessCleanups.push(dir);
return dir;
}
// Build a fake CC plugin layout under a fresh tmp dir:
// <root>/.claude/plugins/cache/context-mode/context-mode/<version>/
// <root>/.claude/plugins/marketplaces/context-mode/
// The marketplace clone gets a realistic package.json files[] pointing
// at a few canned dirs + files so the heal has concrete sources to
// copy. The cache dir starts empty so each test can populate the exact
// subset it wants to simulate.
function buildFakeLayout(version = "1.0.150"): {
pluginRoot: string;
marketplaceClonePath: string;
} {
const root = makeTmp();
const pluginsDir = resolve(root, ".claude", "plugins");
const pluginRoot = resolve(
pluginsDir,
"cache",
"context-mode",
"context-mode",
version,
);
const marketplaceClonePath = resolve(
pluginsDir,
"marketplaces",
"context-mode",
);
mkdirSync(marketplaceClonePath, { recursive: true });
writeFileSync(
resolve(marketplaceClonePath, "package.json"),
JSON.stringify(
{
name: "context-mode",
version,
files: [
"hooks",
".claude-plugin",
"scripts/postinstall.mjs",
"scripts/plugin-cache-integrity.mjs",
"cli.bundle.mjs",
"server.bundle.mjs",
"start.mjs",
"bin",
],
},
null,
2,
),
);
writeFileSync(resolve(marketplaceClonePath, "start.mjs"), "// start\n");
writeFileSync(
resolve(marketplaceClonePath, "cli.bundle.mjs"),
"// cli bundle\n",
);
writeFileSync(
resolve(marketplaceClonePath, "server.bundle.mjs"),
"// server bundle\n",
);
mkdirSync(resolve(marketplaceClonePath, "hooks"), { recursive: true });
writeFileSync(
resolve(marketplaceClonePath, "hooks", "sessionstart.mjs"),
"// hook\n",
);
mkdirSync(resolve(marketplaceClonePath, ".claude-plugin"), {
recursive: true,
});
writeFileSync(
resolve(marketplaceClonePath, ".claude-plugin", "plugin.json"),
JSON.stringify(
{
name: "context-mode",
version,
mcpServers: {
"context-mode": {
command: "node",
args: ["${CLAUDE_PLUGIN_ROOT}/start.mjs"],
},
},
},
null,
2,
),
);
mkdirSync(resolve(marketplaceClonePath, "scripts"), { recursive: true });
writeFileSync(
resolve(marketplaceClonePath, "scripts", "postinstall.mjs"),
"// postinstall\n",
);
writeFileSync(
resolve(marketplaceClonePath, "scripts", "plugin-cache-integrity.mjs"),
"// integrity\n",
);
mkdirSync(resolve(marketplaceClonePath, "bin"), { recursive: true });
writeFileSync(
resolve(marketplaceClonePath, "bin", "statusline.mjs"),
"// statusline\n",
);
mkdirSync(pluginRoot, { recursive: true });
return { pluginRoot, marketplaceClonePath };
}
function readJson(path: string): unknown {
return JSON.parse(readFileSync(path, "utf-8"));
}
// ── isPartialInstall cheap probe ──
it("isPartialInstall returns false for a healthy install", async () => {
const { isPartialInstall } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
cpSync(marketplaceClonePath, pluginRoot, { recursive: true, force: true });
expect(isPartialInstall(pluginRoot)).toBe(false);
});
it("isPartialInstall returns true when start.mjs is missing", async () => {
const { isPartialInstall } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
cpSync(marketplaceClonePath, pluginRoot, { recursive: true, force: true });
rmSync(join(pluginRoot, "start.mjs"));
expect(isPartialInstall(pluginRoot)).toBe(true);
});
it("isPartialInstall returns true when package.json is missing", async () => {
const { isPartialInstall } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
cpSync(marketplaceClonePath, pluginRoot, { recursive: true, force: true });
rmSync(join(pluginRoot, "package.json"));
expect(isPartialInstall(pluginRoot)).toBe(true);
});
it("isPartialInstall returns true when both cli.bundle.mjs and build/cli.js are missing", async () => {
const { isPartialInstall } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
cpSync(marketplaceClonePath, pluginRoot, { recursive: true, force: true });
rmSync(join(pluginRoot, "cli.bundle.mjs"));
expect(isPartialInstall(pluginRoot)).toBe(true);
});
it("isPartialInstall accepts build/cli.js as the cli fallback", async () => {
const { isPartialInstall } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
cpSync(marketplaceClonePath, pluginRoot, { recursive: true, force: true });
rmSync(join(pluginRoot, "cli.bundle.mjs"));
rmSync(join(pluginRoot, "server.bundle.mjs"));
mkdirSync(join(pluginRoot, "build"), { recursive: true });
writeFileSync(join(pluginRoot, "build", "cli.js"), "// tsc output\n");
writeFileSync(join(pluginRoot, "build", "server.js"), "// tsc output\n");
expect(isPartialInstall(pluginRoot)).toBe(false);
});
it("isPartialInstall returns false on falsy pluginRoot", async () => {
const { isPartialInstall } = await import(
"../../hooks/heal-partial-install.mjs"
);
expect(isPartialInstall(undefined)).toBe(false);
expect(isPartialInstall(null)).toBe(false);
expect(isPartialInstall("")).toBe(false);
});
// ── deriveMarketplaceClonePath layout helper ──
it("deriveMarketplaceClonePath derives the marketplace path from a CC cache layout", async () => {
const { deriveMarketplaceClonePath } = await import(
"../../hooks/heal-partial-install.mjs"
);
const cache = "/home/u/.claude/plugins/cache/context-mode/context-mode/1.0.150";
expect(deriveMarketplaceClonePath(cache)).toBe(
resolve("/home/u/.claude/plugins/marketplaces/context-mode"),
);
});
it("deriveMarketplaceClonePath tolerates a trailing slash", async () => {
const { deriveMarketplaceClonePath } = await import(
"../../hooks/heal-partial-install.mjs"
);
const cache = "/home/u/.claude/plugins/cache/context-mode/context-mode/1.0.150/";
expect(deriveMarketplaceClonePath(cache)).toBe(
resolve("/home/u/.claude/plugins/marketplaces/context-mode"),
);
});
it("deriveMarketplaceClonePath handles Windows-style backslashes", async () => {
const { deriveMarketplaceClonePath } = await import(
"../../hooks/heal-partial-install.mjs"
);
const cache = "C:\\Users\\u\\.claude\\plugins\\cache\\context-mode\\context-mode\\1.0.150";
const got = deriveMarketplaceClonePath(cache);
// resolve() leaves the leading drive letter intact as a relative
// segment on POSIX; the important assertion is the suffix.
expect(String(got).replace(/\\/g, "/")).toMatch(
/plugins\/marketplaces\/context-mode$/,
);
});
it("deriveMarketplaceClonePath returns null for non-CC layouts", async () => {
const { deriveMarketplaceClonePath } = await import(
"../../hooks/heal-partial-install.mjs"
);
expect(
deriveMarketplaceClonePath("/usr/local/lib/node_modules/context-mode"),
).toBeNull();
expect(
deriveMarketplaceClonePath("/home/u/sultan-projects/context-mode"),
).toBeNull();
expect(deriveMarketplaceClonePath(undefined)).toBeNull();
expect(deriveMarketplaceClonePath(null)).toBeNull();
expect(deriveMarketplaceClonePath("")).toBeNull();
});
// ── healPartialInstallFromMarketplace full heal ──
it("healPartialInstallFromMarketplace early-returns on a healthy install", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
cpSync(marketplaceClonePath, pluginRoot, { recursive: true, force: true });
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(result.skipped).toBe("not-partial");
expect(result.healed).toEqual([]);
});
it("healPartialInstallFromMarketplace skips when the marketplace clone is missing", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot } = buildFakeLayout();
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath: resolve(pluginRoot, "..", "..", "..", "no-such-mp"),
log: false,
});
expect(result.skipped).toBe("no-marketplace");
});
it("healPartialInstallFromMarketplace refuses to heal when pluginRoot equals marketplaceClonePath", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { marketplaceClonePath } = buildFakeLayout();
// Force the probe to trip by stripping start.mjs from the
// marketplace itself, then point pluginRoot at it.
rmSync(join(marketplaceClonePath, "start.mjs"));
const result = healPartialInstallFromMarketplace({
pluginRoot: marketplaceClonePath,
marketplaceClonePath,
log: false,
});
expect(result.skipped).toBe("same-as-marketplace");
});
it("healPartialInstallFromMarketplace copies missing files from the marketplace clone", async () => {
const { healPartialInstallFromMarketplace, isPartialInstall } =
await import("../../hooks/heal-partial-install.mjs");
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
// Replicate the user-observed partial install: hooks/ and
// .claude-plugin/ made it across, the rest didn't.
cpSync(
join(marketplaceClonePath, "hooks"),
join(pluginRoot, "hooks"),
{ recursive: true, force: true },
);
cpSync(
join(marketplaceClonePath, ".claude-plugin"),
join(pluginRoot, ".claude-plugin"),
{ recursive: true, force: true },
);
expect(isPartialInstall(pluginRoot)).toBe(true);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(result.skipped).toBeUndefined();
expect(result.stillMissing).toEqual([]);
expect(result.healed).toContain("start.mjs");
expect(result.healed).toContain("cli.bundle.mjs");
expect(result.healed).toContain("server.bundle.mjs");
expect(result.healed).toContain("package.json");
expect(isPartialInstall(pluginRoot)).toBe(false);
});
it("healPartialInstallFromMarketplace falls back to the marketplace's package.json when pluginRoot's is missing", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
// Empty pluginRoot, no package.json.
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(result.skipped).toBeUndefined();
expect(result.pkgSource).toBe("marketplace");
expect(result.healed).toContain("package.json");
});
it("healPartialInstallFromMarketplace rewrites carry-forward stale args[0] in plugin.json", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout("1.0.150");
// Seed pluginRoot with the user-observed carry-forward state:
// plugin.json copied from 1.0.146 with bun + an absolute args[0].
cpSync(
join(marketplaceClonePath, "hooks"),
join(pluginRoot, "hooks"),
{ recursive: true, force: true },
);
mkdirSync(join(pluginRoot, ".claude-plugin"), { recursive: true });
writeFileSync(
join(pluginRoot, ".claude-plugin", "plugin.json"),
JSON.stringify(
{
name: "context-mode",
version: "1.0.150",
mcpServers: {
"context-mode": {
command: "/usr/bin/bun",
args: [
"/home/u/.claude/plugins/cache/context-mode/context-mode/1.0.146/start.mjs",
],
},
},
},
null,
2,
),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(result.argsRewritten).toBe(true);
const fixed = readJson(
join(pluginRoot, ".claude-plugin", "plugin.json"),
) as {
mcpServers: { "context-mode": { args: string[] } };
};
expect(fixed.mcpServers["context-mode"].args[0]).toContain("1.0.150");
expect(fixed.mcpServers["context-mode"].args[0]).not.toContain("1.0.146");
});
it.skipIf(process.platform === "win32")("healPartialInstallFromMarketplace refuses to rewrite plugin.json when it's a symlink", async () => {
// Defense-in-depth flagged by round-4 adversarial review: a
// same-user-planted symlink at .claude-plugin/plugin.json would
// otherwise feed attacker JSON into readFileSync, and the
// atomic writeFileSync+renameSync below would replace the
// symlink with a regular file containing attacker mcpServers
// config. POSIX 0700 on ~/.claude scopes this to same-user
// threats, but the heal mirrors the source/destination
// symlink refusals applied elsewhere.
//
// Skipped on Windows: the defense uses openSync(O_NOFOLLOW),
// and libuv silently drops O_NOFOLLOW on Windows (the constant
// exists for portability but maps to no underlying NT flag).
// Real-world Windows users typically don't have Developer Mode
// enabled and so can't plant the symlink in the first place,
// but on CI runners (which DO have Developer Mode) the
// production defense degrades to no-op and the test would
// fail. Re-enable when Node ships a NoOpenReparsePoint
// translation in libuv (tracked nodejs/node#XYZ — TODO).
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout("1.0.150");
// Stage attacker-controlled mcpServers JSON outside the cache
// tree. The args[0] points at a since-deleted version dir so a
// naive rewrite (the carry-forward stale-version code path)
// would mutate it AND write the result into the canonical
// plugin.json location, materializing the attacker config.
const attackerRoot = mkdtempSync(join(tmpdir(), "plugin-json-attack-"));
const attackerJsonPath = join(attackerRoot, "plugin.json");
writeFileSync(
attackerJsonPath,
JSON.stringify({
name: "context-mode",
version: "1.0.150",
mcpServers: {
"context-mode": {
command: "/attacker/payload.sh",
args: [
"/home/u/.claude/plugins/cache/context-mode/context-mode/1.0.146/start.mjs",
],
},
},
}),
);
// Plant the symlink at the canonical location. readFileSync
// would follow this; the lstatSync guard must refuse.
mkdirSync(join(pluginRoot, ".claude-plugin"), { recursive: true });
symlinkSync(
attackerJsonPath,
join(pluginRoot, ".claude-plugin", "plugin.json"),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
// The rewrite was refused.
expect(result.argsRewritten).toBe(false);
// plugin.json on disk is still a symlink; it was not replaced
// by a regular file with attacker mcpServers.
const stPj = lstatSync(
join(pluginRoot, ".claude-plugin", "plugin.json"),
);
expect(stPj.isSymbolicLink()).toBe(true);
// The attacker JSON at the symlink target was not modified
// either (renameSync would have left it intact even if the
// guard had failed, but assert anyway as a tripwire for any
// future implementation that decides to write through the
// symlink path).
const attackerStillThere = JSON.parse(
readFileSync(attackerJsonPath, "utf-8"),
);
expect(attackerStillThere.mcpServers["context-mode"].command).toBe(
"/attacker/payload.sh",
);
});
it("healPartialInstallFromMarketplace resolves the ${CLAUDE_PLUGIN_ROOT} placeholder", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout("1.0.150");
mkdirSync(join(pluginRoot, ".claude-plugin"), { recursive: true });
writeFileSync(
join(pluginRoot, ".claude-plugin", "plugin.json"),
JSON.stringify(
{
name: "context-mode",
version: "1.0.150",
mcpServers: {
"context-mode": {
command: "node",
args: ["${CLAUDE_PLUGIN_ROOT}/start.mjs"],
},
},
},
null,
2,
),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(result.argsRewritten).toBe(true);
const fixed = readJson(
join(pluginRoot, ".claude-plugin", "plugin.json"),
) as {
mcpServers: { "context-mode": { args: string[] } };
};
expect(fixed.mcpServers["context-mode"].args[0]).not.toContain(
"${CLAUDE_PLUGIN_ROOT}",
);
expect(fixed.mcpServers["context-mode"].args[0]).toContain("start.mjs");
});
it("healPartialInstallFromMarketplace leaves a healthy plugin.json alone", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout("1.0.150");
// Seed a healthy plugin.json directly: no placeholder, args[0] is
// an absolute path under the current pluginRoot. The rewrite path
// should all branches decline to mutate.
mkdirSync(join(pluginRoot, ".claude-plugin"), { recursive: true });
const healthyArgs0 = join(pluginRoot, "start.mjs").replace(/\\/g, "/");
writeFileSync(
join(pluginRoot, ".claude-plugin", "plugin.json"),
JSON.stringify(
{
name: "context-mode",
version: "1.0.150",
mcpServers: {
"context-mode": {
command: "node",
args: [healthyArgs0],
},
},
},
null,
2,
),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(result.healed).toContain("start.mjs");
expect(result.argsRewritten).toBe(false);
});
it("healPartialInstallFromMarketplace is idempotent across re-runs", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
const first = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(first.healed.length).toBeGreaterThan(0);
const second = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(second.skipped).toBe("not-partial");
expect(second.healed).toEqual([]);
});
it("healPartialInstallFromMarketplace short-circuits with not-claude-code for non-CC pluginRoots", async () => {
// The heal is scoped to Claude Code's per-version cache layout
// (~/.claude/plugins/cache/<owner>/<plugin>/<version>/). Other
// clients (Codex, Cursor, OpenCode, Kiro, gemini-cli, ...) ship
// their own SessionStart wrappers under hooks/<client>/ and
// never call this module. The module also guards its scope at
// runtime: any pluginRoot that doesn't match the CC cache layout
// bails with skipped="not-claude-code" before any filesystem
// work runs.
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
// npm-global-style layout: no /plugins/cache/ segment.
const npmGlobalRoot = makeTmp();
const npmGlobalPlugin = resolve(
npmGlobalRoot,
"lib",
"node_modules",
"context-mode",
);
mkdirSync(npmGlobalPlugin, { recursive: true });
const r1 = healPartialInstallFromMarketplace({
pluginRoot: npmGlobalPlugin,
log: false,
});
expect(r1.skipped).toBe("not-claude-code");
expect(r1.healed).toEqual([]);
expect(r1.stillMissing).toEqual([]);
// Codex-style layout: under ~/.codex, no /plugins/cache/.
const codexRoot = makeTmp();
const codexPlugin = resolve(
codexRoot,
".codex",
"plugins",
"context-mode",
);
mkdirSync(codexPlugin, { recursive: true });
const r2 = healPartialInstallFromMarketplace({
pluginRoot: codexPlugin,
log: false,
});
expect(r2.skipped).toBe("not-claude-code");
});
it("healPartialInstallFromMarketplace doesn't overwrite files that already exist", async () => {
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
// Seed pluginRoot with hooks/sessionstart.mjs holding a custom
// override. The heal only restores missing paths, so the override
// should survive.
mkdirSync(join(pluginRoot, "hooks"), { recursive: true });
const custom = "// custom user override\n";
writeFileSync(join(pluginRoot, "hooks", "sessionstart.mjs"), custom);
healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(
readFileSync(join(pluginRoot, "hooks", "sessionstart.mjs"), "utf-8"),
).toBe(custom);
});
it("healPartialInstallFromMarketplace rejects files[] entries that escape rootDir via ..", async () => {
// Regression guard for PR #699 review: a corrupted marketplace
// package.json with `files: ["../outside.txt", ...]` must not
// turn the self-heal into an out-of-root write. Seed an
// `outside.txt` in the marketplace's parent dir, point a files[]
// entry at it via ..-escape, and assert nothing lands outside
// pluginRoot.
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
// Write a sentinel file in the marketplace's parent dir — the
// ..-escape target. If the guard fails, the heal would copy this
// into pluginRoot's parent.
const marketplaceParent = dirname(marketplaceClonePath);
const escapeTargetPath = join(marketplaceParent, "OUTSIDE-MARKER.txt");
writeFileSync(escapeTargetPath, "should-not-be-copied\n");
// Repoint marketplace package.json's files[] to include an
// escape entry alongside legitimate entries.
writeFileSync(
join(marketplaceClonePath, "package.json"),
JSON.stringify({
name: "context-mode",
version: "1.0.150",
files: ["../OUTSIDE-MARKER.txt", "hooks", "start.mjs", "cli.bundle.mjs", "server.bundle.mjs", ".claude-plugin"],
}),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
// The escape entry must not appear anywhere the heal acted on.
expect(result.healed).not.toContain("../OUTSIDE-MARKER.txt");
expect(result.healed).not.toContain("OUTSIDE-MARKER.txt");
expect(result.stillMissing).not.toContain("../OUTSIDE-MARKER.txt");
// Nothing got written to pluginRoot's parent.
const pluginRootParent = dirname(pluginRoot);
expect(existsSync(join(pluginRootParent, "OUTSIDE-MARKER.txt"))).toBe(false);
// The legitimate entries still healed.
expect(result.healed).toContain("start.mjs");
expect(result.healed).toContain("cli.bundle.mjs");
});
it("healPartialInstallFromMarketplace rejects nested ..-escape entries (e.g. 'a/../../outside')", async () => {
// Mixed-segment escape: even when the entry starts with a
// legitimate-looking segment, path normalization collapses the
// ..'s and resolves outside rootDir. Guard must catch it.
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
const marketplaceParent = dirname(marketplaceClonePath);
const escapeTargetPath = join(marketplaceParent, "NESTED-OUTSIDE.txt");
writeFileSync(escapeTargetPath, "should-not-be-copied\n");
writeFileSync(
join(marketplaceClonePath, "package.json"),
JSON.stringify({
name: "context-mode",
version: "1.0.150",
files: ["hooks/../../NESTED-OUTSIDE.txt", "start.mjs"],
}),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
expect(result.healed.some((p: string) => p.includes("NESTED-OUTSIDE"))).toBe(false);
const pluginRootParent = dirname(pluginRoot);
expect(existsSync(join(pluginRootParent, "NESTED-OUTSIDE.txt"))).toBe(false);
expect(result.healed).toContain("start.mjs");
});
it("healPartialInstallFromMarketplace replaces a planted leaf symlink at the destination instead of writing through it", async () => {
// Arbitrary-file-write primitive surfaced by the second
// adversarial review: a stale heal leftover or a local attacker
// plants `pluginRoot/server.bundle.mjs` as a symlink to an
// out-of-tree target that doesn't exist yet. existsSync follows
// the dangling link and returns false, so the probe trips and
// the file lands in missingBefore. Without the lstat+unlink
// guard at the copy site, cpSync follows the symlink and writes
// marketplace bytes (arbitrary JS) to the target outside
// pluginRoot. The guard must replace the symlink with a fresh
// regular file before cpSync runs.
const { healPartialInstallFromMarketplace, isPartialInstall } =
await import("../../hooks/heal-partial-install.mjs");
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
// Dangling target outside pluginRoot, must stay un-created.
const pluginRootParent = dirname(pluginRoot);
const escapeTargetPath = join(
pluginRootParent,
"OUT-OF-ROOT-MUST-NOT-EXIST.txt",
);
expect(existsSync(escapeTargetPath)).toBe(false);
// Plant the symlink at the launch-critical leaf so the probe
// trips on it (existsSync on a dangling link returns false).
symlinkSync(escapeTargetPath, join(pluginRoot, "server.bundle.mjs"));
expect(
lstatSync(join(pluginRoot, "server.bundle.mjs")).isSymbolicLink(),
).toBe(true);
expect(isPartialInstall(pluginRoot)).toBe(true);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
// The symlink target must not have been materialized: no
// arbitrary write happened outside pluginRoot.
expect(existsSync(escapeTargetPath)).toBe(false);
// The leaf is now a regular file with marketplace bytes, not a
// dangling symlink.
const stTo = lstatSync(join(pluginRoot, "server.bundle.mjs"));
expect(stTo.isSymbolicLink()).toBe(false);
expect(stTo.isFile()).toBe(true);
expect(
readFileSync(join(pluginRoot, "server.bundle.mjs"), "utf-8"),
).toBe("// server bundle\n");
// The heal records the file as healed, and the probe agrees
// we're healthy.
expect(result.healed).toContain("server.bundle.mjs");
expect(isPartialInstall(pluginRoot)).toBe(false);
});
it("healPartialInstallFromMarketplace rejects deep files[] entries reached through a symlinked ancestor in the marketplace tree", async () => {
// Ancestor-symlink bypass surfaced by the third adversarial
// review: lstat(from) on a deep leaf returns isSymbolicLink=false
// when the symlink is in a PARENT segment, not the leaf itself.
// The lexical resolve+startsWith guard also doesn't catch it,
// since the symlink's own lexical path stays inside the
// marketplace tree. Only realpath(from) collapses the ancestor
// symlink and exposes the escape. Without this guard, cpSync
// would read attacker-staged content through the ancestor link
// and copy it into pluginRoot, where the next session start
// would execute the planted bytes (e.g. scripts/postinstall.mjs).
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
// Stage attacker-controlled bytes outside the marketplace tree.
const attackerRoot = mkdtempSync(join(tmpdir(), "ancestor-attack-"));
writeFileSync(
join(attackerRoot, "postinstall.mjs"),
"// ATTACKER PAYLOAD\n",
);
// Replace marketplace/scripts/ (a real dir set up by
// buildFakeLayout) with a symlink to the attacker-staged dir.
// The lexical resolve check on `from` passes because the
// symlink's path stays inside marketplaceClonePath; only
// realpath collapses through it.
rmSync(join(marketplaceClonePath, "scripts"), {
recursive: true,
force: true,
});
symlinkSync(attackerRoot, join(marketplaceClonePath, "scripts"));
// package.json references the deep leaf through the
// now-symlinked ancestor. lstat(from).isSymbolicLink() === false
// because the leaf is a real file at the symlink target.
writeFileSync(
join(marketplaceClonePath, "package.json"),
JSON.stringify({
name: "context-mode",
version: "1.0.150",
files: [
"scripts/postinstall.mjs",
"hooks",
".claude-plugin",
"start.mjs",
"cli.bundle.mjs",
"server.bundle.mjs",
],
}),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
// The ancestor-symlinked entry must not appear in healed, and
// pluginRoot must not contain the attacker payload at the
// resolved-through-ancestor path.
expect(result.healed).not.toContain("scripts/postinstall.mjs");
const planted = join(pluginRoot, "scripts", "postinstall.mjs");
if (existsSync(planted)) {
expect(readFileSync(planted, "utf-8")).not.toBe(
"// ATTACKER PAYLOAD\n",
);
}
// Legitimate non-symlinked entries still healed (sanity).
expect(result.healed).toContain("start.mjs");
expect(result.healed).toContain("cli.bundle.mjs");
});
it("healPartialInstallFromMarketplace rejects symlink entries that escape rootDir", async () => {
// Symlink-based bypass for the ..-escape resolve+startsWith
// guard: the symlink itself sits inside marketplaceClonePath, so
// its lexical resolve stays inside rootDir, but the symlink
// target points outside. Reading through the link would expose
// arbitrary host filesystem content to the heal. expandFilesArray
// and listFilesRecursive must drop symlinks via lstatSync.
const { healPartialInstallFromMarketplace } = await import(
"../../hooks/heal-partial-install.mjs"
);
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
// Sentinel outside the marketplace tree.
const marketplaceParent = dirname(marketplaceClonePath);
const sentinelPath = join(marketplaceParent, "SYMLINK-TARGET.txt");
writeFileSync(sentinelPath, "should-not-leak\n");
// Top-level symlink-to-outside file at marketplace root.
const topLink = join(marketplaceClonePath, "evil-link.txt");
symlinkSync(sentinelPath, topLink);
// Symlink-to-outside hidden inside a legitimate-looking dir,
// so listFilesRecursive's walk has to drop it too.
const nestedDir = join(marketplaceClonePath, "scripts");
const nestedLink = join(nestedDir, "evil-nested.txt");
symlinkSync(sentinelPath, nestedLink);
writeFileSync(
join(marketplaceClonePath, "package.json"),
JSON.stringify({
name: "context-mode",
version: "1.0.150",
// "scripts" is a directory walk; "evil-link.txt" is a
// top-level symlink entry. Both should be filtered.
files: [
"evil-link.txt",
"scripts",
"hooks",
"start.mjs",
"cli.bundle.mjs",
"server.bundle.mjs",
".claude-plugin",
],
}),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
// Neither the top-level nor the nested symlink can appear in
// healed; nothing got materialized at pluginRoot for them either.
expect(result.healed).not.toContain("evil-link.txt");
expect(result.healed.some((p: string) => p.includes("evil-nested"))).toBe(false);
expect(existsSync(join(pluginRoot, "evil-link.txt"))).toBe(false);
expect(existsSync(join(pluginRoot, "scripts", "evil-nested.txt"))).toBe(false);
// Legitimate scripts entries still healed (the symlink dropped
// out, but the regular files in scripts/ stayed). Use join() so
// Windows backslash and POSIX forward-slash both match the
// OS-native paths listFilesRecursive emits.
expect(result.healed).toContain(join("scripts", "postinstall.mjs"));
expect(result.healed).toContain(join("scripts", "plugin-cache-integrity.mjs"));
// Sentinel content was never copied anywhere in pluginRoot.
const pluginRootParent = dirname(pluginRoot);
expect(existsSync(join(pluginRootParent, "SYMLINK-TARGET.txt"))).toBe(false);
});
it("healPartialInstallFromMarketplace keeps valid entries when escape entries are mixed in", async () => {
// The escape guard must drop only the bad entries, leaving valid
// ones in the manifest. Otherwise a single corrupt entry would
// poison the whole heal.
const { healPartialInstallFromMarketplace, isPartialInstall } =
await import("../../hooks/heal-partial-install.mjs");
const { pluginRoot, marketplaceClonePath } = buildFakeLayout();
const marketplaceParent = dirname(marketplaceClonePath);
writeFileSync(join(marketplaceParent, "POISON.txt"), "x");
writeFileSync(
join(marketplaceClonePath, "package.json"),
JSON.stringify({
name: "context-mode",
version: "1.0.150",
files: [
"../POISON.txt",
"hooks",
".claude-plugin",
"start.mjs",
"cli.bundle.mjs",
"server.bundle.mjs",
"scripts/postinstall.mjs",
"scripts/plugin-cache-integrity.mjs",
"bin",
],
}),
);
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
// Every valid entry healed.
expect(result.healed).toContain("start.mjs");
expect(result.healed).toContain("cli.bundle.mjs");
expect(result.healed).toContain("server.bundle.mjs");
// No escape side effect.
const pluginRootParent = dirname(pluginRoot);
expect(existsSync(join(pluginRootParent, "POISON.txt"))).toBe(false);
// The probe agrees we're healthy after the heal (the legitimate
// launch-critical files all landed).
expect(isPartialInstall(pluginRoot)).toBe(false);
});
it("healPartialInstallFromMarketplace reproduces and heals the v1.0.150 partial install", async () => {
const { healPartialInstallFromMarketplace, isPartialInstall } =
await import("../../hooks/heal-partial-install.mjs");
// Mirrors the exact failure shape captured in the wild:
// - hooks/ and .claude-plugin/ survived
// - plus a few non-files[] extras (web/, CONTRIBUTING.md) from an
// earlier install moment
// - .claude-plugin/plugin.json carry-forwarded from 1.0.146 with
// command "/usr/bin/bun" and args[0] pointing at the stale
// 1.0.146/start.mjs absolute path
// - everything else missing
const { pluginRoot, marketplaceClonePath } = buildFakeLayout("1.0.150");
cpSync(
join(marketplaceClonePath, "hooks"),
join(pluginRoot, "hooks"),
{ recursive: true, force: true },
);
mkdirSync(join(pluginRoot, ".claude-plugin"), { recursive: true });
writeFileSync(
join(pluginRoot, ".claude-plugin", "plugin.json"),
JSON.stringify(
{
name: "context-mode",
version: "1.0.150",
mcpServers: {
"context-mode": {
command: "/usr/bin/bun",
args: [
pluginRoot.replace("1.0.150", "1.0.146") + sep + "start.mjs",
],
},
},
},
null,
2,
),
);
writeFileSync(join(pluginRoot, "CONTRIBUTING.md"), "# Contributing\n");
mkdirSync(join(pluginRoot, "web"), { recursive: true });
writeFileSync(join(pluginRoot, "web", "index.html"), "<!doctype html>");
const result = healPartialInstallFromMarketplace({
pluginRoot,
marketplaceClonePath,
log: false,
});
// Full recovery: every files[] entry restored, args[0] re-pointed
// at 1.0.150, and the cheap probe agrees we're healthy.
expect(result.stillMissing).toEqual([]);
expect(result.argsRewritten).toBe(true);
expect(isPartialInstall(pluginRoot)).toBe(false);
const plugin = readJson(
join(pluginRoot, ".claude-plugin", "plugin.json"),
) as {
mcpServers: { "context-mode": { args: string[] } };
};
expect(plugin.mcpServers["context-mode"].args[0]).toContain("1.0.150");
expect(plugin.mcpServers["context-mode"].args[0]).not.toContain("1.0.146");
// Non-files[] extras left alone.
expect(
readFileSync(join(pluginRoot, "CONTRIBUTING.md"), "utf-8"),
).toBe("# Contributing\n");
});
// ── Wiring assertions ──
it("start.mjs invokes the heal before the Algo-D4 integrity check", () => {
const src = readFileSync(resolve(ROOT, "start.mjs"), "utf-8");
// Anchor on the literal import + call shape, not the bare filename
// substring. A DELETED- prefix or comment mention would otherwise
// pass the test even when the wiring is gone.
const importIdx = src.indexOf('"./hooks/heal-partial-install.mjs"');
const callIdx = src.indexOf("healPartialInstallFromMarketplace({");
const integrityIdx = src.indexOf('"./scripts/plugin-cache-integrity.mjs"');
expect(importIdx, "heal-partial-install import missing in start.mjs").toBeGreaterThan(-1);
expect(callIdx, "healPartialInstallFromMarketplace call missing in start.mjs").toBeGreaterThan(-1);
expect(integrityIdx, "plugin-cache-integrity import missing in start.mjs").toBeGreaterThan(-1);
// Order: heal runs first so a fixable install is repaired, then
// the integrity gate decides whether boot proceeds.
expect(importIdx).toBeLessThan(integrityIdx);
expect(callIdx).toBeLessThan(integrityIdx);
});
it("hooks/sessionstart.mjs invokes the heal early in the runHook callback", () => {
const src = readFileSync(
resolve(ROOT, "hooks", "sessionstart.mjs"),
"utf-8",
);
const importIdx = src.indexOf('"./heal-partial-install.mjs"');
const callIdx = src.indexOf("healPartialInstallFromMarketplace()");
expect(importIdx, "heal-partial-install import missing in sessionstart.mjs").toBeGreaterThan(-1);
expect(callIdx, "healPartialInstallFromMarketplace call missing in sessionstart.mjs").toBeGreaterThan(-1);
// Heal must fire before the age-gated cleanup that wipes sibling
// cache version dirs, since the cleanup would otherwise erase a
// healthy previous version while the new one is still partial.
const cleanupIdx = src.indexOf("Age-gated lazy cleanup");
expect(cleanupIdx, "age-gated cleanup comment missing").toBeGreaterThan(-1);
expect(callIdx).toBeLessThan(cleanupIdx);
});
});
});
// ── session-loaders.mjs fallback ──────────────────────────────────────
describe("session-loaders.mjs fallback to build/session/*.js", () => {
test("session-loaders.mjs has loadModule fallback function", () => {
const src = readFileSync(resolve(ROOT, "hooks", "session-loaders.mjs"), "utf-8");
// Must have a loadModule helper that checks existsSync
expect(src).toContain("loadModule");
expect(src).toContain("existsSync");
});
test("session-loaders.mjs falls back to build/session/*.js paths", () => {
const src = readFileSync(resolve(ROOT, "hooks", "session-loaders.mjs"), "utf-8");
// Must reference the build/session fallback directory
expect(src).toContain("build");
expect(src).toContain("session");
// Must reference specific build fallback filenames
expect(src).toContain("db.js");
expect(src).toContain("extract.js");
expect(src).toContain("snapshot.js");
});
test("session-loaders.mjs still tries bundles first", () => {
const src = readFileSync(resolve(ROOT, "hooks", "session-loaders.mjs"), "utf-8");
// Bundle names must still be present
expect(src).toContain("session-db.bundle.mjs");
expect(src).toContain("session-extract.bundle.mjs");
expect(src).toContain("session-snapshot.bundle.mjs");
});
test("session-loaders.mjs imports existsSync", () => {
const src = readFileSync(resolve(ROOT, "hooks", "session-loaders.mjs"), "utf-8");
expect(src).toMatch(/import\s*\{[^}]*existsSync[^}]*\}\s*from\s*["']node:fs["']/);
});
});
// ── SKILL.md MCP-first pattern ────────────────────────────────────────
describe("SKILL.md prefers MCP tool over Bash", () => {
it("ctx-doctor SKILL.md prefers MCP tool over Bash", () => {
const skill = readFileSync(resolve(ROOT, "skills", "ctx-doctor", "SKILL.md"), "utf-8");
// Must mention the MCP tool
expect(skill).toContain("ctx_doctor");
expect(skill).toContain("MCP tool");
// MCP tool instruction must appear BEFORE the Bash fallback
const mcpIdx = skill.indexOf("ctx_doctor");
const fallbackIdx = skill.indexOf("Fallback");
expect(mcpIdx).toBeGreaterThan(-1);
expect(fallbackIdx).toBeGreaterThan(-1);
expect(mcpIdx).toBeLessThan(fallbackIdx);
});
it("ctx-upgrade SKILL.md prefers MCP tool over Bash", () => {
const skill = readFileSync(resolve(ROOT, "skills", "ctx-upgrade", "SKILL.md"), "utf-8");
// Must mention the MCP tool
expect(skill).toContain("ctx_upgrade");
expect(skill).toContain("MCP tool");
// MCP tool instruction must appear BEFORE the Bash fallback
const mcpIdx = skill.indexOf("ctx_upgrade");
const fallbackIdx = skill.indexOf("Fallback");
expect(mcpIdx).toBeGreaterThan(-1);
expect(fallbackIdx).toBeGreaterThan(-1);
expect(mcpIdx).toBeLessThan(fallbackIdx);
});
});
// ── Package exports ───────────────────────────────────────────────────
describe("Package exports", () => {
test("named export exposes ContextModePlugin factory", async () => {
const mod = await import("../../src/adapters/opencode/plugin.js");
expect(mod.ContextModePlugin).toBeDefined();
expect(typeof mod.ContextModePlugin).toBe("function");
});
test("default export has KiloCode PluginModule shape { server }", async () => {
const mod = (await import("../../src/adapters/opencode/plugin.js")) as any;
expect(mod.default).toBeDefined();
expect(typeof mod.default.server).toBe("function");
});
test("default export does not leak CLI internals", async () => {
const mod = (await import("../../src/adapters/opencode/plugin.js")) as any;
expect(mod.toUnixPath).toBeUndefined();
expect(mod.doctor).toBeUndefined();
expect(mod.upgrade).toBeUndefined();
});
});
// ── Issue #181: upgrade must not delete sibling version dirs mid-session ──
describe("Cache dir safety (#181)", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src/cli.ts"), "utf-8");
const PRETOOLUSE_SOURCE = readFileSync(resolve(ROOT, "hooks/pretooluse.mjs"), "utf-8");
test("cli.ts upgrade does not rmSync sibling cache version dirs", () => {
// The upgrade function must NOT contain a loop that deletes sibling version dirs.
// Old pattern: filter dirs !== myDir → rmSync each in a loop
const hasStaleCleanup = CLI_SOURCE.includes("stale cache dir");
expect(hasStaleCleanup).toBe(false);
});
test("pretooluse.mjs does not nuke stale version dirs", () => {
// Step 4 "Nuke stale version dirs" must not exist
const hasNukeBlock = PRETOOLUSE_SOURCE.includes("Nuke stale version dirs");
expect(hasNukeBlock).toBe(false);
});
test("sessionstart.mjs has age-gated lazy cleanup for old cache dirs", () => {
const SESSION_SOURCE = readFileSync(resolve(ROOT, "hooks/sessionstart.mjs"), "utf-8");
// Must contain age-gated cleanup logic (>1 hour check)
expect(SESSION_SOURCE).toContain("lazy cleanup");
expect(SESSION_SOURCE).toContain("3600000"); // 1 hour in ms
});
// #644: statSync follows symlinks → fresh symlinks pointing at stale targets
// were deleted, breaking sessions whose CLAUDE_PLUGIN_ROOT was pinned to one
// of those linked versions. lstatSync evaluates the link's own mtime, so a
// freshly-created symlink survives the gate even when its target is old.
test("sessionstart.mjs cleanup uses lstatSync to age-check entries (#644)", () => {
const SESSION_SOURCE = readFileSync(resolve(ROOT, "hooks/sessionstart.mjs"), "utf-8");
// Isolate the lazy-cleanup block so we don't get false-positives from
// unrelated stat calls elsewhere in the file.
const blockStart = SESSION_SOURCE.indexOf("Age-gated lazy cleanup");
expect(blockStart, "lazy cleanup block must exist").toBeGreaterThan(-1);
const block = SESSION_SOURCE.slice(blockStart, blockStart + 1500);
// The age check MUST use lstatSync (does not follow symlinks). The path may
// be held in a local variable as long as it is derived from cacheParent + d.
expect(block).toMatch(/const\s+oldDir\s*=\s*join\(\s*cacheParent\s*,\s*d\s*\)/);
expect(block).toMatch(/lstatSync\(\s*oldDir\s*\)/);
// The age check MUST NOT use statSync (follows symlinks → wrongly evaluates
// the link target's mtime, causing fresh symlinks to be deleted).
expect(block).not.toMatch(/(^|[^l])statSync\(\s*(?:join\(\s*cacheParent\s*,\s*d\s*\)|oldDir)\s*\)/);
});
});
// ── Issue #185: upgrade must not use execSync (shell) ──
// ── Statusline self-locate: survive plugin-root staleness post-upgrade ──
describe("statuslineForward survives stale getPluginRoot() (post-upgrade)", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src/cli.ts"), "utf-8");
const fnStart = CLI_SOURCE.indexOf("function statuslineForward");
// Slice through end-of-file rather than a fixed-width window so pattern
// lookups stay valid as the function picks up defensive checks.
const fnBody = CLI_SOURCE.slice(fnStart);
test("statuslineForward falls back to the marketplace clone path", () => {
// After ctx-upgrade, the running CLI binary may live in a cache dir that
// sessionstart.mjs (#181) has already cleaned, so getPluginRoot() resolves
// to a directory whose bin/statusline.mjs has been removed. Falling back
// to the marketplace clone (~/.claude/plugins/marketplaces/context-mode)
// keeps the statusline alive — that path is stable across upgrades and is
// now refreshed every /ctx-upgrade per #418.
expect(fnBody).toMatch(/marketplaces[\\/]+["']?\s*,\s*["']?context-mode["']?|"marketplaces"\s*,\s*"context-mode"/);
});
test("statuslineForward also tries installed_plugins.json install path", () => {
// Defence in depth — if the marketplace clone is also missing (manual
// cleanup, custom install), use the path Claude Code actually loads from.
expect(fnBody).toMatch(/installed_plugins\.json/);
});
test("statuslineForward exits silently on total failure (no stderr noise)", () => {
// Statusline writes to CC's status bar. Stderr from this process surfaces
// visibly. When NO candidate exists, exit 0 quietly — the user already
// sees the empty bar, an error message would be redundant noise.
expect(fnBody).toMatch(/process\.exit\(0\)/);
});
});
// ── Statusline staleness fix: server emits a periodic stats heartbeat ──
describe("server emits periodic stats heartbeat (statusline liveness fix)", () => {
const SERVER_SOURCE = readFileSync(resolve(ROOT, "src/server.ts"), "utf-8");
test("server.ts schedules a setInterval that calls persistStats", () => {
// bin/statusline.mjs flags the session as "stale — restart to resume saving"
// when stats.updated_at is >30min old. Pre-fix, updated_at only advanced on
// MCP tool calls — long Bash/Read/Edit stretches (or post-/compact pauses)
// would falsely flip the statusline to red even though the MCP server was
// alive. Server must refresh the stats file on a timer regardless of tool
// activity so updated_at reflects true server liveness.
expect(SERVER_SOURCE).toMatch(/setInterval\([\s\S]*?persistStats[\s\S]*?,\s*\d/);
});
test("heartbeat interval is shorter than the statusline staleness threshold", () => {
// statusline threshold: 30min (bin/statusline.mjs:272). Heartbeat must fire
// well below this — allow up to 5min so the file refresh is frequent enough
// that one missed tick (sleep, host pause) still keeps us below the cliff.
const m = SERVER_SOURCE.match(/setInterval\(\s*\(\)\s*=>\s*persistStats\(\)\s*,\s*(\d[\d_]*)\s*\)/);
expect(m, "Expected `setInterval(() => persistStats(), <ms>)` in server.ts").not.toBeNull();
const ms = Number(m![1].replace(/_/g, ""));
expect(ms).toBeGreaterThanOrEqual(10_000); // not absurdly chatty
expect(ms).toBeLessThanOrEqual(5 * 60_000); // safely under the 30min cliff
});
test("heartbeat setInterval is .unref()ed so it does not block process exit", () => {
// Matches the pattern of the existing version-check interval (~3115).
expect(SERVER_SOURCE).toMatch(/setInterval\(\s*\(\)\s*=>\s*persistStats\(\)[^.]*\.unref\(\)/s);
});
});
// ── Issue #418: ctx-upgrade must refresh the marketplace clone ──────
describe("ctx-upgrade syncs marketplace clone (#418)", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src/cli.ts"), "utf-8");
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
const upgradeBody = CLI_SOURCE.slice(upgradeStart);
test("upgrade() targets ~/.claude/plugins/marketplaces/context-mode for git refresh", () => {
// CC's marketplace clone (separate from cache dir) was previously left
// pinned at the install-time commit; users running /ctx-upgrade never
// received upstream changes through the marketplace metadata path.
expect(upgradeBody).toMatch(/marketplaces[\\/]+["']?\s*,\s*["']?context-mode["']?|"marketplaces"\s*,\s*"context-mode"/);
});
test("upgrade() runs git fetch + reset against marketplace dir", () => {
// Required: actual git invocation against the marketplace path.
// Look for fetch and reset --hard in the upgrade body.
expect(upgradeBody).toMatch(/execFileSync\(\s*["']git["']\s*,\s*\[\s*["']-C["']/);
expect(upgradeBody).toMatch(/["']fetch["']/);
expect(upgradeBody).toMatch(/["']reset["'].*?["']--hard["']/s);
});
test("upgrade() guards on .git existence so tarball installs do not fail", () => {
// Defensive guard — non-git install paths must not error out.
expect(upgradeBody).toMatch(/existsSync\([^)]*marketplaceDir[^)]*\.git/);
});
test("upgrade() preserves user dev edits via git status --porcelain check", () => {
// Mert-class users symlink the marketplace clone to a dev worktree.
// Destructive `reset --hard` would wipe in-progress work — must skip
// when uncommitted changes exist.
expect(upgradeBody).toMatch(/["']status["'].*?["']--porcelain["']/s);
});
});
describe("ctx-upgrade swap loop supply-chain containment", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src/cli.ts"), "utf-8");
const SERVER_SOURCE = readFileSync(resolve(ROOT, "src/server.ts"), "utf-8");
// Both /ctx-upgrade swap loops iterate `pkg.files[]` read from a freshly
// cloned upstream package.json. Without containment, a compromised
// upstream tag shipping files: ["../../.ssh/authorized_keys"] (or, at
// the cli.ts site, an absolute path) would let rmSync+cpSync escape
// pluginRoot. Mirrors the lexical guard pattern that
// hooks/heal-partial-install.mjs already uses (PR #699).
test("cli.ts upgrade() rejects swap-loop items that escape pluginRoot or srcDir", () => {
// The block of interest is bounded by the comment about reading
// files from the cloned package.json and the next Issue #609 marker.
const loopBlock = CLI_SOURCE.match(
/Read files list from cloned repo's package\.json[\s\S]*?Issue #609/,
);
expect(loopBlock).not.toBeNull();
expect(loopBlock![0]).toContain("resolve(pluginRoot) + sep");
expect(loopBlock![0]).toContain("resolve(srcDir) + sep");
expect(loopBlock![0]).toMatch(/\(to \+ sep\)\.startsWith\(pluginRootWithSep\)/);
expect(loopBlock![0]).toMatch(/\(from \+ sep\)\.startsWith\(srcDirWithSep\)/);
// The pre-fix unguarded form must not return.
expect(loopBlock![0]).not.toMatch(
/rmSync\(resolve\(pluginRoot, item\),[^)]*\);\s*\n\s*cpSync\(resolve\(srcDir, item\)/,
);
// sep must be imported from node:path.
expect(CLI_SOURCE).toMatch(
/import\s*\{[^}]*\bsep\b[^}]*\}\s*from\s*"node:path"/,
);
// F30 hardening: symlinks inside source items must be filtered, not
// copied as destination symlinks. A planted symlink would otherwise
// bypass the lexical containment at copy time.
expect(loopBlock![0]).toContain("refuseSymlinks");
expect(loopBlock![0]).toMatch(/lstatSync\(src\)\.isSymbolicLink\(\)/);
expect(loopBlock![0]).toMatch(/cpSync\(from, to, \{ recursive: true, filter: refuseSymlinks \}\)/);
// lstatSync must be imported from node:fs.
expect(CLI_SOURCE).toMatch(
/import\s*\{[^}]*\blstatSync\b[^}]*\}\s*from\s*"node:fs"/,
);
});
test("server.ts inline-fallback upgrade script rejects swap-loop items that escape pluginRoot or srcDir", () => {
// The inline-script lines are literal-string template segments inside
// the ctx_upgrade handler's scriptLines array, so the guards land as
// quoted lines in src/server.ts.
expect(SERVER_SOURCE).toContain('import{join,resolve,sep}from"node:path"');
expect(SERVER_SOURCE).toContain("const PW=resolve(P)+sep;const TW=resolve(T)+sep;");
expect(SERVER_SOURCE).toContain("if(!(to+sep).startsWith(PW))continue;");
expect(SERVER_SOURCE).toContain("if(!(from+sep).startsWith(TW))continue;");
// The pre-fix unguarded join-only form must not return.
expect(SERVER_SOURCE).not.toMatch(
/for\(const item of items\)\{const from=join\(T,item\);const to=join\(P,item\);if\(existsSync\(from\)\)/,
);
// F30 hardening for the inline script.
expect(SERVER_SOURCE).toMatch(
/import\{[^}]*\blstatSync\b[^}]*\}from"node:fs"/,
);
expect(SERVER_SOURCE).toContain('const noSymlink=(src)=>{try{return !lstatSync(src).isSymbolicLink()}catch{return false}};');
expect(SERVER_SOURCE).toContain("if(!noSymlink(from))continue;");
expect(SERVER_SOURCE).toContain("filter:noSymlink");
});
test("algorithm: lexical containment guard rejects relative and absolute traversal items", async () => {
// Sandbox replay of the guard logic. Two trees: pluginRoot/ and
// srcDir/. Plant a victim file at base/OUTSIDE/victim.txt and an
// absolute-path probe at base/etc/passwd. Run the same guard the
// production swap loop uses; assert the malicious items are skipped
// and the legitimate item is the only one accepted.
const base = mkdtempSync(join(tmpdir(), "swap-loop-containment-"));
try {
const pluginRoot = join(base, "plugin", "root");
const srcDir = join(base, "src", "dir");
const outside = join(base, "OUTSIDE");
const etc = join(base, "etc");
mkdirSync(pluginRoot, { recursive: true });
mkdirSync(srcDir, { recursive: true });
mkdirSync(outside, { recursive: true });
mkdirSync(etc, { recursive: true });
writeFileSync(join(outside, "victim.txt"), "ATTACKER_WOULD_DELETE_ME");
writeFileSync(join(etc, "passwd"), "PRETEND_PASSWD");
mkdirSync(join(srcDir, "src"), { recursive: true });
writeFileSync(join(srcDir, "src", "index.ts"), "ok");
const { sep } = await import("node:path");
const pluginRootWithSep = resolve(pluginRoot) + sep;
const srcDirWithSep = resolve(srcDir) + sep;
const items = [
"../../OUTSIDE/victim.txt", // relative traversal
join(etc, "passwd"), // absolute-path bypass
"src", // legitimate
];
const accepted: string[] = [];
for (const item of items) {
const from = resolve(srcDir, item);
const to = resolve(pluginRoot, item);
if (!(to + sep).startsWith(pluginRootWithSep)) continue;
if (!(from + sep).startsWith(srcDirWithSep)) continue;
accepted.push(item);
}
expect(accepted).toEqual(["src"]);
// Victim files must remain untouched.
expect(existsSync(join(outside, "victim.txt"))).toBe(true);
expect(existsSync(join(etc, "passwd"))).toBe(true);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
describe("Shell-free upgrade (#185)", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src/cli.ts"), "utf-8");
const SERVER_SOURCE = readFileSync(resolve(ROOT, "src/server.ts"), "utf-8");
test("cli.ts upgrade function uses execFileSync, not execSync", () => {
// Extract upgrade function body (from "async function upgrade" to end of file)
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
expect(upgradeStart).toBeGreaterThan(-1);
const upgradeBody = CLI_SOURCE.slice(upgradeStart);
// Must not contain execSync( calls (but execFileSync is fine)
const execSyncCalls = upgradeBody.match(/(?<!File)execSync\s*\(/g);
expect(execSyncCalls).toBeNull();
});
test("cli.ts uses chmodSync instead of execSync chmod", () => {
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
const upgradeBody = CLI_SOURCE.slice(upgradeStart);
// Must not shell out for chmod
expect(upgradeBody).not.toContain('chmod +x');
// Must use fs.chmodSync instead
expect(upgradeBody).toContain("chmodSync");
});
test("cli.ts upgrade aborts when hook configuration fails instead of reporting success", () => {
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
expect(upgradeStart).toBeGreaterThan(-1);
const upgradeBody = CLI_SOURCE.slice(upgradeStart);
expect(upgradeBody).toContain("Hook configuration failed");
expect(upgradeBody).toMatch(/try\s*\{\s*const hookChanges = adapter\.configureAllHooks\(pluginRoot\)/s);
expect(upgradeBody).toMatch(/\}\s*catch\s*\(err: unknown\)\s*\{[\s\S]*throw new Error\(`Hook configuration failed: \$\{message\}`\);/s);
});
test("cli.ts entrypoint catches upgrade() rejection and exits non-zero", () => {
const entryStart = CLI_SOURCE.indexOf("const args = process.argv.slice(2);");
expect(entryStart).toBeGreaterThan(-1);
const entryBody = CLI_SOURCE.slice(entryStart, CLI_SOURCE.indexOf("/* -------------------------------------------------------", entryStart + 20));
expect(entryBody).toContain('} else if (args[0] === "upgrade") {');
// Issue #542 — entrypoint now forwards optional --platform <id> from
// the ctx_upgrade MCP handler. Match either invocation shape:
// upgrade().catch(...) (legacy)
// upgrade(... ? { platform: ... } : ...).catch(...) (issue #542)
expect(entryBody).toMatch(/upgrade\([^)]*\)\.catch\(\(err: unknown\) => \{/);
expect(entryBody).toContain("process.exit(1);");
});
test("cli.ts already-latest path still configures hooks", () => {
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
expect(upgradeStart).toBeGreaterThan(-1);
const upgradeBody = CLI_SOURCE.slice(upgradeStart);
const alreadyLatestIdx = upgradeBody.indexOf('p.log.success(color.green("Already on latest")');
expect(alreadyLatestIdx).toBeGreaterThan(-1);
const configureIdx = upgradeBody.indexOf("adapter.configureAllHooks(pluginRoot)");
expect(configureIdx).toBeGreaterThan(-1);
expect(configureIdx).toBeGreaterThan(alreadyLatestIdx);
});
test("cli.ts swap loop guards rm/cp with existsSync(from)", () => {
// Regression test for the partial-install vector: the rm/cp loop's
// catch-all was swallowing cpSync failures, so a `files[]` entry
// that didn't exist in the cloned source dropped the corresponding
// pluginRoot path without replacement. Mirrors the safe pattern in
// server.ts:3820 inline-fallback (`if (existsSync(from))` before any
// rm or cp). Same architectural-lock as the rest of #609.
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
expect(upgradeStart).toBeGreaterThan(-1);
const upgradeBody = CLI_SOURCE.slice(upgradeStart);
const loopIdx = upgradeBody.indexOf("for (const item of items) {");
expect(loopIdx, "swap loop missing in upgrade()").toBeGreaterThan(-1);
const loopBody = upgradeBody.slice(loopIdx, loopIdx + 1200);
// Source must exist before any rm/cp fires.
expect(loopBody).toMatch(/if\s*\(!existsSync\(from\)\)\s*continue/);
// The existsSync probe must come BEFORE rmSync inside the loop, not
// after; otherwise it's a no-op guard against the actual vector.
const guardIdx = loopBody.indexOf("if (!existsSync(from)) continue");
const rmIdx = loopBody.indexOf("rmSync");
expect(guardIdx).toBeGreaterThan(-1);
expect(rmIdx).toBeGreaterThan(-1);
expect(guardIdx).toBeLessThan(rmIdx);
});
test("server.ts inline fallback uses execFileSync, not execSync", () => {
// The inline script template must use execFileSync
const inlineStart = SERVER_SOURCE.indexOf("Inline fallback");
expect(inlineStart).toBeGreaterThan(-1);
const inlineSection = SERVER_SOURCE.slice(inlineStart, SERVER_SOURCE.indexOf("cmd =", inlineStart + 500));
// Generated script lines must import execFileSync
expect(inlineSection).toContain("execFileSync");
expect(inlineSection).not.toMatch(/(?<!File)execSync/);
});
test("server.ts inline fallback copies package files including bin", () => {
const inlineStart = SERVER_SOURCE.indexOf("Inline fallback");
expect(inlineStart).toBeGreaterThan(-1);
const inlineSection = SERVER_SOURCE.slice(inlineStart, SERVER_SOURCE.indexOf("cmd =", inlineStart + 500));
expect(inlineSection).toContain('readFileSync(join(T,"package.json"),"utf8")');
expect(inlineSection).toContain("pkg.files");
expect(inlineSection).toContain("Array.isArray(pkg.files)");
expect(inlineSection).toContain("for(const item of items)");
// Issue #609: server.ts inline-fallback MUST NOT write `.mcp.json` either.
// Same architectural-lock as cli.ts upgrade(). The inline-fallback was the
// OTHER producer of per-version `.mcp.json` files — both writers had to go
// for the carry-forward bug class to be structurally impossible.
expect(inlineSection).not.toContain('writeFileSync(join(P,".mcp.json")');
expect(inlineSection).not.toContain("copyDirs");
expect(inlineSection).not.toContain("copyFiles");
});
});
// ── Issue #186: temp dirs must be dot-prefixed to hide from VS Code ──
describe("Hidden temp dirs (#186)", () => {
test("executor.ts uses dot-prefixed temp dir to avoid VS Code auto-open", () => {
const EXEC_SOURCE = readFileSync(resolve(ROOT, "src/executor.ts"), "utf-8");
// Must use .ctx-mode- prefix (dot-hidden) not ctx-mode-
expect(EXEC_SOURCE).toContain('.ctx-mode-');
expect(EXEC_SOURCE).not.toMatch(/tmpdir\(\),\s*"ctx-mode-"/);
});
});
// ── Issue #187 follow-up: self-heal must fix ALL hook types, not just PreToolUse ──
describe("Self-heal hook-path rewriting (#187 + #415 follow-up)", () => {
const PRETOOLUSE_SOURCE = readFileSync(resolve(ROOT, "hooks/pretooluse.mjs"), "utf-8");
test("pretooluse.mjs no longer mutates settings.json from runtime hot path (#415)", () => {
// v1.0.107 had a destructive `entries.filter(e => !e.hooks?.some(isCtxMode))`
// block that wiped co-located user hooks. Removed in v1.0.108 — settings.json
// mutation must only happen at install/upgrade time via configureAllHooks().
expect(PRETOOLUSE_SOURCE).not.toContain("hooks.json owns hook registration");
expect(PRETOOLUSE_SOURCE).not.toMatch(/entries\.filter\([^)]*!entry\.hooks\?\.some/);
});
test("pretooluse.mjs legacy stale-path rewrite still iterates ALL hook types (#187)", () => {
// The legacy else-branch (rewrites stale context-mode hook paths to current
// version dir when hooks.json is absent) must cover all hook types, not just
// PreToolUse — that was the original #187 fix and remains intact.
expect(PRETOOLUSE_SOURCE).not.toContain("settings.hooks?.PreToolUse");
expect(PRETOOLUSE_SOURCE).toMatch(/for\s*\(\s*const\s+hookType\s+of\s+Object\.keys/);
});
});
// ── PR #183 fix: path traversal prevention in OpenClaw sessionKey ──
describe("OpenClaw sessionKey safety (#183)", () => {
const WR_SOURCE = readFileSync(resolve(ROOT, "src/adapters/openclaw/workspace-router.ts"), "utf-8");
test("workspace regex only allows safe characters (no path traversal)", () => {
// Must use [a-zA-Z0-9_-]+ not [^:]+ to prevent ../../ in agent name
expect(WR_SOURCE).toContain('[a-zA-Z0-9_-]+');
});
test("workspace path is scoped to /openclaw/workspace- prefix", () => {
// extractWorkspace must only match recognised /openclaw/workspace-<name> paths
expect(WR_SOURCE).toContain('/openclaw/workspace-');
// workspaceFromKey derives workspace from sessionKey agent:<name>:<channel>
expect(WR_SOURCE).toContain('`/openclaw/workspace-');
});
});
// ── PR #190 fix: getRuntimeSummary handles full bun path ──
describe("Runtime summary bun detection (#190)", () => {
const RT_SOURCE = readFileSync(resolve(ROOT, "src", "runtime.ts"), "utf-8");
test("getRuntimeSummary does not use exact === bun comparison", () => {
// Full path like /home/user/.bun/bin/bun must be detected
const summaryStart = RT_SOURCE.indexOf("getRuntimeSummary");
const summaryBody = RT_SOURCE.slice(summaryStart, RT_SOURCE.indexOf("\nexport", summaryStart + 10));
expect(summaryBody).not.toContain('=== "bun"');
});
});
// ── Plugin root detection for Opencode/Kilocode platforms ────────────────
describe("Plugin root detection (#PR refactor/opencode-improvements)", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
test("cachePluginRoot uses LOCALAPPDATA on Windows", () => {
const cacheRootStart = CLI_SOURCE.indexOf("function cachePluginRoot");
const cacheRootBody = CLI_SOURCE.slice(cacheRootStart, cacheRootStart + 500);
expect(cacheRootBody).toContain("process.env.LOCALAPPDATA");
expect(cacheRootBody).toContain('process.platform === "win32"');
});
test("cachePluginRoot uses ~/.cache as default on non-Windows", () => {
const cacheRootStart = CLI_SOURCE.indexOf("function cachePluginRoot");
const cacheRootBody = CLI_SOURCE.slice(cacheRootStart, cacheRootStart + 500);
expect(cacheRootBody).toContain('".cache"');
expect(cacheRootBody).toContain("homedir");
});
test("getPluginRoot uses cache path for opencode/kilo platform", () => {
const getPluginRootStart = CLI_SOURCE.indexOf("function getPluginRoot");
const getPluginRootBody = CLI_SOURCE.slice(getPluginRootStart, getPluginRootStart + 300);
expect(getPluginRootBody).toContain("isInProcessPluginPlatform(platform)");
expect(getPluginRootBody).toContain("cachePluginRoot");
});
});
// ── Issue #225: Codex CLI hook dispatch ──────────────────────────────────
describe("Codex CLI hook dispatch (#225)", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
test("HOOK_MAP includes codex platform", () => {
// Extract HOOK_MAP definition
const mapStart = CLI_SOURCE.indexOf("const HOOK_MAP");
const mapEnd = CLI_SOURCE.indexOf("};", mapStart) + 2;
const hookMap = CLI_SOURCE.slice(mapStart, mapEnd);
expect(hookMap).toContain('"codex"');
});
test("codex HOOK_MAP has all Codex hook dispatches", () => {
const mapStart = CLI_SOURCE.indexOf("const HOOK_MAP");
const mapEnd = CLI_SOURCE.indexOf("};", mapStart) + 2;
const hookMap = CLI_SOURCE.slice(mapStart, mapEnd);
// Extract codex block
const codexStart = hookMap.indexOf('"codex"');
const codexEnd = hookMap.indexOf("}", codexStart + 10) + 1;
const codexBlock = hookMap.slice(codexStart, codexEnd);
expect(codexBlock).toContain("pretooluse");
expect(codexBlock).toContain("posttooluse");
expect(codexBlock).toContain("precompact");
expect(codexBlock).toContain("sessionstart");
expect(codexBlock).toContain("userpromptsubmit");
expect(codexBlock).toContain("stop");
});
test("codex hooks point to dedicated hooks/codex/ directory", () => {
const mapStart = CLI_SOURCE.indexOf("const HOOK_MAP");
const mapEnd = CLI_SOURCE.indexOf("};", mapStart) + 2;
const hookMap = CLI_SOURCE.slice(mapStart, mapEnd);
const codexStart = hookMap.indexOf('"codex"');
const codexEnd = hookMap.indexOf("}", codexStart + 10) + 1;
const codexBlock = hookMap.slice(codexStart, codexEnd);
expect(codexBlock).toContain("hooks/codex/pretooluse.mjs");
expect(codexBlock).toContain("hooks/codex/posttooluse.mjs");
expect(codexBlock).toContain("hooks/codex/precompact.mjs");
expect(codexBlock).toContain("hooks/codex/sessionstart.mjs");
expect(codexBlock).toContain("hooks/codex/userpromptsubmit.mjs");
expect(codexBlock).toContain("hooks/codex/stop.mjs");
});
test("hooks/codex/pretooluse.mjs exists", () => {
expect(existsSync(resolve(ROOT, "hooks/codex/pretooluse.mjs"))).toBe(true);
});
test("hooks/codex/posttooluse.mjs exists", () => {
expect(existsSync(resolve(ROOT, "hooks/codex/posttooluse.mjs"))).toBe(true);
});
test("hooks/codex/sessionstart.mjs exists", () => {
expect(existsSync(resolve(ROOT, "hooks/codex/sessionstart.mjs"))).toBe(true);
});
test("hooks/codex/precompact.mjs exists", () => {
expect(existsSync(resolve(ROOT, "hooks/codex/precompact.mjs"))).toBe(true);
});
test("session-helpers.mjs exports CODEX_OPTS", () => {
const helpers = readFileSync(resolve(ROOT, "hooks/session-helpers.mjs"), "utf-8");
expect(helpers).toContain("export const CODEX_OPTS");
});
test("CODEX_OPTS uses .codex config dir", () => {
const helpers = readFileSync(resolve(ROOT, "hooks/session-helpers.mjs"), "utf-8");
const optsStart = helpers.indexOf("CODEX_OPTS");
const optsEnd = helpers.indexOf("};", optsStart) + 2;
const optsBlock = helpers.slice(optsStart, optsEnd);
expect(optsBlock).toContain('".codex"');
});
test("configs/codex/hooks.json commands match HOOK_MAP platform name", () => {
const hooksJson = JSON.parse(readFileSync(resolve(ROOT, "configs/codex/hooks.json"), "utf-8"));
for (const [eventType, entries] of Object.entries(hooksJson.hooks)) {
for (const entry of entries as any[]) {
for (const hook of entry.hooks) {
// Command must use "context-mode hook codex <event>"
expect(hook.command).toMatch(/context-mode hook codex \w+/);
}
}
}
});
test("CODEX_OPTS.projectDirEnv is undefined (Codex passes cwd in stdin, not env)", () => {
const helpers = readFileSync(resolve(ROOT, "hooks/session-helpers.mjs"), "utf-8");
const optsStart = helpers.indexOf("CODEX_OPTS");
const optsEnd = helpers.indexOf("};", optsStart) + 2;
const optsBlock = helpers.slice(optsStart, optsEnd);
expect(optsBlock).toMatch(/projectDirEnv:\s*undefined/);
});
test("codex hooks include hookEventName in output (required by codex-rs)", () => {
const posttooluse = readFileSync(resolve(ROOT, "hooks/codex/posttooluse.mjs"), "utf-8");
expect(posttooluse).toContain('hookEventName: "PostToolUse"');
const sessionstart = readFileSync(resolve(ROOT, "hooks/codex/sessionstart.mjs"), "utf-8");
expect(sessionstart).toContain('hookEventName: "SessionStart"');
});
test("codex pretooluse formatter includes hookEventName in deny response", () => {
const formatters = readFileSync(resolve(ROOT, "hooks/core/formatters.mjs"), "utf-8");
const codexStart = formatters.indexOf('"codex"');
const codexEnd = formatters.indexOf("},", codexStart + 50);
const codexBlock = formatters.slice(codexStart, codexEnd);
expect(codexBlock).toContain('hookEventName: "PreToolUse"');
});
});
// ── Kimi Code CLI hook dispatch (#729) ───────────────────────────────────
describe("Kimi Code CLI hook dispatch (#729)", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
test("HOOK_MAP includes kimi platform", () => {
const mapStart = CLI_SOURCE.indexOf("const HOOK_MAP");
const mapEnd = CLI_SOURCE.indexOf("};", mapStart) + 2;
const hookMap = CLI_SOURCE.slice(mapStart, mapEnd);
expect(hookMap).toContain('"kimi"');
});
test("kimi HOOK_MAP has all Kimi hook dispatches including sessionend", () => {
const mapStart = CLI_SOURCE.indexOf("const HOOK_MAP");
const mapEnd = CLI_SOURCE.indexOf("};", mapStart) + 2;
const hookMap = CLI_SOURCE.slice(mapStart, mapEnd);
const kimiStart = hookMap.indexOf('"kimi"');
const kimiEnd = hookMap.indexOf("}", kimiStart + 10) + 1;
const kimiBlock = hookMap.slice(kimiStart, kimiEnd);
expect(kimiBlock).toContain("pretooluse");
expect(kimiBlock).toContain("posttooluse");
expect(kimiBlock).toContain("precompact");
expect(kimiBlock).toContain("sessionstart");
expect(kimiBlock).toContain("sessionend");
expect(kimiBlock).toContain("userpromptsubmit");
expect(kimiBlock).toContain("stop");
});
test("kimi hooks point to dedicated hooks/kimi/ directory", () => {
const mapStart = CLI_SOURCE.indexOf("const HOOK_MAP");
const mapEnd = CLI_SOURCE.indexOf("};", mapStart) + 2;
const hookMap = CLI_SOURCE.slice(mapStart, mapEnd);
const kimiStart = hookMap.indexOf('"kimi"');
const kimiEnd = hookMap.indexOf("}", kimiStart + 10) + 1;
const kimiBlock = hookMap.slice(kimiStart, kimiEnd);
expect(kimiBlock).toContain("hooks/kimi/pretooluse.mjs");
expect(kimiBlock).toContain("hooks/kimi/posttooluse.mjs");
expect(kimiBlock).toContain("hooks/kimi/precompact.mjs");
expect(kimiBlock).toContain("hooks/kimi/sessionstart.mjs");
expect(kimiBlock).toContain("hooks/kimi/sessionend.mjs");
expect(kimiBlock).toContain("hooks/kimi/userpromptsubmit.mjs");
expect(kimiBlock).toContain("hooks/kimi/stop.mjs");
});
test("configs/kimi/hooks.json commands match HOOK_MAP platform name", () => {
const hooksJson = JSON.parse(readFileSync(resolve(ROOT, "configs/kimi/hooks.json"), "utf-8"));
for (const [eventType, entries] of Object.entries(hooksJson.hooks)) {
for (const entry of entries as any[]) {
for (const hook of entry.hooks) {
expect(hook.command).toMatch(/context-mode hook kimi \w+/);
}
}
}
});
test("session-helpers.mjs exports KIMI_OPTS", () => {
const helpers = readFileSync(resolve(ROOT, "hooks/session-helpers.mjs"), "utf-8");
expect(helpers).toContain("export const KIMI_OPTS");
});
test("KIMI_OPTS uses .kimi-code config dir", () => {
const helpers = readFileSync(resolve(ROOT, "hooks/session-helpers.mjs"), "utf-8");
const optsStart = helpers.indexOf("KIMI_OPTS");
const optsEnd = helpers.indexOf("};", optsStart) + 2;
const optsBlock = helpers.slice(optsStart, optsEnd);
expect(optsBlock).toContain('".kimi-code"');
});
test("KIMI_OPTS honours KIMI_CODE_HOME env var", () => {
const helpers = readFileSync(resolve(ROOT, "hooks/session-helpers.mjs"), "utf-8");
const optsStart = helpers.indexOf("KIMI_OPTS");
const optsEnd = helpers.indexOf("};", optsStart) + 2;
const optsBlock = helpers.slice(optsStart, optsEnd);
expect(optsBlock).toContain('configDirEnv: "KIMI_CODE_HOME"');
});
test("kimi pretooluse formatter includes hookEventName in deny response", () => {
const formatters = readFileSync(resolve(ROOT, "hooks/core/formatters.mjs"), "utf-8");
const kimiStart = formatters.indexOf('"kimi"');
const kimiEnd = formatters.indexOf("},", kimiStart + 50);
const kimiBlock = formatters.slice(kimiStart, kimiEnd);
expect(kimiBlock).toContain('hookEventName: "PreToolUse"');
});
});
// ── Cursor stop hook (#HOOK_MAP cursor stop) ─────────────────────────────
describe("Cursor CLI hook dispatch — stop event", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
test("HOOK_MAP cursor entry includes stop event", () => {
const cursorEntry = CLI_SOURCE.match(/"cursor"\s*:\s*\{[\s\S]*?\}/);
expect(cursorEntry).not.toBeNull();
expect(cursorEntry![0]).toContain("stop");
expect(cursorEntry![0]).toContain("hooks/cursor/stop.mjs");
});
});
// ── Upgrade skill sync to marketplace/cache directories ───────────────────
describe("Upgrade syncs skills to active install path (#228)", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src/cli.ts"), "utf-8");
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
// Bound the slice to the upgrade() function only — without this the slice
// includes downstream helpers (statuslineForward, etc.) and assertions like
// "no marketplace ref" fire on UNRELATED code added later in the file.
const upgradeEnd = CLI_SOURCE.indexOf("\n/* ---", upgradeStart);
const upgradeBody = CLI_SOURCE.slice(upgradeStart, upgradeEnd > 0 ? upgradeEnd : undefined);
test("upgrade reads installed_plugins.json to find active install path", () => {
expect(upgradeBody).toContain("installed_plugins.json");
expect(upgradeBody).toContain("context-mode@context-mode");
expect(upgradeBody).toContain("installPath");
});
test("upgrade only syncs when installPath differs from pluginRoot", () => {
// Must short-circuit when installPath === pluginRoot before copying.
// Accept either the original `installPath !== pluginRoot` conjunction
// form OR the refactored early-continue form (`if (installPath ===
// pluginRoot) continue;`).
expect(upgradeBody).toMatch(
/installPath.*!==.*pluginRoot|installPath\s*===\s*pluginRoot/,
);
});
test("upgrade does NOT blindly copy to marketplace or cache directories", () => {
// No hardcoded marketplace/cache paths — only installPath from registry
const syncSection = upgradeBody.slice(upgradeBody.indexOf("installed_plugins.json"));
expect(syncSection).not.toContain('"marketplaces"');
expect(syncSection).not.toContain("readdirSync");
});
test("upgrade warns user to restart for new MCP tools", () => {
expect(upgradeBody).toMatch(/[Rr]estart.*MCP|new MCP tools/i);
});
test("restart hint is adapter-aware (Claude Code gets /reload-plugins)", () => {
expect(upgradeBody).toContain("reload-plugins");
expect(upgradeBody).toContain('adapter.name === "Claude Code"');
});
});
describe("installed_plugins.json installPath containment", () => {
// installed_plugins.json is written by Claude Code itself with installPath
// values under <claudeRoot>/plugins/cache/<marketplace>/<plugin>/<version>.
// Any installPath that resolves elsewhere has been tampered with by a co-
// resident plugin, a malicious postinstall, or another local actor. cli.ts
// consumes the field in two hot places: the upgrade() skills sync (cpSync
// into installPath) and statuslineForward() (dynamic import from
// installPath/bin/statusline.mjs, ~3-5 Hz while CC is open). Both sites
// need the same lexical guard that server.ts:790 already uses on the
// same field.
const CLI_SOURCE = readFileSync(resolve(ROOT, "src/cli.ts"), "utf-8");
test("upgrade() skills cpSync gates installPath under <claudeRoot>/plugins/cache", () => {
// Bound the slice to the upgrade() skills-sync block.
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
expect(upgradeStart).toBeGreaterThan(0);
const skillsBlock = CLI_SOURCE
.slice(upgradeStart)
.match(/Sync skills to the active install path[\s\S]*?best effort — registry may not exist or be malformed/);
expect(skillsBlock).not.toBeNull();
expect(skillsBlock![0]).toContain('resolve(claudeRoot, "plugins", "cache")');
expect(skillsBlock![0]).toMatch(/\(resolvedInstallPath \+ sep\)\.startsWith\(cacheRootWithSep\)/);
// The pre-fix shape passed installPath verbatim to cpSync without
// normalizing or gating it.
expect(skillsBlock![0]).not.toMatch(
/cpSync\(srcSkills, resolve\(installPath, "skills"\),/,
);
// F30 hardening: realpathSync re-check defeats symlink-anchor bypasses
// where the cacheRoot-anchored installPath is itself a symlink to an
// attacker target.
expect(skillsBlock![0]).toMatch(/realpathSync\(cacheRoot\)/);
expect(skillsBlock![0]).toMatch(/realpathSync\(resolvedInstallPath\)/);
expect(skillsBlock![0]).toMatch(/\(realInstallPath \+ sep\)\.startsWith\(cacheRootWithSep\)/);
expect(skillsBlock![0]).toMatch(/cpSync\(srcSkills, resolve\(realInstallPath, "skills"\)/);
});
test("statuslineForward() candidate selection gates installPath under <claudeRoot>/plugins/cache", () => {
// Slice to the statuslineForward() function body.
const statuslineStart = CLI_SOURCE.indexOf("statuslineForward");
expect(statuslineStart).toBeGreaterThan(0);
const tail = CLI_SOURCE.slice(statuslineStart);
// The candidate-building block runs between the candidates[] declaration
// and the candidates.find() that picks the script to import.
const candidateBlock = tail.match(
/candidates: string\[\] = \[[\s\S]*?candidates\.find\(/,
);
expect(candidateBlock).not.toBeNull();
expect(candidateBlock![0]).toContain('resolve(claudeRoot, "plugins", "cache")');
expect(candidateBlock![0]).toMatch(/\(resolvedInstallPath \+ sep\)\.startsWith\(cacheRootWithSep\)/);
// The pre-fix shape pushed any installPath string into candidates without
// gating it.
expect(candidateBlock![0]).not.toMatch(
/candidates\.push\(resolve\(installPath, "bin", "statusline\.mjs"\)\)/,
);
// F30 hardening: realpathSync re-check on the candidate's installPath.
expect(candidateBlock![0]).toMatch(/realpathSync\(cacheRoot\)/);
expect(candidateBlock![0]).toMatch(/realpathSync\(resolvedInstallPath\)/);
expect(candidateBlock![0]).toMatch(/\(realInstallPath \+ sep\)\.startsWith\(cacheRootWithSep\)/);
expect(candidateBlock![0]).toMatch(/candidates\.push\(resolve\(realInstallPath, "bin", "statusline\.mjs"\)\)/);
// realpathSync must be imported from node:fs.
expect(CLI_SOURCE).toMatch(
/import\s*\{[^}]*\brealpathSync\b[^}]*\}\s*from\s*"node:fs"/,
);
});
test("algorithm: realpath re-check rejects symlink-anchor planted at cacheRoot/<owner>/<plugin>/<version>", async () => {
// Sandbox: build <fakeClaudeRoot>/plugins/cache/owner/plugin/version
// as a SYMLINK targeting an attacker-controlled directory outside
// cacheRoot. The lexical resolve+startsWith check passes (the symlink
// path itself is under cacheRoot). realpathSync follows the link and
// returns the attacker target -- which then fails the post-realpath
// startsWith gate.
const fs = await import("node:fs");
// Canonicalize the base directory up front: on macOS, mkdtempSync(tmpdir())
// returns a path under /var/folders, which is itself a symlink to
// /private/var/folders. Without realpathSync here, the legit-installPath
// arm below trips the lexical startsWith gate (resolved input still
// /var/folders/..., cacheRootCanon is /private/var/folders/...). Same
// canonicalization production users get when ~/.claude lives on a real
// (non-symlinked) path.
const base = fs.realpathSync(
mkdtempSync(join(tmpdir(), "installpath-symlink-anchor-")),
);
try {
const cacheRoot = resolve(base, ".claude", "plugins", "cache");
const legitVersionDir = resolve(cacheRoot, "owner", "plugin", "1.0.0");
const attackerDir = resolve(base, "attacker-target");
mkdirSync(legitVersionDir, { recursive: true });
mkdirSync(attackerDir, { recursive: true });
writeFileSync(join(attackerDir, "marker.txt"), "PWNED");
// Planted symlink anchor: <cacheRoot>/owner/plugin/2.0.0 -> attackerDir.
// On Windows without symlink privilege / Developer Mode, symlinkSync
// with type "dir" fails with EPERM; "junction" works without privilege
// and still reports isSymbolicLink()===true under lstatSync.
const plantedAnchor = resolve(cacheRoot, "owner", "plugin", "2.0.0");
const symlinkType = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(attackerDir, plantedAnchor, symlinkType);
const cacheRootCanon = fs.realpathSync(cacheRoot);
const cacheRootWithSep = cacheRootCanon + require("node:path").sep;
const isAccepted = (installPath: string): boolean => {
const resolvedInstallPath = resolve(installPath);
if (!(resolvedInstallPath + require("node:path").sep).startsWith(cacheRootWithSep)) return false;
let realInstallPath: string;
try { realInstallPath = fs.realpathSync(resolvedInstallPath); }
catch { return false; }
return (realInstallPath + require("node:path").sep).startsWith(cacheRootWithSep);
};
// Legit version dir: accepted.
expect(isAccepted(legitVersionDir)).toBe(true);
// Symlink anchor: lexical passes, realpath escapes -> rejected.
expect(isAccepted(plantedAnchor)).toBe(false);
// Direct attacker path: lexical rejects immediately.
expect(isAccepted(attackerDir)).toBe(false);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("algorithm: containment rejects installPath values outside the cache root", async () => {
// Sandbox a fake <claudeRoot> tree with a cache dir holding one
// legitimate plugin entry, plus a malicious entry pointing at an
// attacker-chosen directory outside the cache. Replay the production
// guard; assert only the legitimate entry survives.
const claudeRoot = mkdtempSync(join(tmpdir(), "installpath-containment-"));
try {
const cacheRoot = resolve(claudeRoot, "plugins", "cache");
const legitCacheDir = resolve(cacheRoot, "context-mode", "context-mode", "1.0.0");
const attackerDir = resolve(claudeRoot, "outside-cache", "evil");
mkdirSync(legitCacheDir, { recursive: true });
mkdirSync(attackerDir, { recursive: true });
const { sep } = await import("node:path");
const cacheRootWithSep = cacheRoot + sep;
const inputs = [
{ installPath: legitCacheDir, label: "legit" },
{ installPath: attackerDir, label: "outside-cache" },
{ installPath: "/etc", label: "absolute-system" },
// Relative-".." escape: legitimate prefix then ".." up and out.
{ installPath: join(legitCacheDir, "..", "..", "..", "..", "outside-cache", "evil"), label: "traversal" },
];
const accepted: string[] = [];
for (const { installPath, label } of inputs) {
if (typeof installPath !== "string" || !installPath) continue;
const resolvedInstallPath = resolve(installPath);
if (!(resolvedInstallPath + sep).startsWith(cacheRootWithSep)) continue;
accepted.push(label);
}
expect(accepted).toEqual(["legit"]);
} finally {
rmSync(claudeRoot, { recursive: true, force: true });
}
});
// Issue #795: when ~/.claude is a symlink to another volume, the
// healCacheMidSession traversal guard in server.ts compares a lexical
// cacheRoot (symlink path, e.g. /Users/me/.claude/plugins/cache) against
// a physical installPath from the plugin registry (e.g.
// /Volumes/SSD/claude-code/plugins/cache/...). Without canonicalizing
// cacheRoot via realpathSync, the startsWith check fails and the heal
// is skipped for every user with a symlinked ~/.claude.
//
// RED→GREEN: the source-level assertion (realpathSync on cacheRoot inside
// healCacheMidSession) FAILS when the fix is reverted. The algorithmic
// assertions prove the lexical comparison rejects and the canonical
// comparison accepts the symlinked-layout entry.
const SERVER_SOURCE = readFileSync(resolve(ROOT, "src/server.ts"), "utf-8");
test("server.ts: healCacheMidSession canonicalizes cacheRoot with realpathSync (#795)", () => {
// Locate the healCacheMidSession function body.
const healStart = SERVER_SOURCE.indexOf("function healCacheMidSession");
expect(healStart).toBeGreaterThan(0);
const healBody = SERVER_SOURCE.slice(healStart);
// Bound to the end of the function (the next top-level "function").
const nextFn = healBody.indexOf("\nfunction ");
const fnSlice = nextFn > 0 ? healBody.slice(0, nextFn) : healBody;
// The fix canonicalizes cacheRoot via realpathSync with a try/catch
// fallback, matching the pattern already used in cli.ts.
expect(fnSlice).toMatch(/realpathSync\(cacheRoot\)/);
// The traversal guard must use the canonical cacheRootCanon, not the
// lexical cacheRoot directly.
expect(fnSlice).toMatch(/cacheRootCanon\s*\+\s*sep/);
// Pre-fix shape: startsWith(cacheRoot + sep) without canonicalization.
// This pattern MUST NOT appear in the traversal guard for installPath.
// (It may still appear in comments or string literals, so we scope the
// negative assertion to the line that checks installPath containment.)
expect(fnSlice).toMatch(/resolve\(rp\)\.startsWith\(cacheRootCanon/);
});
test("algorithm: canonical cacheRoot accepts physical installPath when ~/.claude is a symlink (#795)", async () => {
// Skip on Windows: symlink-permission requirements vary and the
// scenario the issue targets (symlink-rehomed ~/.claude to another
// volume) is a macOS/macOS-on-Linux-Desktop pattern.
if (process.platform === "win32") return;
const fs = await import("node:fs");
// Build two directories: the "real" target (simulates the external volume)
// and the symlink path (simulates ~/.claude → external).
const sandbox = mkdtempSync(join(tmpdir(), "ctx-795-cache-root-symlink-"));
try {
const realTarget = join(sandbox, "real-claude-root");
const symlinkPath = join(sandbox, "dot-claude");
mkdirSync(realTarget, { recursive: true });
// Create the cache tree inside the real target.
const realCacheRoot = resolve(realTarget, "plugins", "cache");
const versionDir = resolve(realCacheRoot, "context-mode", "context-mode", "1.0.161");
mkdirSync(versionDir, { recursive: true });
// Simulate ~/.claude → realTarget
fs.symlinkSync(realTarget, symlinkPath, process.platform === "win32" ? "junction" : "dir");
// The cacheRoot as computed by resolveClaudeConfigDir():
// path.resolve does NOT dereference symlinks, so cacheRoot uses the
// symlink path (NOT the real path).
const lexicalCacheRoot = resolve(symlinkPath, "plugins", "cache");
// The canonical cacheRoot (what the fix produces via realpathSync).
const canonicalCacheRoot = fs.realpathSync(lexicalCacheRoot);
const canonicalCacheRootWithSep = canonicalCacheRoot + sep;
// installPath as stored by the plugin registry: the physical path
// under the real target (not the symlink path).
const physicalInstallPath = resolve(realTarget, "plugins", "cache", "context-mode", "context-mode", "1.0.161");
// Verification 1 — OLD behaviour (lexical, no fix):
// The lexical comparison fails because the physical installPath
// (/.../real-claude-root/...) does not start with the symlink-path
// cacheRoot (/.../dot-claude/...).
const lexicalPasses = resolve(physicalInstallPath).startsWith(lexicalCacheRoot + sep);
expect(lexicalPasses).toBe(false); // RED: would skip the heal
// Verification 2 — FIXED behaviour (canonical cacheRoot):
// After canonicalizing cacheRoot via realpathSync, the physical
// installPath does start with the canonical prefix.
// realpathSync is used on both sides because on macOS /var is itself a
// symlink to /private/var — resolve() alone stays on the non-canonical
// side and fails the startsWith check.
const canonicalPasses = fs.realpathSync(resolve(physicalInstallPath)).startsWith(canonicalCacheRootWithSep);
expect(canonicalPasses).toBe(true); // GREEN: heal proceeds
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
});
// ── better-sqlite3 binding self-heal (#408) ───────────────────────────────
//
// On Windows, `npm rebuild better-sqlite3` falls through to node-gyp when
// prebuild-install is not on cmd.exe PATH, then dies for users without
// MSVC. The fix is a 3-layer heal (spawn prebuild-install via
// process.execPath → `npm install better-sqlite3` → actionable stderr)
// shared between scripts/postinstall.mjs and hooks/ensure-deps.mjs via
// scripts/heal-better-sqlite3.mjs.
//
// Tests in this block cover:
// - doctor() in src/cli.ts: clearer hint when FTS5 check fails on a
// bindings-missing pattern.
// - scripts/postinstall.mjs: calls the shared heal helper after the
// existing nvm4w junction logic; mklink /J regression guard.
// - scripts/heal-better-sqlite3.mjs: single source of truth — pins the
// prebuild-install + npm install + Windows/#408 stderr surface so the
// dedupe doesn't silently regress.
describe("better-sqlite3 binding self-heal (#408)", () => {
const CLI_SRC = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
const POSTINSTALL_SRC = readFileSync(resolve(ROOT, "scripts", "postinstall.mjs"), "utf-8");
const HEAL_SRC = readFileSync(resolve(ROOT, "scripts", "heal-better-sqlite3.mjs"), "utf-8");
// Locate doctor()'s FTS5 / SQLite catch branch — the hint lives here.
function ftsCatchBlock(): string {
const ftsAnchor = CLI_SRC.indexOf("Checking FTS5 / SQLite");
expect(ftsAnchor).toBeGreaterThan(-1);
const catchIdx = CLI_SRC.indexOf("catch (err", ftsAnchor);
expect(catchIdx).toBeGreaterThan(-1);
const versionIdx = CLI_SRC.indexOf("Checking versions", catchIdx);
expect(versionIdx).toBeGreaterThan(catchIdx);
return CLI_SRC.slice(catchIdx, versionIdx);
}
// ── doctor(): bindings-missing hint ────────────────────────────────
describe("doctor: bindings-missing hint", () => {
it("hint mentions `npm install better-sqlite3` as the primary remedy", () => {
// On Windows `npm rebuild` falls through to node-gyp without MSVC,
// while `npm install` re-runs prebuild-install and pulls a prebuilt.
const block = ftsCatchBlock();
expect(block).toContain("npm install better-sqlite3");
});
it("hint explains the Windows / prebuild-install root cause", () => {
const block = ftsCatchBlock();
const mentionsRootCause =
/prebuild-install/i.test(block) || /Windows/i.test(block);
expect(mentionsRootCause).toBe(true);
});
it("retains `npm rebuild better-sqlite3` as a non-Windows fallback", () => {
// Existing rebuild hint is still valid on Linux/macOS where the
// toolchain is present. The fix only augments — does not delete.
const block = ftsCatchBlock();
expect(block).toContain("npm rebuild better-sqlite3");
});
it("bindings-error detection branches on the bindings error message", () => {
// Hint must be conditional on the actual bindings failure
// signature (`Could not locate the bindings file` / `bindings`),
// otherwise it would spam the install hint for unrelated FTS5 errors.
const block = ftsCatchBlock();
const detectsBindingsError =
/bindings file/i.test(block) || /\bbindings\b/.test(block);
expect(detectsBindingsError).toBe(true);
});
});
// ── scripts/postinstall.mjs: delegates to shared helper ────────────
describe("postinstall.mjs", () => {
it("imports the shared heal helper", () => {
// After dedupe, the inline 3-layer heal is gone — replaced by an
// import + call to the shared helper. Match either bare or
// explicit-extension import path.
const importsHelper = /from\s+["']\.\/heal-better-sqlite3(?:\.mjs)?["']/.test(POSTINSTALL_SRC);
expect(importsHelper).toBe(true);
expect(POSTINSTALL_SRC).toContain("healBetterSqlite3Binding");
});
it("calls healBetterSqlite3Binding(pkgRoot) after the nvm4w junction logic", () => {
// Order matters: the junction fix must run first so the heal can
// resolve prebuild-install through the correct node_modules path.
const junctionIdx = POSTINSTALL_SRC.indexOf("mklink /J");
expect(junctionIdx).toBeGreaterThan(-1);
const callIdx = POSTINSTALL_SRC.indexOf("healBetterSqlite3Binding(");
expect(callIdx).toBeGreaterThan(junctionIdx);
});
it("existing Windows nvm4w junction logic (mklink /J) remains intact", () => {
// Regression guard — Slice 3 must not have wiped the unrelated
// Windows install fix.
expect(/mklink\s+\/J/.test(POSTINSTALL_SRC)).toBe(true);
});
});
// ── scripts/heal-better-sqlite3.mjs: single source of truth ────────
describe("heal-better-sqlite3.mjs (shared helper)", () => {
it("exports healBetterSqlite3Binding", () => {
expect(/export\s+function\s+healBetterSqlite3Binding\s*\(/.test(HEAL_SRC)).toBe(true);
});
it("contains all three heal layers in one place (dedupe guarantee)", () => {
// Layer A — prebuild-install via process.execPath
expect(HEAL_SRC).toContain("prebuild-install");
expect(HEAL_SRC).toContain("process.execPath");
// Layer B — npm install fallback (NOT npm rebuild)
expect(/install\s+better-sqlite3/.test(HEAL_SRC)).toBe(true);
// Layer C — actionable stderr with Windows / #408 context
const mentionsContext =
/#408/.test(HEAL_SRC) ||
(/Windows/i.test(HEAL_SRC) && /better-sqlite3/.test(HEAL_SRC));
expect(mentionsContext).toBe(true);
const writesStderr =
/process\.stderr\.write\s*\(/.test(HEAL_SRC) ||
/console\.(error|warn)\s*\(/.test(HEAL_SRC);
expect(writesStderr).toBe(true);
});
it("ships in npm package (listed in package.json files array)", () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
expect(pkg.files).toContain("scripts/heal-better-sqlite3.mjs");
});
it("never throws — all layers wrapped in try/catch (best-effort posture)", () => {
// Outer try/catch around the whole function body protects callers
// (postinstall + ensure-deps) from blocking on a heal failure.
expect(/function\s+healBetterSqlite3Binding[\s\S]{0,200}?try\s*\{/.test(HEAL_SRC)).toBe(true);
});
});
});
// ── Issue #564 — docs sync to hasModernSqlite() source of truth ────────
// README and docs/platform-support.md historically promised "Node 18+"
// (9 spots in README) and "Node >= 22.13" (platform-support), while the
// runtime gate (`hasModernSqlite()` in src/db-base.ts:226-244) uses 22.5.
// Three numbers, three contracts. v1.0.132 collapses them to one — the
// runtime gate is the canonical source.
describe("Issue #564 — docs match hasModernSqlite() source of truth", () => {
const DB_BASE_SRC = readFileSync(resolve(ROOT, "src", "db-base.ts"), "utf-8");
it("src/db-base.ts hasModernSqlite() uses the 22.5 floor (sanity / source of truth)", () => {
// If this fails, the floor moved — the README + docs assertions
// below need their threshold updated in lockstep. This test pins the
// contract so the docs assertions can not silently drift.
expect(DB_BASE_SRC).toContain("export function hasModernSqlite");
// Inline major/minor compare must reference 22 and 5.
expect(DB_BASE_SRC).toMatch(/major\s*===\s*22\s*&&\s*minor\s*>=\s*5/);
});
it("README.md does NOT promise Node.js 18+ on platforms where Linux is unsafe", () => {
const readme = readFileSync(resolve(ROOT, "README.md"), "utf-8");
// The literal string "Node.js 18+" must be gone from prerequisites
// lines — it's a false promise on Linux.
const nodeJs18PrereqLines = readme
.split("\n")
.filter((l) => /Node\.js\s+18\s*\+/.test(l));
expect(nodeJs18PrereqLines).toEqual([]);
// README must positively state the 22.5 (or Bun) floor somewhere.
expect(readme).toMatch(/22\.5/);
});
it("docs/platform-support.md SQLite Backend Selection table uses 22.5, not 22.13", () => {
const doc = readFileSync(resolve(ROOT, "docs", "platform-support.md"), "utf-8");
// The literal "22.13" must be gone — it disagrees with hasModernSqlite().
expect(doc).not.toMatch(/22\.13/);
// The 22.5 floor must be present.
expect(doc).toMatch(/22\.5/);
});
});
// ── Issue #564 — doctor RED FAIL on Linux + Node < 22.5 + no Bun ──────
// Six prior fixes (#228, #331, #461, #540, #551, #556) silently assumed
// Node >= 22.5 on Linux. Reporter #564 hit SIGSEGV on Node 20 because
// engines.node was absent and doctor never flagged the unsafe config.
//
// Architect contract for v1.0.132: doctor MUST emit an explicit RED FAIL
// (not a warn / not a passing note) for the predicate
// process.platform === "linux" && !hasModernSqlite() && globalThis.Bun === undefined
// linking to issue #564.
//
// Static-analysis assertion (same pattern as cli.test.ts:289, :820, :970):
// runtime spawning would need a fake-Linux fake-Node-20 environment that
// is not portable; asserting the gate exists in source catches the
// regression at PR time and is the precedent used elsewhere in this file.
describe("Issue #564 — doctor() flags Linux + Node < 22.5 + no Bun", () => {
const CLI_SRC = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
function doctorBody(): string {
const start = CLI_SRC.indexOf("async function doctor(");
expect(start).toBeGreaterThan(-1);
// doctor() spans ~300 lines; grab a generous window that ends before
// the next top-level function declaration (`async function insight`).
const end = CLI_SRC.indexOf("async function insight", start);
expect(end).toBeGreaterThan(start);
return CLI_SRC.slice(start, end);
}
it("doctor fails on Linux + Node < 22.5 + no bun (RED FAIL line)", () => {
const body = doctorBody();
// The Linux predicate must be in doctor().
expect(body).toMatch(/process\.platform\s*===\s*["']linux["']/);
// Must consult the 22.5 gate via hasModernSqlite (the source of truth
// in src/db-base.ts:226-244) OR an equivalent inline major/minor check.
const usesHelper = /hasModernSqlite/.test(body);
const usesInlineGate =
/process\.versions\.node/.test(body) && /22(?:\.5|[^0-9])/.test(body);
expect(usesHelper || usesInlineGate).toBe(true);
// Bun must be allowed through (Linux + Bun is fine).
expect(body).toMatch(/globalThis\.Bun|hasBunRuntime|process\.versions\.bun/);
// Must be a RED FAIL (architect mandate) — not a warn/info. Reuses
// the existing FAIL surface: `p.log.error(color.red(... FAIL ...`.
// We assert FAIL appears in the new block by matching against an
// anchor unique to it (issue #564 reference).
const issueIdx = body.indexOf("#564");
expect(issueIdx).toBeGreaterThan(-1);
// Look at a wide window around the #564 anchor — the comment block
// sits above the predicate and the FAIL emission sits below, so we
// grab text on both sides.
const surrounding = body.slice(
Math.max(0, issueIdx - 1500),
issueIdx + 2000,
);
expect(surrounding).toMatch(/p\.log\.error/);
expect(surrounding).toMatch(/FAIL/);
// The block must increment criticalFails so the doctor exits non-zero.
expect(surrounding).toMatch(/criticalFails\+\+/);
// Remediation: must point users at 22.5+ (or Bun).
expect(body).toMatch(/22\.5/);
});
});
// ── Upgrade flow: stale ABI guard ─────────────────────────────────────
// `/ctx-upgrade` must not declare success just because better_sqlite3.node
// exists. On modern Node the startup probe is skipped, so the ABI-specific
// cache file is the no-probe compatibility marker and hooks/ensure-deps.mjs
// owns the repair decision.
describe("Upgrade native ABI bootstrap", () => {
const CLI_SOURCE = readFileSync(resolve(ROOT, "src/cli.ts"), "utf-8");
const upgradeStart = CLI_SOURCE.indexOf("async function upgrade");
const upgradeBody = CLI_SOURCE.slice(upgradeStart);
it("delegates native compatibility to hooks/ensure-deps.mjs", () => {
const rebuildStartIdx = upgradeBody.indexOf("Verifying native addon ABI");
expect(rebuildStartIdx).toBeGreaterThan(-1);
const region = upgradeBody.slice(
Math.max(0, rebuildStartIdx - 600),
rebuildStartIdx + 800,
);
expect(region).toContain('"hooks", "ensure-deps.mjs"');
expect(region).toContain("pathToFileURL");
expect(region).toContain("await import");
expect(region).not.toContain("binding present");
});
it("uses the current ABI cache as the native-addon success marker", () => {
const rebuildStartIdx = upgradeBody.indexOf("Verifying native addon ABI");
expect(rebuildStartIdx).toBeGreaterThan(-1);
const region = upgradeBody.slice(rebuildStartIdx, rebuildStartIdx + 1500);
expect(region).toContain("better_sqlite3.abi${process.versions.modules}.node");
expect(region).toContain("existsSync(bsqAbiCachePath)");
expect(region).toContain("ABI cache present");
});
});
// ── Issue #613/#609 — doctor() surfaces persistence-tier bug class ─────
// PR #620 (Family A) shipped the architectural fixes:
// - #609: stop writing per-version cache `.mcp.json` + post-bump sweep
// - #613: vscode/jetbrains-copilot hook commands ship CLI-dispatcher form
// (no absolute `process.execPath` + script path baked into
// workspace-committed `.github/hooks/context-mode.json`)
//
// These two slices are the *prevention* surface — root-cause fixes that
// stop the bug from being written. But users on the field can still be
// holding pre-PR-620 poisoned state:
// - already-committed `.github/hooks/context-mode.json` in their repo
// with absolute Windows fnm shim paths from v1.0.136 or earlier
// - leftover `.mcp.json` in `~/.claude/plugins/cache/.../<version>/`
// from /ctx-upgrade flows that ran before PR #620
//
// Doctor's job per the verdict family ("silent-green doctor while hooks
// are dead is itself a P0 trust bug" — ISSUE-604-VERDICT §11) is to
// SURFACE that pre-PR state BEFORE the user hits a runtime failure.
//
// Architect contract for PR #620 + slice 4:
// CHECK A: doctor scans workspace Tier C files (`.github/hooks/context-mode.json`,
// `.cursor/hooks.json`, `.jetbrains/copilot/hooks.json` under
// process.cwd()) — for each that exists, parse JSON, recurse
// into all string values, FAIL if any matches absolute path
// patterns (unix `/`, Windows `[A-Z]:[/\\]`, `\\`, fnm_multishells).
// Remediation: "run `ctx_upgrade` to rewrite to portable form".
// Missing config → SKIP (no false fail).
//
// CHECK B: doctor scans `~/.claude/plugins/cache/context-mode/context-mode/*/`
// for `.mcp.json` files (post-PR-620 these should not exist).
// Found → WARN (not fail) with remediation:
// "ctx_upgrade will sweep on next run".
//
// Same static-analysis assertion pattern as Issue #564 doctor test (above)
// and lines 962, 997, 1010 — runtime spawning would need fixture
// workspaces on three OSes and is not portable; asserting the gate
// exists in doctor() source catches the regression at PR time.
describe("PR #620 slice 4 — doctor() surfaces persistence-tier bug class", () => {
const CLI_SRC = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
function doctorBody(): string {
const start = CLI_SRC.indexOf("async function doctor(");
expect(start).toBeGreaterThan(-1);
const end = CLI_SRC.indexOf("async function insight", start);
expect(end).toBeGreaterThan(start);
return CLI_SRC.slice(start, end);
}
it("doctor scans workspace Tier C config files for absolute paths (#613 proactive)", () => {
const body = doctorBody();
// Must reference all three Tier C path shapes that PR #620 covers
// (vscode-copilot writes `.github/hooks/context-mode.json`,
// cursor writes `.cursor/hooks.json`,
// jetbrains-copilot writes `.jetbrains/copilot/hooks.json`
// — workspace-committed per ISSUE-613-VERDICT §6.1 Tier C table).
expect(body).toContain(".github/hooks/context-mode.json");
expect(body).toContain(".cursor/hooks.json");
expect(body).toContain(".jetbrains/copilot/hooks.json");
// Must detect the fnm-shim pattern (reporter's stderr literally shows
// `fnm_multishells/<pid>_<ts>/node.exe` per ISSUE-613-VERDICT §2 H2).
expect(body).toMatch(/fnm_multishells/);
// Must surface the failure with remediation pointing at ctx_upgrade.
// The Tier C section is identifiable by the issue anchor `#613`.
const anchorIdx = body.indexOf("#613");
expect(anchorIdx).toBeGreaterThan(-1);
const window_ = body.slice(
Math.max(0, anchorIdx - 500),
anchorIdx + 3000,
);
// The check must use p.log.error or p.log.warn (not info) AND
// mention ctx_upgrade so the user knows the remediation.
expect(window_).toMatch(/p\.log\.(error|warn)/);
expect(window_).toMatch(/ctx[_-]?upgrade/i);
});
it("doctor warns on stale `.mcp.json` files in cache version dirs (#609 proactive)", () => {
const body = doctorBody();
// Must reference the cache plugin path shape that PR #620 sweeps.
// The path nests `context-mode/context-mode` (marketplace/plugin
// nesting per ISSUE-609-VERDICT path examples). cli.ts uses
// path.join() so the literal appears as adjacent string args:
// join(homedir(), ".claude", "plugins", "cache",
// "context-mode", "context-mode")
// We assert on both the cache anchor segments AND the join args
// (Mert standing rule — use platform-neutral path joins, not literal
// separators that fail on Windows).
expect(body).toMatch(/"plugins"\s*,\s*"cache"/);
expect(body).toMatch(/"context-mode"\s*,\s*"context-mode"/);
// Must check for `.mcp.json` (the file that should not exist after
// PR #620's architectural untrack — ISSUE-609-VERDICT §H1 → PR #618 → #620).
const anchorIdx = body.indexOf("#609");
expect(anchorIdx).toBeGreaterThan(-1);
const window_ = body.slice(
Math.max(0, anchorIdx - 500),
anchorIdx + 2500,
);
expect(window_).toContain(".mcp.json");
// WARN (not FAIL) — the verdict spec is explicit that this is
// recoverable: "ctx_upgrade will sweep on next run".
expect(window_).toMatch(/p\.log\.warn/);
expect(window_).toMatch(/ctx[_-]?upgrade/i);
});
it("doctor uses homedir() (cross-platform) not literal '~' for cache scan", () => {
const body = doctorBody();
// Mert standing rule: Windows safety. Cache path must resolve via
// os.homedir() — not a literal `~/` prefix which fails on Windows.
const anchorIdx = body.indexOf("#609");
expect(anchorIdx).toBeGreaterThan(-1);
const window_ = body.slice(anchorIdx, anchorIdx + 2500);
// The scan must use homedir() (already imported at the top of cli.ts).
expect(window_).toMatch(/homedir\(\)|process\.env\.HOME/);
// And must NOT use a literal `~/` path string (would be treated
// literally on Windows).
expect(window_).not.toMatch(/["']~\//);
});
});