diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..f64e692 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "crosshead", + "interface": { + "displayName": "Crosshead" + }, + "plugins": [ + { + "name": "crosshead", + "source": { + "source": "local", + "path": "./plugins/codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..5da2138 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "crosshead", + "owner": { + "name": "FriendMachine", + "url": "https://crosshead.sh" + }, + "metadata": { + "description": "Crosshead plugins for Claude Code. Codex reads .agents/plugins/marketplace.json." + }, + "plugins": [ + { + "name": "crosshead", + "source": "./plugins/claude-code", + "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", + "version": "1.0.1" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44ec19b..3dcfd74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: - run: npm run typecheck - run: npm run check:boundary - run: npm run build + - run: npm run plugins:check - run: npm test release-build: diff --git a/.github/workflows/native-hosts.yml b/.github/workflows/native-hosts.yml index c9cb0cc..7ca402c 100644 --- a/.github/workflows/native-hosts.yml +++ b/.github/workflows/native-hosts.yml @@ -7,6 +7,10 @@ on: - 'src/runtime/**' - 'tests/runtime/native-host-plugins.test.ts' - 'scripts/*native-host*' + - 'scripts/*host-plugins*' + - 'plugins/**' + - '.claude-plugin/**' + - '.agents/**' - 'package-lock.json' - '.github/workflows/native-hosts.yml' @@ -40,3 +44,58 @@ jobs: include-hidden-files: true if-no-files-found: ignore retention-days: 14 + + host-plugins: + name: Claude Code and Codex plugins + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run release:build + - run: npm run plugins:check + - run: bash scripts/test-host-plugins.sh + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: host-plugin-results + path: .native-host-test-logs/host-plugins/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 14 + + host-plugins-macos: + name: Claude Code and Codex plugins (macOS) + runs-on: macos-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run release:build + - run: npm run plugins:check + - name: Install pinned host CLIs + run: npm install --global --no-audit --no-fund @anthropic-ai/claude-code@2.1.319 @openai/codex@0.167.0 + - name: Exercise installed plugins + env: + CROSSHEAD_SMOKE_ROOT: ${{ runner.temp }}/crosshead-plugin-smoke + run: node scripts/smoke-host-plugins.mjs + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: host-plugin-macos-results + path: ${{ runner.temp }}/crosshead-plugin-smoke/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 diff --git a/README.md b/README.md index 70db675..e323d9f 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,41 @@ are covered by [TRADEMARKS.md](TRADEMARKS.md). ## Install +### Claude Code and Codex: plugin + +```bash +claude plugin marketplace add FriendMachine/crosshead +claude plugin install crosshead@crosshead +# Codex: +codex plugin marketplace add FriendMachine/crosshead +codex plugin add crosshead@crosshead +``` + +Then start a session and run `/crosshead:setup` in Claude Code, or type +`$crosshead:setup` in Codex. Only you can start setup; the model does not run +it on its own. Until setup has run, each session starts with one line saying +Crosshead isn't set up; nothing else happens and the session is never blocked. +Setup asks for your consent, runs the installer below, and connects the host +with `crosshead quickstart --adapter claude-code|codex --hook-mode plugin`. +Codex runs plugin hooks only after you trust them in `/hooks`. The plugin +launcher needs a POSIX shell; Windows is not supported. + +The plugins are thin. They contain the lifecycle hooks, the setup skill and a +small launcher that runs the installed runtime's hook entrypoints. They do not +bundle, download or update the runtime, so one runtime, daemon and identity +stays shared by every host. See [host plugins](docs/HOST-PLUGINS.md). + +### Other hosts and scripts: installer + ```bash curl -fsSL https://crosshead.sh/install | bash crosshead quickstart --api-base https://crosshead.sh --adapter claude-code --workspace-root . ``` +`--adapter` also takes `codex`, `pi`, `omp`, `hermes` and `opencode`. Without +the plugin, `crosshead install claude-code|codex` registers the hooks in +`.claude/settings.json` or `$CODEX_HOME/hooks.json` itself. + The installer downloads one release tarball, checks its SHA-256 against the installer script, installs three npm dependencies (`@noble/curves`, `nostr-tools`, `tar`) with install scripts disabled, and writes the wrappers @@ -41,7 +71,8 @@ same bytes (see [Releases](#releases)). in the runtime's local secret store under `~/.crosshead/config/secrets/`. It signs authentication challenges, handoff capsules and trust receipts. Logs redact key material and session tokens. -- The adapters register Claude Code and Codex hooks. On session start and each +- The Crosshead plugin (or, without it, the adapter installer) registers + Claude Code and Codex hooks. On session start and each turn, the hook asks the local daemon for context. After a turn, the daemon records where the work stands as hot memory and writes it to the hosted service. Promotion to warm memory and archiving to cold memory follow your @@ -61,8 +92,10 @@ same bytes (see [Releases](#releases)). - `crosshead status`, `crosshead doctor` and `crosshead logs` show what the runtime is doing. Local state lives in `~/.crosshead/state/`. -`crosshead uninstall` removes the runtime; `crosshead uninstall claude-code` -and `crosshead uninstall codex` remove the hooks. +`crosshead uninstall` removes the runtime. Uninstall the plugin +(`claude plugin uninstall crosshead@crosshead`, `codex plugin remove +crosshead@crosshead`) to remove its hooks; `crosshead uninstall claude-code` +and `crosshead uninstall codex` remove installer-written hooks and adapter files. ## Hermes and OpenCode @@ -85,6 +118,7 @@ uninstall commands, supported versions, and Docker tests. | Path | Contents | | --- | --- | | `src/runtime/` | CLI, daemon, host adapters, hooks, hosted API client | +| `plugins/`, `.claude-plugin/`, `.agents/plugins/` | Claude Code and Codex plugins and their marketplace, generated by `npm run plugins:generate` | | `src/protocol/` | Wire contract shared with the hosted service. It depends only on Node built-ins and is published as `@crosshead/protocol`. | | `schemas/` | Published JSON schemas for protocol records | | `tests/` | Unit tests. Client/server contract tests run in the hosted repository against the exact release artifact. | @@ -103,6 +137,7 @@ npm ci npm run typecheck npm run check:boundary npm run build # tests spawn the compiled daemon and hooks +npm run plugins:check npm test ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 49a8deb..265cbe6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,12 @@ Rules, enforced by `npm run check:boundary` and `scripts/verify-release.mjs`: Code that only the hosted service needs, such as recall heuristics, does not belong here. Code both sides need lives here; the hosted service imports it. +The Claude Code and Codex plugins (`plugins/`, `.claude-plugin/`, +`.agents/plugins/`) are generated from `src/runtime/host-hook-spec.ts` and are +installed from this repository by the hosts, not from the release tarball. They +only run the installed runtime's hook entrypoints; see +[HOST-PLUGINS.md](HOST-PLUGINS.md). + ## Operator extensions FriendMachine's local operator runs a superset build from the hosted diff --git a/docs/HOST-PLUGINS.md b/docs/HOST-PLUGINS.md new file mode 100644 index 0000000..e981434 --- /dev/null +++ b/docs/HOST-PLUGINS.md @@ -0,0 +1,203 @@ +# Claude Code and Codex plugins + +Claude Code and Codex install Crosshead as a plugin from this repository's +marketplace. The plugin replaces the hooks that `crosshead install claude-code` +and `crosshead install codex` used to write into host configuration. The runtime +is still installed by `curl -fsSL https://crosshead.sh/install | bash`. + +```sh +claude plugin marketplace add FriendMachine/crosshead +claude plugin install crosshead@crosshead +codex plugin marketplace add FriendMachine/crosshead +codex plugin add crosshead@crosshead +``` + +## What the plugin contains + +| File | Purpose | +| --- | --- | +| `hooks/hooks.json` | `SessionStart`, `PreCompact`, `PostCompact`, `UserPromptSubmit` and `Stop` hooks | +| `scripts/crosshead-hook` | POSIX `sh` launcher the hooks run | +| `skills/setup/SKILL.md` | `/crosshead:setup` (Claude Code) or `$crosshead:setup` (Codex), user-invoked only | +| `skills/setup/agents/openai.yaml` (Codex) | `allow_implicit_invocation: false` | +| `.claude-plugin/plugin.json` or `.codex-plugin/plugin.json` | Manifest | + +The launcher locates the runtime home the way the installer does: the first +non-empty `CROSSHEAD_HOME`, `CROSSHEAD_RUNTIME_HOME`, `ONLYBOTS_HOME` or +`ONLYBOTS_RUNTIME_HOME`, else `~/.crosshead` (or `~/.onlybots` when only that +exists). If both `~/.crosshead` and `~/.onlybots` exist as separate +installations, it prints the runtime's "choose one with `CROSSHEAD_HOME`" +notice instead of picking one. It then runs the installed +`crosshead-claude-code-hook` or `crosshead-codex-hook` with `--via plugin`. +These are the hook entrypoints the installer-written hooks use. The launcher +does not read host configuration: the runtime finds the adapter configuration +itself (the host's `CLAUDE_CONFIG_DIR`/`CODEX_HOME`, else the directory recorded +at install time), so `--claude-home` and `--codex-home` installs work. The +launcher never downloads, installs or updates code. Without a configured +runtime, it prints one line on `SessionStart`, prints nothing for other events, +and exits 0: + +```text +Crosshead isn't set up. To set it up, run /crosshead:setup. +Crosshead isn't set up. To set it up, type $crosshead:setup. +``` + +The line is a `systemMessage` only: the user sees it, and it is not added to +the model's context, so nothing invites the model to run setup. The setup skill +is user-invoked only (`disable-model-invocation: true` in Claude Code; +`policy.allow_implicit_invocation: false` in Codex, where an explicit +`$crosshead:setup` still works). If the runtime's hook wrapper predates plugin +dispatch (it lacks the `crosshead-hook-capability` marker), the line asks you +to run setup to update it, and the plugin stays inactive, so older +installer-written hooks keep working without duplicates. + +The launcher is POSIX `sh`. Windows is not supported. + +The setup skill explains what it is about to do, asks for consent, runs the +digest-checked installer, and then runs: + +```sh +crosshead quickstart --api-base https://crosshead.sh --adapter claude-code --workspace-root . --hook-mode plugin +crosshead quickstart --api-base https://crosshead.sh --adapter codex --workspace-root . --hook-mode plugin +``` + +`--hook-mode plugin` writes the adapter configuration and wrappers but no host +hooks. Codex additionally requires you to trust the plugin hooks in `/hooks`; +a new plugin version needs a new review. The plugins have no MCP server and +ask for no credentials. The Codex marketplace entry still carries +`policy.authentication: "ON_INSTALL"` because Codex requires both policy fields +on every entry and documents no other value. + +When `quickstart` finds a daemon already running, it compares the daemon's +release (its health response reports its entry script) with the installed one. +A daemon left on another release is restarted on the installed release through +the ownership-checked stop/start path. If that is not safe (a legacy daemon +without ownership records, or one hosting an organizational worker), quickstart +reports the mismatch instead. `--port` applies when quickstart starts or +restarts the daemon; if a current daemon already runs on another port, +quickstart reports that rather than ignoring the flag. + +## One hook table + +`src/runtime/host-hook-spec.ts` lists the hook events, matchers, Codex status +messages and timeouts. The installer-written hooks and the generated plugin +files are both rendered from it. `scripts/host-plugins.mjs` generates +`plugins/`, `.claude-plugin/marketplace.json` and +`.agents/plugins/marketplace.json`. CI runs `npm run plugins:check`, and +`tests/runtime/host-plugins.test.ts` fails if a checked-in file differs from the +generated output. Edit the spec or the generator, never the generated files, +then run `npm run build && npm run plugins:generate`. Raise +`CROSSHEAD_HOST_PLUGIN_VERSION` whenever generated content changes: hosts pin +installed plugins to their manifest version. `host-plugins.test.ts` pins a +SHA-256 of the generated `plugins/` content per version and fails when the +content changes without a new version entry. + +Claude Code reads `.claude-plugin/marketplace.json`. Codex reads +`.agents/plugins/marketplace.json` first and would otherwise fall back to the +Claude file. Both list one plugin, `crosshead@crosshead`, pointing at the +host's own plugin directory. Plugin files are not part of the install tarball +or the npm package. + +## Hook modes, migration and doctor + +`crosshead install claude-code|codex` takes `--hook-mode auto|plugin|settings` +(default `auto`, or the explicit mode of the previous install, so +`crosshead update` repeats it). Plugin mode is chosen only when the plugin is +enabled at user scope, because the mode is global and the hooks it removes can +cover every project: + +- Claude Code: `"enabledPlugins": {"crosshead@crosshead": true}` in the user + settings (`settings.json` in the Claude config directory). +- Codex: `[plugins."crosshead@crosshead"]` (enabled unless `enabled = false`) + in `$CODEX_HOME/config.toml`. + +Only this repository's marketplace id, `crosshead@crosshead`, is recognized. +When the plugin is enabled only for a project (Claude Code project or local +settings, or a Codex project `.codex/config.toml`, which Codex applies only to +trusted projects), the mode stays `settings` and the installer hooks stay in +place; in that project the plugin defers to them. `--hook-mode plugin` in that +case writes no new installer hooks and removes none. + +The mode is stored in the adapter configuration. In plugin mode the installer +removes the hooks it previously wrote (in the current workspace and the +previously configured one for Claude Code, and in `$CODEX_HOME/hooks.json` for +Codex). It keeps unrelated hooks, settings and top-level keys, and never deletes +a file: one left with nothing becomes `{}` (`{"hooks": {}}` for Codex). It +checks every file it would change before writing any, and refuses a symlinked +settings file without changing anything. Running it again changes nothing. + +Codex runs plugin hooks only after the user trusts them in `/hooks`, again for +each changed version. So the Codex installer keeps its old hooks until the +runtime has seen a plugin invocation (recorded in +`state/host-plugin-invocations.json`); rerun `crosshead install codex` after the +first session with trusted hooks to remove them. + +The hook entrypoint makes sure each event is handled once. "Installer hooks +loaded" means installer-written Crosshead hook commands (counted one by one; +empty arrays or groups never count) in exactly the files the host loads for the +session: + +- Claude Code: the user settings, and `.claude/settings.json` and + `.claude/settings.local.json` in the session's project directory + (`CLAUDE_PROJECT_DIR`), plus `.claude/settings.local.json` at the git root. + Ancestor directories are not checked, because Claude Code does not read them. +- Codex: `hooks.json` in the `CODEX_HOME` Codex ran with. + +| Invocation | Installer hooks loaded | Recorded mode | Result | +| --- | --- | --- | --- | +| installer-written hook | yes | either | runs | +| plugin | yes | either | skipped | +| plugin | no | either | runs | +| installer-written hook | no (stale session) | `plugin` | skipped | +| installer-written hook | no | `settings` | runs | + +`crosshead doctor` adds `claude_code_hooks` and `codex_hooks` checks for the +workspace (`--workspace-root`, else `CLAUDE_PROJECT_DIR`, else the current +directory). Each check reports the mode, where the plugin is enabled, and any +remaining installer-written hooks. The check fails for duplicate registrations, +for plugin mode with the plugin disabled or uninstalled, for settings mode with +no hooks for the workspace, and, for Codex, when the plugin is enabled but no +plugin invocation has been seen (it points to `/hooks` trust). It explains +project-only enablement instead of failing. Each failure names the fix. + +Uninstalling the plugin removes its hooks. Doctor then reports that no Crosshead +hooks are active. Reinstall the plugin, or run +`crosshead install --hook-mode settings` to go back to installer-written +hooks. + +`SessionStart` matches `startup|resume|clear|compact` in both modes: `/clear` +starts a fresh context like `startup`. Installer-written hooks from earlier +versions keep their old matcher until the next install. + +## Verification + +```sh +npm ci && npm run release:build +npm run plugins:check +bash scripts/test-host-plugins.sh +``` + +The wrapper runs one disposable container (2 CPUs, 3 GiB RAM, no extra swap, +bounded tmpfs). It uses the pinned Node image with Claude Code `2.1.319` and +Codex `0.167.0`. `scripts/smoke-host-plugins.mjs` runs there as UID 1000 with +the repository read-only. A local fixture stands in for the Anthropic and +OpenAI APIs and serves this repository's installer +(`docs/security-review/install.sh`), with its digest pointed at the locally +built release. Hosted Crosshead calls go to an unreachable local address. The +real CLIs run headless sessions (`claude -p`, `codex exec`) that fire the +hooks. The check covers: + +- `claude plugin validate --strict` and installation from the marketplace + files. +- The setup line when no runtime is installed. +- Setup without host hooks. +- One capture per session with installer hooks, with installer hooks plus the + plugin, and after migration. +- Doctor output in each of those states. +- No hooks left after the plugins are uninstalled. + +It does not cover model-driven skill invocation or the Codex trust dialog (runs +use `--dangerously-bypass-hook-trust`); the unit tests cover the untrusted +window with direct hook invocations. It also does not cover identity +registration with the hosted service or native compaction: the compaction hooks +are driven with representative payloads. diff --git a/package-lock.json b/package-lock.json index a318652..46ea5cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.8", + "version": "0.5.0-alpha.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.8", + "version": "0.5.0-alpha.9", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.7", diff --git a/package.json b/package.json index d88552e..ebf3b16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.8", + "version": "0.5.0-alpha.9", "description": "Crosshead local runtime: persistent memory for Claude Code, Codex, Pi, Hermes and OpenCode. CLI, daemon, host adapters and hooks.", "license": "Apache-2.0", "private": true, @@ -24,6 +24,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "check:boundary": "node scripts/check-boundary.mjs", + "plugins:generate": "node scripts/host-plugins.mjs", + "plugins:check": "node scripts/host-plugins.mjs --check", "release:build": "npm run build && node scripts/build-release.mjs", "release:verify": "node scripts/verify-release.mjs" }, diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json new file mode 100644 index 0000000..3a6b575 --- /dev/null +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "crosshead", + "displayName": "Crosshead", + "version": "1.0.1", + "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", + "author": { + "name": "FriendMachine", + "url": "https://crosshead.sh" + }, + "homepage": "https://crosshead.sh", + "repository": "https://github.com/FriendMachine/crosshead", + "license": "Apache-2.0", + "keywords": [ + "memory", + "continuity", + "hooks", + "crosshead" + ] +} diff --git a/plugins/claude-code/hooks/hooks.json b/plugins/claude-code/hooks/hooks.json new file mode 100644 index 0000000..49d5791 --- /dev/null +++ b/plugins/claude-code/hooks/hooks.json @@ -0,0 +1,58 @@ +{ + "description": "Crosshead persistent memory hooks. They run the locally installed Crosshead runtime.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact|fork", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code SessionStart" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code PreCompact" + } + ] + } + ], + "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code PostCompact" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code UserPromptSubmit" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code Stop" + } + ] + } + ] + } +} diff --git a/plugins/claude-code/scripts/crosshead-hook b/plugins/claude-code/scripts/crosshead-hook new file mode 100755 index 0000000..2e4fc50 --- /dev/null +++ b/plugins/claude-code/scripts/crosshead-hook @@ -0,0 +1,50 @@ +#!/bin/sh +# Crosshead host plugin hook launcher. Generated by scripts/host-plugins.mjs from +# src/runtime/host-hook-spec.ts; do not edit. +# +# Runs the hook entrypoint of the Crosshead runtime installed by +# `curl -fsSL https://crosshead.sh/install | bash`. It never downloads or +# installs code. It only locates the runtime home (as the installer does); the +# runtime finds its own host configuration, including --claude-home and +# --codex-home installs. Without a usable runtime it prints a notice on +# SessionStart and exits 0 so the session is never blocked. POSIX sh only; +# Windows is not supported. +host="$1" +event="$2" +case "$host" in + claude-code) + hook=crosshead-claude-code-hook + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up. To set it up, run /crosshead:setup."}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates this plugin. To update it, run /crosshead:setup."}' + ;; + codex) + hook=crosshead-codex-hook + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up. To set it up, type $crosshead:setup."}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates this plugin. To update it, type $crosshead:setup."}' + ;; + *) exit 0 ;; +esac +conflict_notice='{"systemMessage":"Crosshead found separate ~/.crosshead and ~/.onlybots installations. Set CROSSHEAD_HOME to choose one; no state was moved."}' +notice() { + cat > /dev/null 2>&1 || true + if [ "$event" = SessionStart ]; then printf '%s\n' "$1"; fi + exit 0 +} +# Empty variables count as unset, as in the installer and the runtime. +home="${CROSSHEAD_HOME:-${CROSSHEAD_RUNTIME_HOME:-${ONLYBOTS_HOME:-${ONLYBOTS_RUNTIME_HOME:-}}}}" +if [ -z "$home" ]; then + if [ -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ] && ! [ "$HOME/.crosshead" -ef "$HOME/.onlybots" ]; then + notice "$conflict_notice" + fi + if [ ! -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi +fi +wrapper="$home/bin/$hook" +[ -x "$wrapper" ] || notice "$setup_notice" +# A runtime that predates plugin dispatch would treat this call as an installer hook. +grep -q 'crosshead-hook-capability: plugin-dispatch-v1' "$wrapper" 2> /dev/null || notice "$update_notice" +# The Codex hook wrapper sets CODEX_HOME to the configured home; keep the one Codex ran with. +if [ "$host" = codex ]; then + CROSSHEAD_HOST_CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" + export CROSSHEAD_HOST_CODEX_HOME +fi +exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.0.1 diff --git a/plugins/claude-code/skills/setup/SKILL.md b/plugins/claude-code/skills/setup/SKILL.md new file mode 100644 index 0000000..c2c4a48 --- /dev/null +++ b/plugins/claude-code/skills/setup/SKILL.md @@ -0,0 +1,55 @@ +--- +name: setup +description: Install or update the Crosshead runtime and connect it to Claude Code. Run only when the user invokes it. +disable-model-invocation: true +--- + +# Set up Crosshead for Claude Code + + + +This plugin contains only hooks and this skill. The hooks run the Crosshead +runtime installed on this machine (`~/.crosshead` unless `CROSSHEAD_HOME` is +set). One runtime, daemon and identity is shared by Claude Code, Codex, Hermes, +OpenCode and Pi. Setup installs that runtime and connects it to Claude Code. + +Run each command with your shell tool. Use `"${CROSSHEAD_HOME:-$HOME/.crosshead}/bin/crosshead"` +for `crosshead` if it is not on PATH. + +1. Check the current state: `crosshead doctor`. If the runtime is installed and + the `claude_code_hooks` check is ok with `mode=plugin`, setup is already + done; report that and stop. +2. Explain the next two steps to the user and ask for explicit consent. Do not + continue without a clear yes. + - The installer is downloaded from https://crosshead.sh/install and run with + bash. It downloads one release tarball, checks its SHA-256 against the + digest pinned in the installer, installs three npm dependencies with install + scripts disabled, and writes wrappers to `~/.crosshead/bin`. Runtimes + never update themselves. + - Quickstart creates a local identity if there is none (its private key stays + in `~/.crosshead`), registers it with the hosted service at + https://crosshead.sh and starts the local daemon. An existing identity is + reused. +3. Install or update the runtime: + + ```sh + curl -fsSL https://crosshead.sh/install | bash + ``` + +4. Connect Claude Code: + + ```sh + crosshead quickstart --api-base https://crosshead.sh --adapter claude-code --workspace-root . --hook-mode plugin + ``` + + `--hook-mode plugin` keeps quickstart from writing its own Claude Code hooks + (this plugin provides them). When the plugin is enabled for the user (the + default for a plugin install) it also removes Crosshead hooks an earlier + install wrote. Either way each event is captured once. +5. Verify with `crosshead doctor`. Expect `claude_code_hooks` to report `mode=plugin`. + Report any other failed check to the user verbatim; do not work around it. +6. Start a new Claude Code session (or run /reload-plugins) so the hooks load with the runtime in place. + +Other hosts and scripts keep using `curl -fsSL https://crosshead.sh/install | bash` +followed by `crosshead quickstart --adapter `. Remove Crosshead from +Claude Code by uninstalling this plugin; `crosshead uninstall` removes the runtime. diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..6981417 --- /dev/null +++ b/plugins/codex/.codex-plugin/plugin.json @@ -0,0 +1,31 @@ +{ + "name": "crosshead", + "version": "1.0.1", + "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", + "author": { + "name": "FriendMachine", + "url": "https://crosshead.sh" + }, + "homepage": "https://crosshead.sh", + "repository": "https://github.com/FriendMachine/crosshead", + "license": "Apache-2.0", + "keywords": [ + "memory", + "continuity", + "hooks", + "crosshead" + ], + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "interface": { + "displayName": "Crosshead", + "shortDescription": "Persistent memory shared across coding agents", + "developerName": "FriendMachine", + "category": "Productivity", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://crosshead.sh" + } +} diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json new file mode 100644 index 0000000..5e259b1 --- /dev/null +++ b/plugins/codex/hooks/hooks.json @@ -0,0 +1,67 @@ +{ + "description": "Crosshead persistent memory hooks. They run the locally installed Crosshead runtime.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact|fork", + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex SessionStart", + "statusMessage": "Crosshead session sync" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex PreCompact", + "statusMessage": "Crosshead pre-compact checkpoint", + "timeout": 30 + } + ] + } + ], + "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex PostCompact", + "statusMessage": "Crosshead post-compact sync", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex UserPromptSubmit", + "statusMessage": "Crosshead context", + "timeout": 45 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex Stop", + "statusMessage": "Crosshead memory sync", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/codex/scripts/crosshead-hook b/plugins/codex/scripts/crosshead-hook new file mode 100755 index 0000000..2e4fc50 --- /dev/null +++ b/plugins/codex/scripts/crosshead-hook @@ -0,0 +1,50 @@ +#!/bin/sh +# Crosshead host plugin hook launcher. Generated by scripts/host-plugins.mjs from +# src/runtime/host-hook-spec.ts; do not edit. +# +# Runs the hook entrypoint of the Crosshead runtime installed by +# `curl -fsSL https://crosshead.sh/install | bash`. It never downloads or +# installs code. It only locates the runtime home (as the installer does); the +# runtime finds its own host configuration, including --claude-home and +# --codex-home installs. Without a usable runtime it prints a notice on +# SessionStart and exits 0 so the session is never blocked. POSIX sh only; +# Windows is not supported. +host="$1" +event="$2" +case "$host" in + claude-code) + hook=crosshead-claude-code-hook + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up. To set it up, run /crosshead:setup."}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates this plugin. To update it, run /crosshead:setup."}' + ;; + codex) + hook=crosshead-codex-hook + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up. To set it up, type $crosshead:setup."}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates this plugin. To update it, type $crosshead:setup."}' + ;; + *) exit 0 ;; +esac +conflict_notice='{"systemMessage":"Crosshead found separate ~/.crosshead and ~/.onlybots installations. Set CROSSHEAD_HOME to choose one; no state was moved."}' +notice() { + cat > /dev/null 2>&1 || true + if [ "$event" = SessionStart ]; then printf '%s\n' "$1"; fi + exit 0 +} +# Empty variables count as unset, as in the installer and the runtime. +home="${CROSSHEAD_HOME:-${CROSSHEAD_RUNTIME_HOME:-${ONLYBOTS_HOME:-${ONLYBOTS_RUNTIME_HOME:-}}}}" +if [ -z "$home" ]; then + if [ -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ] && ! [ "$HOME/.crosshead" -ef "$HOME/.onlybots" ]; then + notice "$conflict_notice" + fi + if [ ! -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi +fi +wrapper="$home/bin/$hook" +[ -x "$wrapper" ] || notice "$setup_notice" +# A runtime that predates plugin dispatch would treat this call as an installer hook. +grep -q 'crosshead-hook-capability: plugin-dispatch-v1' "$wrapper" 2> /dev/null || notice "$update_notice" +# The Codex hook wrapper sets CODEX_HOME to the configured home; keep the one Codex ran with. +if [ "$host" = codex ]; then + CROSSHEAD_HOST_CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" + export CROSSHEAD_HOST_CODEX_HOME +fi +exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.0.1 diff --git a/plugins/codex/skills/setup/SKILL.md b/plugins/codex/skills/setup/SKILL.md new file mode 100644 index 0000000..7be2a33 --- /dev/null +++ b/plugins/codex/skills/setup/SKILL.md @@ -0,0 +1,57 @@ +--- +name: setup +description: Install or update the Crosshead runtime and connect it to Codex. Run only when the user invokes it. +--- + +# Set up Crosshead for Codex + + + +This plugin contains only hooks and this skill. The hooks run the Crosshead +runtime installed on this machine (`~/.crosshead` unless `CROSSHEAD_HOME` is +set). One runtime, daemon and identity is shared by Claude Code, Codex, Hermes, +OpenCode and Pi. Setup installs that runtime and connects it to Codex. + +Run each command with your shell tool. Use `"${CROSSHEAD_HOME:-$HOME/.crosshead}/bin/crosshead"` +for `crosshead` if it is not on PATH. + +1. Check the current state: `crosshead doctor`. If the runtime is installed and + the `codex_hooks` check is ok with `mode=plugin`, setup is already + done; report that and stop. +2. Explain the next two steps to the user and ask for explicit consent. Do not + continue without a clear yes. + - The installer is downloaded from https://crosshead.sh/install and run with + bash. It downloads one release tarball, checks its SHA-256 against the + digest pinned in the installer, installs three npm dependencies with install + scripts disabled, and writes wrappers to `~/.crosshead/bin`. Runtimes + never update themselves. + - Quickstart creates a local identity if there is none (its private key stays + in `~/.crosshead`), registers it with the hosted service at + https://crosshead.sh and starts the local daemon. An existing identity is + reused. +3. Install or update the runtime: + + ```sh + curl -fsSL https://crosshead.sh/install | bash + ``` + +4. Connect Codex: + + ```sh + crosshead quickstart --api-base https://crosshead.sh --adapter codex --workspace-root . --hook-mode plugin + ``` + + `--hook-mode plugin` keeps quickstart from writing its own Codex hooks + (this plugin provides them). When the plugin is enabled for the user (the + default for a plugin install) it also removes Crosshead hooks an earlier + install wrote. Either way each event is captured once. +5. Verify with `crosshead doctor`. Expect `codex_hooks` to report `mode=plugin`. + Until Codex has run the plugin hooks, that check warns that no plugin hook + invocation has been seen; the next step resolves it. + Report any other failed check to the user verbatim; do not work around it. +6. Codex runs plugin hooks only after you trust them. Ask the user to open `/hooks`, review the Crosshead hooks and trust them. A changed plugin version needs a new review. Until Codex has run them, quickstart keeps any Crosshead hooks an earlier install wrote; rerun `crosshead install codex` after the first session to remove those. +7. Start a new Codex session so the hooks load with the runtime in place. + +Other hosts and scripts keep using `curl -fsSL https://crosshead.sh/install | bash` +followed by `crosshead quickstart --adapter `. Remove Crosshead from +Codex by uninstalling this plugin; `crosshead uninstall` removes the runtime. diff --git a/plugins/codex/skills/setup/agents/openai.yaml b/plugins/codex/skills/setup/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/plugins/codex/skills/setup/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/scripts/host-plugins.mjs b/scripts/host-plugins.mjs new file mode 100644 index 0000000..883070b --- /dev/null +++ b/scripts/host-plugins.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node +// Generates the Claude Code and Codex plugins and their marketplace files from +// the hook table in src/runtime/host-hook-spec.ts, the same table the legacy +// `crosshead install claude-code|codex` writer uses. +// +// node scripts/host-plugins.mjs write the generated files +// node scripts/host-plugins.mjs --check fail if a checked-in file differs +// +// Uses the compiled spec in dist/ (run `npm run build` first). The plugins are +// thin: hooks, a setup skill and a launcher that runs the installed runtime. +// They never bundle, download or install runtime code. +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); + +const json = (value) => `${JSON.stringify(value, null, 2)}\n`; + +const author = { name: "FriendMachine", url: "https://crosshead.sh" }; +const homepage = "https://crosshead.sh"; +const repository = "https://github.com/FriendMachine/crosshead"; +const description = + "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi."; +const keywords = ["memory", "continuity", "hooks", "crosshead"]; + +const HOSTS = { + "claude-code": { + dir: "plugins/claude-code", + label: "Claude Code", + hookWrapper: "crosshead-claude-code-hook", + doctorCheck: "claude_code_hooks", + activate: "Start a new Claude Code session (or run /reload-plugins) so the hooks load with the runtime in place.", + trust: null, + }, + codex: { + dir: "plugins/codex", + label: "Codex", + hookWrapper: "crosshead-codex-hook", + doctorCheck: "codex_hooks", + activate: "Start a new Codex session so the hooks load with the runtime in place.", + trust: + "Codex runs plugin hooks only after you trust them. Ask the user to open `/hooks`, review the Crosshead hooks and trust them. A changed plugin version needs a new review. Until Codex has run them, quickstart keeps any Crosshead hooks an earlier install wrote; rerun `crosshead install codex` after the first session to remove those.", + }, +}; + +const shQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; + +// User-visible only: no additionalContext, so the model is not invited to run setup. +const noticeJson = (message) => JSON.stringify({ systemMessage: message }); + +const renderLauncher = (spec) => { + const cases = Object.entries(HOSTS) + .map( + ([host, info]) => ` ${host}) + hook=${info.hookWrapper} + setup_notice=${shQuote(noticeJson(spec.pluginSetupHint(host)))} + update_notice=${shQuote(noticeJson(spec.pluginUpdateHint(host)))} + ;;` + ) + .join("\n"); + return `#!/bin/sh +# Crosshead host plugin hook launcher. Generated by scripts/host-plugins.mjs from +# src/runtime/host-hook-spec.ts; do not edit. +# +# Runs the hook entrypoint of the Crosshead runtime installed by +# \`curl -fsSL https://crosshead.sh/install | bash\`. It never downloads or +# installs code. It only locates the runtime home (as the installer does); the +# runtime finds its own host configuration, including --claude-home and +# --codex-home installs. Without a usable runtime it prints a notice on +# SessionStart and exits 0 so the session is never blocked. POSIX sh only; +# Windows is not supported. +host="$1" +event="$2" +case "$host" in +${cases} + *) exit 0 ;; +esac +conflict_notice=${shQuote(noticeJson(spec.PLUGIN_HOME_CONFLICT_HINT))} +notice() { + cat > /dev/null 2>&1 || true + if [ "$event" = SessionStart ]; then printf '%s\\n' "$1"; fi + exit 0 +} +# Empty variables count as unset, as in the installer and the runtime. +home="\${CROSSHEAD_HOME:-\${CROSSHEAD_RUNTIME_HOME:-\${ONLYBOTS_HOME:-\${ONLYBOTS_RUNTIME_HOME:-}}}}" +if [ -z "$home" ]; then + if [ -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ] && ! [ "$HOME/.crosshead" -ef "$HOME/.onlybots" ]; then + notice "$conflict_notice" + fi + if [ ! -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi +fi +wrapper="$home/bin/$hook" +[ -x "$wrapper" ] || notice "$setup_notice" +# A runtime that predates plugin dispatch would treat this call as an installer hook. +grep -q '${spec.PLUGIN_DISPATCH_CAPABILITY}' "$wrapper" 2> /dev/null || notice "$update_notice" +# The Codex hook wrapper sets CODEX_HOME to the configured home; keep the one Codex ran with. +if [ "$host" = codex ]; then + CROSSHEAD_HOST_CODEX_HOME="\${CODEX_HOME:-$HOME/.codex}" + export CROSSHEAD_HOST_CODEX_HOME +fi +exec "$wrapper" "$event" --home "$home" ${spec.PLUGIN_HOOK_VIA_FLAG.join(" ")} ${spec.PLUGIN_VERSION_FLAG} ${spec.CROSSHEAD_HOST_PLUGIN_VERSION} +`; +}; + +const renderSetupSkill = (host) => { + const info = HOSTS[host]; + return `--- +name: setup +description: Install or update the Crosshead runtime and connect it to ${info.label}. Run only when the user invokes it. +${host === "claude-code" ? "disable-model-invocation: true\n" : ""}--- + +# Set up Crosshead for ${info.label} + + + +This plugin contains only hooks and this skill. The hooks run the Crosshead +runtime installed on this machine (\`~/.crosshead\` unless \`CROSSHEAD_HOME\` is +set). One runtime, daemon and identity is shared by Claude Code, Codex, Hermes, +OpenCode and Pi. Setup installs that runtime and connects it to ${info.label}. + +Run each command with your shell tool. Use \`"\${CROSSHEAD_HOME:-$HOME/.crosshead}/bin/crosshead"\` +for \`crosshead\` if it is not on PATH. + +1. Check the current state: \`crosshead doctor\`. If the runtime is installed and + the \`${info.doctorCheck}\` check is ok with \`mode=plugin\`, setup is already + done; report that and stop. +2. Explain the next two steps to the user and ask for explicit consent. Do not + continue without a clear yes. + - The installer is downloaded from https://crosshead.sh/install and run with + bash. It downloads one release tarball, checks its SHA-256 against the + digest pinned in the installer, installs three npm dependencies with install + scripts disabled, and writes wrappers to \`~/.crosshead/bin\`. Runtimes + never update themselves. + - Quickstart creates a local identity if there is none (its private key stays + in \`~/.crosshead\`), registers it with the hosted service at + https://crosshead.sh and starts the local daemon. An existing identity is + reused. +3. Install or update the runtime: + + \`\`\`sh + curl -fsSL https://crosshead.sh/install | bash + \`\`\` + +4. Connect ${info.label}: + + \`\`\`sh + crosshead quickstart --api-base https://crosshead.sh --adapter ${host} --workspace-root . --hook-mode plugin + \`\`\` + + \`--hook-mode plugin\` keeps quickstart from writing its own ${info.label} hooks + (this plugin provides them). When the plugin is enabled for the user (the + default for a plugin install) it also removes Crosshead hooks an earlier + install wrote. Either way each event is captured once. +5. Verify with \`crosshead doctor\`. Expect \`${info.doctorCheck}\` to report \`mode=plugin\`.${ + info.trust + ? ` + Until Codex has run the plugin hooks, that check warns that no plugin hook + invocation has been seen; the next step resolves it.` + : "" + } + Report any other failed check to the user verbatim; do not work around it. +${info.trust ? `6. ${info.trust}\n7. ${info.activate}` : `6. ${info.activate}`} + +Other hosts and scripts keep using \`curl -fsSL https://crosshead.sh/install | bash\` +followed by \`crosshead quickstart --adapter \`. Remove Crosshead from +${info.label} by uninstalling this plugin; \`crosshead uninstall\` removes the runtime. +`; +}; + +/** Map of repository-relative path to file contents. `spec` is host-hook-spec. */ +export const renderHostPluginFiles = (spec) => { + const version = spec.CROSSHEAD_HOST_PLUGIN_VERSION; + const name = spec.CROSSHEAD_PLUGIN_NAME; + const launcher = renderLauncher(spec); + const files = { + ".claude-plugin/marketplace.json": json({ + name: spec.CROSSHEAD_MARKETPLACE_NAME, + owner: author, + metadata: { description: "Crosshead plugins for Claude Code. Codex reads .agents/plugins/marketplace.json." }, + plugins: [{ name, source: `./${HOSTS["claude-code"].dir}`, description, version }], + }), + ".agents/plugins/marketplace.json": json({ + name: spec.CROSSHEAD_MARKETPLACE_NAME, + interface: { displayName: "Crosshead" }, + plugins: [ + { + name, + source: { source: "local", path: `./${HOSTS.codex.dir}` }, + // Codex requires both policy fields on every entry; ON_INSTALL is the only + // documented authentication value. The plugin itself asks for no credentials. + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Productivity", + }, + ], + }), + [`${HOSTS["claude-code"].dir}/.claude-plugin/plugin.json`]: json({ + name, + displayName: "Crosshead", + version, + description, + author, + homepage, + repository, + license: "Apache-2.0", + keywords, + }), + [`${HOSTS.codex.dir}/.codex-plugin/plugin.json`]: json({ + name, + version, + description, + author, + homepage, + repository, + license: "Apache-2.0", + keywords, + skills: "./skills/", + hooks: "./hooks/hooks.json", + interface: { + displayName: "Crosshead", + shortDescription: "Persistent memory shared across coding agents", + developerName: "FriendMachine", + category: "Productivity", + capabilities: ["Read", "Write"], + websiteURL: homepage, + }, + }), + }; + for (const host of Object.keys(HOSTS)) { + const dir = HOSTS[host].dir; + files[`${dir}/hooks/hooks.json`] = spec.renderPluginHooksFile(host); + files[`${dir}/${spec.PLUGIN_HOOK_LAUNCHER}`] = launcher; + files[`${dir}/skills/setup/SKILL.md`] = renderSetupSkill(host); + } + // Codex has no SKILL.md field for this; agents/openai.yaml keeps the setup + // skill out of implicit (model-chosen) invocation. `$crosshead:setup` still works. + files[`${HOSTS.codex.dir}/skills/setup/agents/openai.yaml`] = "policy:\n allow_implicit_invocation: false\n"; + return files; +}; + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); +if (isMain) { + const specPath = path.join(root, "dist", "runtime", "host-hook-spec.js"); + if (!existsSync(specPath)) { + console.error("Missing dist/runtime/host-hook-spec.js; run npm run build first."); + process.exit(1); + } + const spec = require(specPath); + const files = renderHostPluginFiles(spec); + const check = process.argv.includes("--check"); + const stale = []; + for (const [relative, content] of Object.entries(files)) { + const target = path.join(root, relative); + const current = existsSync(target) ? readFileSync(target, "utf8") : null; + if (current === content) continue; + if (check) { + stale.push(relative); + continue; + } + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, content); + if (relative.endsWith(spec.PLUGIN_HOOK_LAUNCHER)) chmodSync(target, 0o755); + console.log(`wrote ${relative}`); + } + if (stale.length > 0) { + console.error(`Generated host plugin files are stale; run node scripts/host-plugins.mjs:\n${stale.join("\n")}`); + process.exit(1); + } + if (check) console.log(`Host plugin files match the hook spec (${Object.keys(files).length} files).`); +} + diff --git a/scripts/smoke-host-plugins.mjs b/scripts/smoke-host-plugins.mjs new file mode 100644 index 0000000..f60ffac --- /dev/null +++ b/scripts/smoke-host-plugins.mjs @@ -0,0 +1,392 @@ +// Run inside a disposable container with the real Claude Code and Codex CLIs on +// PATH, this repository read-only at /crosshead (with node_modules and a local +// `npm run release:build` in release/), and a writable /test. No credentials: +// a local fixture stands in for the Anthropic and OpenAI APIs and for the +// crosshead.sh installer and release download. Hosted Crosshead calls go to an +// unreachable local address; nothing reaches a production service. +// +// Proves: plugins install from this repository's marketplace files; the +// plugins validate; without a runtime SessionStart prints one setup line and +// exits 0; the setup skill's commands connect the runtime without writing +// legacy hooks; with a legacy install present each event is captured exactly +// once before and after migration; uninstalling the plugins leaves no hooks. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn, spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const { generateSecretKey, getPublicKey, nip19 } = require("nostr-tools"); +const repo = path.resolve(process.env.CROSSHEAD_SMOKE_REPO ?? fileURLToPath(new URL("..", import.meta.url))); +const persistence = require(path.join(repo, "dist/runtime/runtime-persistence.js")); +const spec = require(path.join(repo, "dist/runtime/host-hook-spec.js")); +const root = process.env.CROSSHEAD_SMOKE_ROOT ?? "/test"; +const logDir = path.join(root, "logs"); +mkdirSync(logDir, { recursive: true }); +const results = { checks: [], notExercised: [] }; +const check = (label) => { + results.checks.push(label); + console.log(`ok - ${label}`); +}; + +// ---- Fixture: model APIs, installer and release download ----------------- +const requests = []; +const replyText = "Fixture reply: findings recorded, next step is the transport round-trip check."; +const pkg = JSON.parse(readFileSync(path.join(repo, "package.json"), "utf8")); +const tarball = readFileSync(path.join(repo, "release", `crosshead-runtime-install-${pkg.version}.tgz`)); +const tarballSha = createHash("sha256").update(tarball).digest("hex"); +const sse = (res, events) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + for (const [event, data] of events) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + res.end(); +}; +let installerScript = ""; +const server = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + requests.push({ url: req.url, body: raw }); + appendFileSync(path.join(logDir, "fixture-requests.log"), `${req.method} ${req.url} ${raw.length}\n`); + if (req.url === "/install") return res.end(installerScript); + if (req.url === "/install/releases/crosshead-runtime.tgz") return res.end(tarball); + let body = {}; + try { body = JSON.parse(raw); } catch {} + if (req.url.startsWith("/v1/messages/count_tokens")) return res.end(JSON.stringify({ input_tokens: 10 })); + if (req.url.startsWith("/v1/messages")) { + const message = { id: "msg_fixture", type: "message", role: "assistant", model: body.model ?? "fixture", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 1 } }; + if (!body.stream) return res.end(JSON.stringify({ ...message, content: [{ type: "text", text: replyText }], stop_reason: "end_turn" })); + return sse(res, [ + ["message_start", { type: "message_start", message }], + ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }], + ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: replyText } }], + ["content_block_stop", { type: "content_block_stop", index: 0 }], + ["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 5 } }], + ["message_stop", { type: "message_stop" }], + ]); + } + if (req.url.startsWith("/v1/responses")) { + const item = { id: "msg_fixture", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: replyText, annotations: [] }] }; + const response = { id: "resp_fixture", object: "response", created_at: 1_800_000_000, status: "in_progress", model: body.model ?? "fixture", output: [] }; + return sse(res, [ + ["response.created", { type: "response.created", response }], + ["response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { ...item, status: "in_progress", content: [] } }], + ["response.output_text.delta", { type: "response.output_text.delta", item_id: item.id, output_index: 0, content_index: 0, delta: replyText }], + ["response.output_item.done", { type: "response.output_item.done", output_index: 0, item }], + ["response.completed", { type: "response.completed", response: { ...response, status: "completed", output: [item], usage: { input_tokens: 10, input_tokens_details: { cached_tokens: 0 }, output_tokens: 5, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 15 } } }], + ]); + } + res.writeHead(404); + res.end("{}"); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const base = `http://127.0.0.1:${server.address().port}`; +// The digest-checked installer from this repository, pointed at the local release. +installerScript = readFileSync(path.join(repo, "docs", "security-review", "install.sh"), "utf8") + .replace(/^BASE_URL=.*$/m, `BASE_URL="${base}"`) + .replace(/^RELEASE_ID=.*$/m, `RELEASE_ID="${pkg.version}-${tarballSha.slice(0, 12)}"`) + .replace(/^RELEASE_URL=.*$/m, `RELEASE_URL="${base}/install/releases/onlybots-runtime.tgz"`) + .replace(/^CROSSHEAD_RELEASE_URL=.*$/m, `CROSSHEAD_RELEASE_URL="${base}/install/releases/crosshead-runtime.tgz"`) + .replace(/^RELEASE_SHA256=.*$/m, `RELEASE_SHA256="${tarballSha}"`); + +// ---- Process helpers ------------------------------------------------------- +const homeEnv = (home) => ({ + // ~/.crosshead/bin stays off PATH: its claude/codex launchers check crosshead.sh for updates. + PATH: process.env.PATH, + HOME: home, + LANG: "C.UTF-8", + CROSSHEAD_HOT_SYNC_ENABLED: "false", + ANTHROPIC_BASE_URL: base, + ANTHROPIC_API_KEY: "fixture-not-a-real-key", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + DISABLE_AUTOUPDATER: "1", + FIXTURE_API_KEY: "fixture-only", + DO_NOT_TRACK: "1", +}); +let logIndex = 0; +const run = async (label, home, command, args, { cwd = home, input = "", allowFailure = false, env = {} } = {}) => { + const child = spawn(command, args, { cwd, env: { ...homeEnv(home), ...env }, stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.stdin.end(input); + const timer = setTimeout(() => child.kill("SIGKILL"), 150_000); + const code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }).finally(() => clearTimeout(timer)); + const file = path.join(logDir, `${String(++logIndex).padStart(2, "0")}-${label}.log`); + writeFileSync(file, `$ ${command} ${args.join(" ")}\nexit=${code}\n--- stdout\n${stdout}\n--- stderr\n${stderr}`); + if (!allowFailure) assert.equal(code, 0, `${label} exited ${code}; see ${file}`); + return { code, stdout, stderr, file }; +}; + +const makeHome = (name) => { + const home = path.join(root, name); + const project = path.join(home, "project"); + mkdirSync(project, { recursive: true }); + // The Claude CLI's project-trust prompt is skipped in -p mode; record onboarding as done. + writeFileSync(path.join(home, ".claude.json"), JSON.stringify({ hasCompletedOnboarding: true, projects: { [project]: { hasTrustDialogAccepted: true } } })); + mkdirSync(path.join(home, ".codex"), { recursive: true }); + writeFileSync( + path.join(home, ".codex", "config.toml"), + `model = "fixture-model"\nmodel_provider = "fixture"\n\n[model_providers.fixture]\nname = "Fixture"\nbase_url = "${base}/v1"\nwire_api = "responses"\nenv_key = "FIXTURE_API_KEY"\n` + ); + return { home, project }; +}; + +const addMarketplaces = async (home) => { + await run("claude-marketplace-add", home, "claude", ["plugin", "marketplace", "add", repo]); + await run("claude-plugin-install", home, "claude", ["plugin", "install", "crosshead@crosshead"]); + await run("codex-marketplace-add", home, "codex", ["plugin", "marketplace", "add", repo]); + await run("codex-plugin-add", home, "codex", ["plugin", "add", "crosshead@crosshead"]); + const list = await run("codex-plugin-list", home, "codex", ["plugin", "list"]); + assert.match(list.stdout, /\.agents\/plugins\/marketplace\.json/); + assert.match(list.stdout, /crosshead@crosshead\s+installed/); + assert(list.stdout.includes(path.join(repo, "plugins", "codex")), "Codex loaded the wrong plugin source"); + const claudeList = JSON.parse((await run("claude-plugin-list", home, "claude", ["plugin", "list", "--json"])).stdout); + assert(claudeList.some((entry) => entry.id === "crosshead@crosshead" && entry.enabled === true), "Claude plugin not enabled"); +}; + +const seedProfile = (home) => { + // Stands in for `quickstart` identity creation, which registers with the hosted service. + const paths = persistence.ensureRuntimeHomeLayout(path.join(home, ".crosshead")); + if (persistence.loadProfile(paths, "default")) return; + const key = generateSecretKey(); + const now = new Date().toISOString(); + persistence.saveProfile(paths, { + name: "default", apiBase: "http://127.0.0.1:9", npub: nip19.npubEncode(getPublicKey(key)), memoryMode: "assist", workspaceScope: "repo", + features: { feedback: false, relay: false, coldSync: false, autoWarmPromotion: false }, secureStoreMode: "file", createdAt: now, updatedAt: now, + }); + persistence.saveSecret(paths, "default", { nsec: nip19.nsecEncode(key), session: null }); + persistence.saveRuntimeConfig(paths, { ...persistence.loadRuntimeConfig(paths), defaultProfile: "default" }); +}; + +const installRuntime = async (home) => { + await run("installer", home, "bash", ["-c", `curl -fsSL ${base}/install | bash`]); + assert(existsSync(path.join(home, ".crosshead", "bin", "crosshead"))); + await run("update-pin", home, xhd(home), ["update", "--pin"]); + seedProfile(home); +}; + +const xhd = (home) => path.join(home, ".crosshead", "bin", "crosshead"); +const lifecycle = (home) => { + const file = path.join(home, ".crosshead", "state", "lifecycle-events.jsonl"); + return existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)) : []; +}; +const eventCounts = (events, sessionId) => { + const counts = {}; + for (const event of events) if (!sessionId || event.sessionId === sessionId) counts[event.type] = (counts[event.type] ?? 0) + 1; + return counts; +}; + +// One real headless session per host. Returns the host's session id and its hook-run log. +const claudeSession = async (label, home, project) => { + const debugFile = path.join(logDir, `claude-debug-${label}.txt`); + const result = await run(`claude-session-${label}`, home, "claude", ["-p", "Investigate the duration serializer and report findings.", "--output-format", "json", "--debug-file", debugFile], { cwd: project }); + const sessionId = JSON.parse(result.stdout.trim().split("\n").at(-1)).session_id; + const hookRuns = readFileSync(debugFile, "utf8").split("\n").filter((line) => /Hooks: \S+ \[.*\] .*finished with status/.test(line)); + return { sessionId, hookRuns, result }; +}; +const codexSession = async (label, home, project) => { + // The human-readable output lists each hook run ("hook: Stop Completed"); --json omits them. + const result = await run(`codex-session-${label}`, home, "codex", ["exec", "--skip-git-repo-check", "--dangerously-bypass-hook-trust", "Investigate the duration serializer and report findings."], { cwd: project }); + const output = `${result.stdout}\n${result.stderr}`; + const sessionId = /^session id: (\S+)/m.exec(output)?.[1] ?? null; + const hookRuns = output.split("\n").map((line) => line.trim()).filter((line) => /^hook: \S+ Completed/.test(line)); + return { sessionId, hookRuns, result }; +}; +const waitFor = async (predicate, label) => { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.fail(`timed out waiting for ${label}`); +}; +// Session ids are redacted in the lifecycle log, so each session is measured as the +// change in event counts it causes. Sessions run one at a time. +const baselines = {}; +const measuredSession = async (home, host, label, runSession) => { + const before = eventCounts(lifecycle(home)); + const session = await runSession(); + await waitFor(() => (eventCounts(lifecycle(home))["turn.completed"] ?? 0) > (before["turn.completed"] ?? 0), `${host} ${label} capture`); + await new Promise((resolve) => setTimeout(resolve, 1500)); // let any duplicate land before counting + const after = eventCounts(lifecycle(home)); + const delta = Object.fromEntries(Object.keys(after).map((type) => [type, after[type] - (before[type] ?? 0)]).filter(([, count]) => count > 0)); + assert.equal(delta["turn.completed"], 1, `${host} ${label}: ${JSON.stringify(delta)}`); + if (baselines[host]) assert.deepEqual(delta, baselines[host], `${host} ${label}: events differ from the single-registration baseline`); + else baselines[host] = delta; + results[`${host}:${label}`] = { lifecycleDelta: delta, hookRuns: session.hookRuns.length }; + return session; +}; +const crossheadHookLines = (session) => session.hookRuns.filter((line) => /crosshead/.test(line) || /^hook:/.test(line.trim())); +const doctor = async (home, project) => JSON.parse((await run("doctor", home, xhd(home), ["doctor", "--json", "--workspace-root", project], { cwd: project, allowFailure: true })).stdout); +const doctorCheck = (report, id) => report.checks.find((entry) => entry.id === id); + +const pluginHookCommand = (home, host, event) => { + const hooksFile = host === "codex" + ? path.join(home, ".codex", "plugins", "cache", "crosshead", "crosshead", spec.CROSSHEAD_HOST_PLUGIN_VERSION, "hooks", "hooks.json") + : path.join(repo, "plugins", "claude-code", "hooks", "hooks.json"); + return JSON.parse(readFileSync(hooksFile, "utf8")).hooks[event][0].hooks[0].command; +}; +const pluginRoot = (home, host) => + host === "codex" ? path.join(home, ".codex", "plugins", "cache", "crosshead", "crosshead", spec.CROSSHEAD_HOST_PLUGIN_VERSION) : path.join(repo, "plugins", "claude-code"); +// Runs the exact command a host would, with the plugin-root variables the host sets. +const runPluginHook = (label, home, project, host, event, payload) => + run(`hook-${host}-${event}-${label}`, home, "sh", ["-c", pluginHookCommand(home, host, event)], { + cwd: project, + input: JSON.stringify({ cwd: project, hook_event_name: event, ...payload }), + env: host === "codex" ? { PLUGIN_ROOT: pluginRoot(home, host), CLAUDE_PLUGIN_ROOT: pluginRoot(home, host) } : { CLAUDE_PLUGIN_ROOT: pluginRoot(home, host) }, + }); + +const scanForCrossheadHooks = (home, project) => { + const files = [ + path.join(home, ".claude", "settings.json"), + path.join(project, ".claude", "settings.json"), + path.join(project, ".claude", "settings.local.json"), + path.join(home, ".codex", "hooks.json"), + path.join(home, ".codex", "config.toml"), + ]; + const hits = files.filter((file) => existsSync(file) && /crosshead-(claude-code-|codex-)?hook|crosshead@crosshead/.test(readFileSync(file, "utf8"))); + return hits; +}; + +try { + await run("claude-version", root, "claude", ["--version"]); + await run("codex-version", root, "codex", ["--version"]); + for (const target of [repo, path.join(repo, "plugins", "claude-code")]) { + await run("validate", root, "claude", ["plugin", "validate", target, "--strict"]); + } + check("claude plugin validate --strict passes for the marketplace and the Claude Code plugin"); + + // ---- B1: existing legacy install, no plugins: single-registration baseline --- + const b = makeHome("legacy"); + await installRuntime(b.home); + await run("legacy-install-claude", b.home, xhd(b.home), ["install", "claude-code", "--workspace-root", b.project], { cwd: b.project }); + await run("legacy-install-codex", b.home, xhd(b.home), ["install", "codex"], { cwd: b.project }); + const claudeLegacy = await measuredSession(b.home, "claude-code", "legacy only", () => claudeSession("legacy-only", b.home, b.project)); + assert.equal(crossheadHookLines(claudeLegacy).length, 3); + const codexLegacy = await measuredSession(b.home, "codex", "legacy only", () => codexSession("legacy-only", b.home, b.project)); + assert.equal(codexLegacy.hookRuns.length, 3); + check("legacy settings-mode install: 3 hook runs and one capture per session in each host (baseline)"); + + // ---- A: new user. Plugins first, no runtime -------------------------------- + const a = makeHome("fresh"); + await addMarketplaces(a.home); + check("both plugins install from the repository marketplace (Codex reads .agents/plugins/marketplace.json)"); + for (const host of ["claude-code", "codex"]) { + const start = await runPluginHook("no-runtime", a.home, a.project, host, "SessionStart", { session_id: "no-runtime", source: "startup" }); + assert.equal(start.stdout.trim().split("\n").length, 1); + assert.equal(JSON.parse(start.stdout).systemMessage, spec.pluginSetupHint(host)); + for (const event of ["UserPromptSubmit", "Stop", "PreCompact", "PostCompact"]) { + const other = await runPluginHook("no-runtime", a.home, a.project, host, event, { session_id: "no-runtime", prompt: "x" }); + assert.equal(other.stdout, ""); + } + } + const claudeFresh = await claudeSession("no-runtime", a.home, a.project); + assert.equal(crossheadHookLines(claudeFresh).length, 3, claudeFresh.hookRuns.join("\n")); + assert(claudeFresh.hookRuns.every((line) => line.includes("status 0"))); + // The notice is for the user only (systemMessage): it must not reach the model. + const inModel = (prefix, text) => requests.some((request) => request.url.startsWith(prefix) && request.body.includes(text)); + assert(!inModel("/v1/messages", "isn't set up. To set it up"), "Claude setup hint reached model context"); + const codexFresh = await codexSession("no-runtime", a.home, a.project); + assert(!inModel("/v1/responses", "isn't set up. To set it up"), "Codex setup hint reached model context"); + // Recorded, not asserted: whether either host lists the user-only setup skill to the model. + results.setupSkillListedToModel = { claude: inModel("/v1/messages", "crosshead:setup"), codex: inModel("/v1/responses", "crosshead:setup") }; + assert.deepEqual(codexFresh.hookRuns.map((line) => line.trim()), ["hook: SessionStart Completed", "hook: UserPromptSubmit Completed", "hook: Stop Completed"]); + assert(!existsSync(path.join(a.home, ".crosshead")), "a plugin hook created runtime state"); + check("without a runtime, SessionStart prints one user-only setup line (not in model context), every hook exits 0, real sessions complete and nothing is installed"); + + // The setup skill's commands, after consent: digest-checked installer, then quickstart in plugin mode. + await installRuntime(a.home); + for (const adapter of ["claude-code", "codex"]) { + const quick = await run(`quickstart-${adapter}`, a.home, xhd(a.home), ["quickstart", "--api-base", "http://127.0.0.1:9", "--adapter", adapter, "--workspace-root", ".", "--hook-mode", "plugin"], { cwd: a.project }); + assert.match(quick.stdout, /hook_mode: plugin/); + } + assert(!existsSync(path.join(a.project, ".claude", "settings.json")), "quickstart wrote Claude Code settings hooks"); + assert(!existsSync(path.join(a.home, ".codex", "hooks.json")), "quickstart wrote Codex hooks.json"); + const claudeA = await measuredSession(a.home, "claude-code", "after setup", () => claudeSession("setup-done", a.home, a.project)); + assert.equal(crossheadHookLines(claudeA).length, 3); + const codexA = await measuredSession(a.home, "codex", "after setup", () => codexSession("setup-done", a.home, a.project)); + assert.equal(codexA.hookRuns.length, 3); + const setupDoctor = await doctor(a.home, a.project); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(setupDoctor, id).message, /^mode=plugin; plugin enabled/); + for (const id of ["adapter_claude_code_automation", "adapter_codex_automation"]) assert.equal(doctorCheck(setupDoctor, id).ok, true, doctorCheck(setupDoctor, id).message); + check("setup (installer + quickstart --hook-mode plugin) writes no host hooks; one capture per event in both hosts; doctor reports plugin mode"); + + // ---- B2: plugins added to the legacy install, then migration ------------- + await addMarketplaces(b.home); + const claudeDup = await measuredSession(b.home, "claude-code", "legacy + plugin", () => claudeSession("legacy-plus-plugin", b.home, b.project)); + assert.equal(claudeDup.hookRuns.filter((line) => /crosshead/.test(line)).length, 6, "expected both registrations to run"); + const codexDup = await measuredSession(b.home, "codex", "legacy + plugin", () => codexSession("legacy-plus-plugin", b.home, b.project)); + assert.equal(codexDup.hookRuns.length, 6, codexDup.hookRuns.join("\n")); + const before = await doctor(b.home, b.project); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(before, id).message, /Duplicate registration/); + check("legacy + plugin before migration: both registrations run, exactly one captures, doctor flags the duplicate"); + + for (const adapter of ["claude-code", "codex"]) { + const migrated = await run(`migrate-${adapter}`, b.home, xhd(b.home), ["install", adapter, "--workspace-root", b.project], { cwd: b.project }); + assert.match(migrated.stdout, /hook_mode: plugin/); + assert.match(migrated.stdout, /migrated_legacy_hooks: /); + const again = await run(`migrate-again-${adapter}`, b.home, xhd(b.home), ["install", adapter, "--workspace-root", b.project], { cwd: b.project }); + assert.doesNotMatch(again.stdout, /migrated_legacy_hooks/); + } + const claudeMigrated = await measuredSession(b.home, "claude-code", "after migration", () => claudeSession("migrated", b.home, b.project)); + assert.equal(claudeMigrated.hookRuns.filter((line) => /crosshead/.test(line)).length, 3); + assert(claudeMigrated.hookRuns.every((line) => line.includes("plugin crosshead@crosshead")), "a legacy hook still ran"); + const codexMigrated = await measuredSession(b.home, "codex", "after migration", () => codexSession("migrated", b.home, b.project)); + assert.equal(codexMigrated.hookRuns.length, 3); + for (const host of ["claude-code", "codex"]) { + const before = eventCounts(lifecycle(b.home)); + for (const event of ["PreCompact", "PostCompact"]) { + await runPluginHook("migrated", b.home, b.project, host, event, { session_id: `direct-compact-${host}`, trigger: "manual" }); + } + await waitFor(() => (eventCounts(lifecycle(b.home))["compact.completed"] ?? 0) > (before["compact.completed"] ?? 0), `${host} compact events`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const after = eventCounts(lifecycle(b.home)); + for (const type of ["compact.started", "compact.completed"]) assert.equal(after[type] - (before[type] ?? 0), 1, `${host} ${type}`); + } + const after = await doctor(b.home, b.project); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(after, id).message, /^mode=plugin; plugin enabled.*no legacy hooks\.$/); + check("migration is idempotent; afterwards only the plugin hooks run, one capture per event (PreCompact/PostCompact driven directly), doctor ok"); + + // ---- Uninstall the plugins ---------------------------------------------- + await run("claude-plugin-uninstall", b.home, "claude", ["plugin", "uninstall", "crosshead@crosshead"]); + await run("codex-plugin-remove", b.home, "codex", ["plugin", "remove", "crosshead@crosshead"]); + const leftovers = scanForCrossheadHooks(b.home, b.project); + // Hosts may keep an inactive copy of an uninstalled plugin for garbage collection; record it. + const walk = (dir) => (existsSync(dir) ? readdirSync(dir, { withFileTypes: true }).flatMap((entry) => (entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)])) : []); + results.inactivePluginCacheAfterUninstall = [path.join(b.home, ".claude", "plugins", "cache", "crosshead"), path.join(b.home, ".codex", "plugins", "cache", "crosshead")].flatMap(walk); + assert.deepEqual(leftovers, [], `orphaned Crosshead hook registrations: ${leftovers.join(", ")}`); + const eventsBefore = lifecycle(b.home).length; + const claudeAfter = await claudeSession("uninstalled", b.home, b.project); + assert.equal(claudeAfter.hookRuns.filter((line) => /crosshead/.test(line)).length, 0); + const codexAfter = await codexSession("uninstalled", b.home, b.project); + assert.equal(codexAfter.hookRuns.length, 0); + assert.equal(lifecycle(b.home).length, eventsBefore, "a hook ran after the plugins were uninstalled"); + const removed = await doctor(b.home, b.project); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(removed, id).message, /No Crosshead hooks are active/); + check("uninstalling the plugins leaves no Crosshead hooks in any host config; sessions run no Crosshead hook; doctor reports it"); + + results.notExercised.push( + "interactive /crosshead:setup and Codex skill invocation by a model (the skill's commands were run directly)", + "Codex hook trust review (/hooks); runs used --dangerously-bypass-hook-trust", + "identity registration with the hosted service (profile seeded locally; hosted API unreachable by design)", + "native compaction (PreCompact/PostCompact were driven with representative payloads)", + "downloading the installer from crosshead.sh and its production digest (a local rendering of docs/security-review/install.sh served the local release)" + ); + results.ok = true; +} catch (error) { + results.ok = false; + results.error = error instanceof Error ? error.stack : String(error); + process.exitCode = 1; +} finally { + writeFileSync(path.join(root, "host-plugins-result.json"), JSON.stringify(results, null, 2)); + for (const home of [path.join(root, "fresh"), path.join(root, "legacy")]) { + if (existsSync(path.join(home, ".crosshead", "bin", "crosshead"))) { + spawnSync(path.join(home, ".crosshead", "bin", "crosshead"), ["stop"], { env: homeEnv(home), stdio: "ignore", timeout: 20_000 }); + } + } + server.close(); + console.log(JSON.stringify(results, null, 2)); +} diff --git a/scripts/test-host-plugins.sh b/scripts/test-host-plugins.sh new file mode 100755 index 0000000..88c4fc0 --- /dev/null +++ b/scripts/test-host-plugins.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Claude Code and Codex plugin end-to-end check in one disposable container. +# Real host CLIs, local model/installer fixture, no credentials. +# Requires `npm ci && npm run release:build` (dist/ and release/). +set -euo pipefail +plugin_repo=$(cd "$(dirname "$0")/.." && pwd) +plugin_logs=${CROSSHEAD_TEST_LOG_DIR:-"$plugin_repo/.native-host-test-logs/host-plugins"} +# A prebuilt image with both CLIs may be supplied; otherwise they are installed at the pinned versions. +plugin_image=${CROSSHEAD_PLUGIN_TEST_IMAGE:-'node@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da'} +claude_version=${CROSSHEAD_TEST_CLAUDE_VERSION:-2.1.319} +codex_version=${CROSSHEAD_TEST_CODEX_VERSION:-0.167.0} +plugin_container=${CROSSHEAD_PLUGIN_TEST_CONTAINER:-"crosshead-host-plugins-$(date -u +%Y%m%d%H%M%S)-$$"} +version=$(node -p 'require(process.argv[1]).version' "$plugin_repo/package.json") +test -f "$plugin_repo/dist/runtime/host-hook-spec.js" || { printf 'Run npm run build first.\n' >&2; exit 1; } +test -f "$plugin_repo/release/crosshead-runtime-install-$version.tgz" || { printf 'Run npm run release:build first.\n' >&2; exit 1; } +mkdir -p "$plugin_logs" +docker ps -aq | sort > "$plugin_logs/containers-before.txt" +docker volume ls -q | sort > "$plugin_logs/volumes-before.txt" +plugin_cleanup() { + local plugin_status=$? + trap - EXIT INT TERM + docker rm -f -v "$plugin_container" > /dev/null 2>&1 || true + if docker ps -a --format '{{.Names}}' | grep -qx "$plugin_container"; then plugin_status=1; fi + docker ps -aq | sort > "$plugin_logs/containers-after.txt" + docker volume ls -q | sort > "$plugin_logs/volumes-after.txt" + diff -u "$plugin_logs/containers-before.txt" "$plugin_logs/containers-after.txt" > "$plugin_logs/container-diff.txt" || true + diff -u "$plugin_logs/volumes-before.txt" "$plugin_logs/volumes-after.txt" > "$plugin_logs/volume-diff.txt" || true + exit "$plugin_status" +} +trap plugin_cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +docker run -d --rm --name "$plugin_container" --cpus 2 --memory 3g --memory-swap 3g \ + --tmpfs /tmp:rw,size=384m --tmpfs /test:rw,exec,size=768m,uid=1000,gid=1000 \ + --mount "type=bind,src=$plugin_repo,dst=/crosshead,readonly" \ + --user 0:0 "$plugin_image" sleep infinity > "$plugin_logs/container.txt" +docker inspect "$plugin_container" --format '{{.Id}} memory={{.HostConfig.Memory}} swap={{.HostConfig.MemorySwap}} cpus={{.HostConfig.NanoCpus}}' >> "$plugin_logs/container.txt" +docker exec "$plugin_container" bash -c " + set -euo pipefail + command -v curl > /dev/null || { apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl; } + if ! claude --version 2> /dev/null | grep -q '^$claude_version ' || ! codex --version 2> /dev/null | grep -q ' $codex_version\$'; then + npm install --global --no-audit --no-fund @anthropic-ai/claude-code@$claude_version @openai/codex@$codex_version + fi + claude --version; codex --version +" > "$plugin_logs/setup.log" 2>&1 +plugin_status=0 +docker exec --user 1000:1000 "$plugin_container" node /crosshead/scripts/smoke-host-plugins.mjs > "$plugin_logs/smoke.log" 2>&1 || plugin_status=$? +docker exec "$plugin_container" cat /test/host-plugins-result.json > "$plugin_logs/result.json" 2> /dev/null || true +docker exec "$plugin_container" tar -C /test -cf - logs 2> /dev/null | tar -C "$plugin_logs" -xf - 2> /dev/null || true +if [[ $plugin_status -ne 0 ]]; then + printf 'Host plugin checks failed (exit %s). Logs: %s\n' "$plugin_status" "$plugin_logs" >&2 + exit "$plugin_status" +fi +printf 'Claude Code and Codex plugin checks passed. Logs: %s\n' "$plugin_logs" diff --git a/src/runtime/adapter-utils.ts b/src/runtime/adapter-utils.ts index 4a311bb..63f4a21 100644 --- a/src/runtime/adapter-utils.ts +++ b/src/runtime/adapter-utils.ts @@ -181,8 +181,8 @@ export type WrapperEnvExports = { runtimeHome: string; }; -export const buildWrapperScript = (scriptPath: string, env?: WrapperEnvExports): string => `#!/usr/bin/env bash -set -euo pipefail +export const buildWrapperScript = (scriptPath: string, env?: WrapperEnvExports, marker?: string): string => `#!/usr/bin/env bash +${marker ? `# ${marker}\n` : ""}set -euo pipefail unset NODE_OPTIONS NODE_PATH ${ env diff --git a/src/runtime/claude-code-adapter.ts b/src/runtime/claude-code-adapter.ts index 9f4ef4d..ce4e142 100644 --- a/src/runtime/claude-code-adapter.ts +++ b/src/runtime/claude-code-adapter.ts @@ -59,6 +59,21 @@ import { type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; import { resolveClaudeCodeBinaryPath, resolveClaudeCodeHostHome } from "./runtime-env"; +import { PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; +import { + claudeLoadedSettingsFiles, + countMatchingHookCommands, + describeHookMode, + detectClaudeCodePlugin, + normalizeHookModeRequest, + normalizeHostHookMode, + parseHookModeFlag, + readSettingsForInspection, + resolveHostHookMode, + type HookModeRequest, + type HostHookMode, + type PluginDetection, +} from "./host-plugin-mode"; import type { HandoffCapsule, HandoffInspection, @@ -81,6 +96,10 @@ export type ClaudeCodeAdapterConfig = { workspaceRoot: string; profileName: string; claudeBinary: string; + /** Absent in configs written before host plugins; treated as "settings". */ + hookMode?: HostHookMode; + /** Explicit `--hook-mode` of the last install; reinstalls without the flag repeat it. */ + hookModeRequest?: HostHookMode | null; installedAt: string; updatedAt: string; }; @@ -107,12 +126,19 @@ export type ClaudeCodeAdapterInstallOptions = { bridgeScriptPath: string; hookScriptPath: string; claudeBinary?: string; + /** "auto" (default) selects "plugin" when the Crosshead Claude Code plugin is enabled at user scope. */ + hookMode?: HookModeRequest; }; export type ClaudeCodeAdapterInstallResult = { adapter: RuntimeAdapterRecord; config: ClaudeCodeAdapterConfig; managedFiles: string[]; + pluginDetection: PluginDetection | null; + /** Settings files whose legacy Crosshead hooks were removed because the plugin supplies them. */ + migratedFiles: string[]; + /** Explanation when plugin hooks were requested but plugin mode could not be used. */ + hookModeNote: string | null; }; export type ClaudeCodeAdapterUninstallOptions = { @@ -273,8 +299,8 @@ const projectSettingsFile = (workspaceRoot: string): string => path.join(workspa const isManagedContent = (filePath: string): boolean => existsSync(filePath) && readFileSync(filePath, "utf8").includes(CLAUDE_CODE_ADAPTER_MARKER); -const writeManagedExecutable = (filePath: string, scriptPath: string, runtimeHome: string): void => { - writeManagedFile(filePath, buildWrapperScript(scriptPath), runtimeHome); +const writeManagedExecutable = (filePath: string, scriptPath: string, runtimeHome: string, marker?: string): void => { + writeManagedFile(filePath, buildWrapperScript(scriptPath, undefined, marker), runtimeHome); chmodSync(filePath, 0o755); }; @@ -307,6 +333,8 @@ const buildClaudeCodeAdapterConfig = (options: { profileName: string; adapterVersion: string; claudeBinary: string; + hookMode: HostHookMode; + hookModeRequest: HostHookMode | null; }): ClaudeCodeAdapterConfig => ({ marker: CLAUDE_CODE_MANAGED_CONFIG_MARKER, adapterName: "claude-code", @@ -316,13 +344,15 @@ const buildClaudeCodeAdapterConfig = (options: { workspaceRoot: options.workspaceRoot, profileName: options.profileName, claudeBinary: options.claudeBinary, + hookMode: options.hookMode, + hookModeRequest: options.hookModeRequest, installedAt: nowIso(), updatedAt: nowIso(), }); const buildHookCommand = ( hookWrapperFile: string, - eventName: "SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop", + eventName: HostHookEvent, config: ClaudeCodeAdapterConfig ): string => [ @@ -336,6 +366,11 @@ const buildHookCommand = ( const CLAUDE_HOOK_MARKER = "crosshead-claude-code-hook"; +/** True for a hook command an installer-written (settings-mode) Crosshead registration uses. */ +const isLegacyClaudeHookCommand = (command: string | undefined, hookWrapperPath: string): boolean => + isManagedHookCommand(command, hookWrapperPath, CLAUDE_HOOK_MARKER) || + isManagedHookCommand(command, path.join(path.dirname(hookWrapperPath), "onlybots-claude-code-hook"), "onlybots-claude-code-hook"); + const readClaudeSettingsConfig = (filePath: string): ClaudeSettingsConfig => { if (!existsSync(filePath)) { return {}; @@ -358,11 +393,13 @@ const filterManagedClaudeHooks = (existing: ClaudeSettingsConfig, hookWrapperPat const filteredHooks: ClaudeHooksConfig = {}; for (const [eventName, matcherGroups] of Object.entries(hooks)) { - const remainingGroups = (matcherGroups ?? []) + const remainingGroups = (Array.isArray(matcherGroups) ? matcherGroups : []) .filter((group): group is ClaudeHookMatcherGroup => !!group && typeof group === "object" && !Array.isArray(group)) .map((group) => ({ ...group, - hooks: (group.hooks ?? []).filter((hook) => !isManagedHookCommand(hook.command, hookWrapperPath, CLAUDE_HOOK_MARKER) && !isManagedHookCommand(hook.command, path.join(path.dirname(hookWrapperPath), "onlybots-claude-code-hook"), "onlybots-claude-code-hook")), + hooks: (Array.isArray(group.hooks) ? group.hooks : []).filter( + (hook) => !(hook && typeof hook === "object" && isLegacyClaudeHookCommand(hook.command, hookWrapperPath)) + ), })) .filter((group) => group.hooks.length > 0); if (remainingGroups.length > 0) { @@ -385,43 +422,9 @@ const renderClaudeSettingsConfig = ( ): string => { const next = filterManagedClaudeHooks(existing, adapterPaths.hookWrapperFile); const hooks: ClaudeHooksConfig = next.hooks && typeof next.hooks === "object" && !Array.isArray(next.hooks) ? next.hooks : {}; - const managedHooks: Array< - readonly ["SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop", ClaudeHookMatcherGroup] - > = [ - [ - "SessionStart", - { - matcher: "startup|resume|compact", - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "SessionStart", config) }], - }, - ], - [ - "PreCompact", - { - matcher: "manual|auto", - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "PreCompact", config) }], - }, - ], - [ - "PostCompact", - { - matcher: "manual|auto", - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "PostCompact", config) }], - }, - ], - [ - "UserPromptSubmit", - { - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "UserPromptSubmit", config) }], - }, - ], - [ - "Stop", - { - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "Stop", config) }], - }, - ], - ]; + const managedHooks = renderHostHookGroups("claude-code", (eventName) => + buildHookCommand(adapterPaths.hookWrapperFile, eventName, config) + ); for (const [eventName, group] of managedHooks) { const existingGroups = hooks[eventName] ?? []; hooks[eventName] = [...existingGroups, group]; @@ -586,6 +589,33 @@ export const claudeCodeContinuityTranslationContract: ContinuityTranslationContr reportFidelity: (translation) => translation.fidelity, }; +const legacyHookWrapper = (runtimeHome: string): string => path.join(runtimeHome, "bin", "crosshead-claude-code-hook"); + +/** Number of installer-written Crosshead hook handlers in a Claude Code settings file. */ +export const countLegacyClaudeHookCommands = (filePath: string, runtimeHome: string, match?: import("./host-plugin-mode").HookMatch): number => + countMatchingHookCommands(readSettingsForInspection(filePath), (command) => isLegacyClaudeHookCommand(command, legacyHookWrapper(runtimeHome)), match); + +/** + * Settings files Claude Code loads for a session in `projectDir` (user settings in + * `userConfigDir`) that still register installer-written Crosshead hooks. + */ +export const findLegacyClaudeHookFiles = (userConfigDir: string, projectDir: string, runtimeHome: string, match?: import("./host-plugin-mode").HookMatch): string[] => + claudeLoadedSettingsFiles(userConfigDir, projectDir) + .map((source) => source.file) + .filter((filePath) => countLegacyClaudeHookCommands(filePath, runtimeHome, match) > 0); + +/** + * Removes installer-written Crosshead hooks from a settings file, keeping every + * other key and hook. A file left with nothing is rewritten as `{}`, never + * deleted, so a tracked project file stays in place. + */ +const removeLegacyClaudeHooks = (filePath: string, managedRoot: string, runtimeHome: string): boolean => { + if (countLegacyClaudeHookCommands(filePath, runtimeHome) === 0) return false; + const filtered = filterManagedClaudeHooks(readClaudeSettingsConfig(filePath), legacyHookWrapper(runtimeHome)); + writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, managedRoot); + return true; +}; + export const installClaudeCodeAdapter = async ( options: ClaudeCodeAdapterInstallOptions ): Promise => { @@ -596,6 +626,22 @@ export const installClaudeCodeAdapter = async ( const profileName = options.profileName ?? "default"; const adapterVersion = options.adapterVersion ?? ONLYBOTS_CLAUDE_CODE_ADAPTER_VERSION; const claudeBinary = resolveClaudeCodeBinary(runtimeLayout.home, options.claudeBinary); + const previousConfig = existsSync(adapterPaths.configFile) + ? (() => { + try { + return loadClaudeCodeAdapterConfig(runtimeLayout.home, hostHome); + } catch { + return null; + } + })() + : null; + // Plugin mode needs the plugin enabled in the user settings of this Claude config dir. + const resolution = resolveHostHookMode( + options.hookMode, + () => detectClaudeCodePlugin(hostHome, workspaceRoot), + normalizeHookModeRequest(previousConfig?.hookModeRequest) + ); + const { mode: hookMode, detection: pluginDetection } = resolution; const config = buildClaudeCodeAdapterConfig({ runtimeHome: runtimeLayout.home, @@ -604,29 +650,62 @@ export const installClaudeCodeAdapter = async ( profileName, adapterVersion, claudeBinary, + hookMode, + hookModeRequest: resolution.request, }); - assertManagedFilePath(adapterPaths.projectSettingsFile, workspaceRoot); + // Plugin mode retires legacy entries this runtime wrote (in this workspace and + // the previously configured one) so that no event is handled twice. + const legacyRoots = [workspaceRoot, previousConfig?.workspaceRoot].filter( + (root, index, all): root is string => typeof root === "string" && all.indexOf(root) === index + ); + const migrationTargets = + hookMode === "plugin" + ? legacyRoots + .map((root) => ({ root, filePath: projectSettingsFile(root) })) + .filter(({ filePath }) => countLegacyClaudeHookCommands(filePath, runtimeLayout.home) > 0) + : []; + // Check every host file this install changes before writing any of them. A + // symlinked settings file is refused rather than followed or half-migrated. + if (hookMode === "settings" && resolution.writeLegacyHooks) assertManagedFilePath(adapterPaths.projectSettingsFile, workspaceRoot); + for (const { root, filePath } of migrationTargets) { + try { + assertManagedFilePath(filePath, root); + } catch (error) { + throw new Error( + `Refusing to migrate Crosshead hooks in ${filePath}: ${error instanceof Error ? error.message : String(error)}. No files were changed; remove the Crosshead hooks from it by hand or rerun with --hook-mode settings.` + ); + } + } + assertManagedFilePath(adapterPaths.configFile, hostHome); writeManagedFile( adapterPaths.configFile, `${CLAUDE_CODE_ADAPTER_MARKER}\n${JSON.stringify(config, null, 2)}\n`, hostHome ); - writeManagedFile( - adapterPaths.projectSettingsFile, - renderClaudeSettingsConfig(readClaudeSettingsConfig(adapterPaths.projectSettingsFile), adapterPaths, config), - workspaceRoot - ); + const migratedFiles: string[] = []; + for (const { root, filePath } of migrationTargets) { + if (removeLegacyClaudeHooks(filePath, root, runtimeLayout.home)) migratedFiles.push(filePath); + } + if (hookMode === "settings" && resolution.writeLegacyHooks) { + writeManagedFile( + adapterPaths.projectSettingsFile, + renderClaudeSettingsConfig(readClaudeSettingsConfig(adapterPaths.projectSettingsFile), adapterPaths, config), + workspaceRoot + ); + } writeManagedExecutable(adapterPaths.wrapperFile, options.bridgeScriptPath, runtimeLayout.home); - writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home); + writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home, PLUGIN_DISPATCH_CAPABILITY); writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-claude-code"), options.bridgeScriptPath, runtimeLayout.home); - writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-claude-code-hook"), options.hookScriptPath, runtimeLayout.home); + writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-claude-code-hook"), options.hookScriptPath, runtimeLayout.home, PLUGIN_DISPATCH_CAPABILITY); writeManagedFile(adapterPaths.claudeLaunchWrapperFile, renderClaudeLaunchWrapper(config), runtimeLayout.home); chmodSync(adapterPaths.claudeLaunchWrapperFile, 0o755); const managedFiles = [ adapterPaths.configFile, - adapterPaths.projectSettingsFile, + ...(hookMode === "settings" && (resolution.writeLegacyHooks || countLegacyClaudeHookCommands(adapterPaths.projectSettingsFile, runtimeLayout.home) > 0) + ? [adapterPaths.projectSettingsFile] + : []), adapterPaths.wrapperFile, adapterPaths.hookWrapperFile, path.join(config.runtimeHome, "bin", "onlybots-claude-code"), @@ -650,6 +729,9 @@ export const installClaudeCodeAdapter = async ( adapter: existingRecord, config, managedFiles, + pluginDetection, + migratedFiles, + hookModeNote: resolution.note, }; }; @@ -675,13 +757,8 @@ export const uninstallClaudeCodeAdapter = async ( continue; } if (filePath === projectSettingsFile(workspaceRoot)) { - const filtered = filterManagedClaudeHooks(readClaudeSettingsConfig(filePath), path.join(runtimeHome, "bin", "crosshead-claude-code-hook")); - if (Object.keys(filtered).length > 0) { - writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, workspaceRoot); - } else { - rmSync(filePath, { force: true }); - removed.push(filePath); - } + // Never delete the settings file (it may be tracked); leave it cleaned, or `{}`. + removeLegacyClaudeHooks(filePath, workspaceRoot, runtimeHome); continue; } if (["onlybots-claude-code", "onlybots-claude-code-hook", "crosshead-claude-code", "crosshead-claude-code-hook", "claude"].includes(path.basename(filePath)) || isManagedContent(filePath)) { @@ -759,7 +836,7 @@ export class ClaudeCodeRuntimeAdapter extends HostRuntimeAdapterBase { hostHome: input.hostHome ?? this.config.hostHome, managedFiles: [ configFile(this.config.hostHome), - projectSettingsFile(this.config.workspaceRoot), + ...(normalizeHostHookMode(this.config.hookMode) === "settings" ? [projectSettingsFile(this.config.workspaceRoot)] : []), path.join(this.config.runtimeHome, "bin", "crosshead-claude-code"), path.join(this.config.runtimeHome, "bin", "crosshead-claude-code-hook"), path.join(this.config.runtimeHome, "bin", "onlybots-claude-code"), @@ -1289,6 +1366,7 @@ export const installClaudeCodeFromCli = async ( bridgeScriptPath, hookScriptPath, claudeBinary: typeof flags["claude-bin"] === "string" ? flags["claude-bin"] : undefined, + hookMode: parseHookModeFlag(flags["hook-mode"]), }); return [ `Installed Claude Code adapter ${result.adapter.version}`, @@ -1296,6 +1374,9 @@ export const installClaudeCodeFromCli = async ( `claude_home: ${result.config.hostHome}`, `workspace_root: ${result.config.workspaceRoot}`, `claude_bin: ${result.config.claudeBinary}`, + `hook_mode: ${describeHookMode(result.config.hookMode ?? "settings", result.pluginDetection)}`, + ...(result.hookModeNote ? [`hook_mode_note: ${result.hookModeNote}`] : []), + ...result.migratedFiles.map((filePath) => `migrated_legacy_hooks: ${filePath}`), ].join("\n"); }; diff --git a/src/runtime/claude-code-hook.ts b/src/runtime/claude-code-hook.ts index f7cb2ca..fe0befc 100644 --- a/src/runtime/claude-code-hook.ts +++ b/src/runtime/claude-code-hook.ts @@ -5,7 +5,18 @@ import path from "path"; import { captureHotFinding } from "./hot-continuity"; import { readLastAssistantMessage } from "./hook-transcript"; -import { ClaudeCodeRuntimeAdapter } from "./claude-code-adapter"; +import { ClaudeCodeRuntimeAdapter, findLegacyClaudeHookFiles, loadClaudeCodeAdapterConfig } from "./claude-code-adapter"; +import { pluginSetupHint } from "./host-hook-spec"; +import { + claudeSessionProjectDir, + claudeUserConfigDir, + decideHookDispatch, + isPluginHookInvocation, + normalizeHostHookMode, + recordPluginInvocation, + resolvePluginHostHome, + writePluginSetupNotice, +} from "./host-plugin-mode"; import { ensureRuntimeHomeLayout, resolveRuntimeHome } from "./local-runtime"; import { writeJsonFile } from "./runtime-persistence"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; @@ -360,10 +371,46 @@ export const runOnlyBotsClaudeCodeHookCli = async ( const eventName = positionals[0] ?? "help"; const normalizedEventName = eventName.toLowerCase(); const runtimeHome = resolveRuntimeHome(typeof flags.home === "string" ? flags.home : undefined); - const hostHome = typeof flags["claude-home"] === "string" ? flags["claude-home"] : undefined; + const viaPlugin = isPluginHookInvocation(flags); + const hostHome = + typeof flags["claude-home"] === "string" ? flags["claude-home"] : viaPlugin ? resolvePluginHostHome("claude-code", runtimeHome) : undefined; const payload = readStdinJson(stdinOverride); const workspaceRoot = resolveHookWorkspaceRoot(payload, flags); const workspaceId = resolveHookWorkspaceId(payload, flags, workspaceRoot); + if (eventName !== "help") { + let installedConfig: ReturnType | null = null; + try { + installedConfig = loadClaudeCodeAdapterConfig(runtimeHome, hostHome); + } catch (error) { + if (!viaPlugin) throw error; + } + if (!installedConfig) { + // Plugin installed before setup: tell the user how to finish, never fail the session. + if (normalizedEventName === "sessionstart" || normalizedEventName === "session_start") { + writePluginSetupNotice(pluginSetupHint("claude-code")); + } + return; + } + if (viaPlugin) { + try { + recordPluginInvocation(runtimeHome, "claude-code", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null, claudeUserConfigDir()); + } catch { + // A receipt is diagnostic only; never fail the hook over it. + } + } + // Exactly the settings files Claude Code loaded for this session: the user + // settings and the session's project directory (CLAUDE_PROJECT_DIR). + const decision = decideHookDispatch({ + viaPlugin, + mode: normalizeHostHookMode(installedConfig.hookMode), + legacyHooksPresent: () => + findLegacyClaudeHookFiles(claudeUserConfigDir(), claudeSessionProjectDir(workspaceRoot), runtimeHome, { + event: eventName, + matcherValue: normalizedEventName.replace(/_/g, "") === "sessionstart" ? pickString(payload, ["source", "payload.source"]) ?? undefined : pickCompactionTrigger(payload) ?? undefined, + }).length > 0, + }); + if (decision === "skip-duplicate") return; + } const adapter = ClaudeCodeRuntimeAdapter.fromInstalled({ home: runtimeHome, hostHome, diff --git a/src/runtime/cli.ts b/src/runtime/cli.ts index e3684ed..5dd84e2 100644 --- a/src/runtime/cli.ts +++ b/src/runtime/cli.ts @@ -255,11 +255,11 @@ export const runOnlyBotsCtlCli = async ( "crosshead install [--home ] [--channel stable|beta|canary]", "crosshead install hermes [--home ] [--hermes-home ] [--hermes-bin ] [--profile ]", "crosshead install opencode [--home ] [--opencode-home ] [--profile ]", - "crosshead install codex [--home ] [--codex-home ] [--codex-bin ] [--profile ] [--version ]", - "crosshead install claude-code [--home ] [--claude-home ] [--workspace-root ] [--profile ] [--version ] [--claude-bin ]", + "crosshead install codex [--home ] [--codex-home ] [--codex-bin ] [--profile ] [--version ] [--hook-mode auto|plugin|settings]", + "crosshead install claude-code [--home ] [--claude-home ] [--workspace-root ] [--profile ] [--version ] [--claude-bin ] [--hook-mode auto|plugin|settings]", "crosshead install pi [--home ] [--profile ] [--version ] # embedded hosts without shell wiring", "crosshead install omp [--home ] [--profile ] [--workspace-root ] [--omp-bin ] [--no-envrc] # Pi-compatible OMP project wiring", - "crosshead quickstart --api-base [--home ] [--profile ] [--adapter codex|claude-code|pi|omp|hermes|opencode|none] [--workspace-root ] [--omp-bin ] [--create-checkout] [--json]", + "crosshead quickstart --api-base [--home ] [--profile ] [--adapter codex|claude-code|pi|omp|hermes|opencode|none] [--workspace-root ] [--hook-mode auto|plugin|settings] [--omp-bin ] [--create-checkout] [--json]", capabilityHelpLine, "crosshead keygen [--json]", "crosshead keygen --setup --profile --api-base [--home ] [--memory-mode assist] [--workspace-scope repo|workspace|global] [--json]", @@ -277,7 +277,7 @@ export const runOnlyBotsCtlCli = async ( "crosshead update --pin|--auto|--policy [--home ]", "crosshead update [adapter] [--home ] [--channel stable|beta|canary] [--version ]", "crosshead rollback [adapter] --to [--home ]", - "crosshead doctor [--home ] [--json]", + "crosshead doctor [--home ] [--workspace-root ] [--json]", "crosshead logs [--home ] [--lines 50]", "crosshead events [--home ] [--lines 50] [--json]", "crosshead workspace link --name --workspace-root [--home ] [--profile ]", diff --git a/src/runtime/cli/core.ts b/src/runtime/cli/core.ts index 4d35cb7..fd100be 100644 --- a/src/runtime/cli/core.ts +++ b/src/runtime/cli/core.ts @@ -4,6 +4,7 @@ import { RUNTIME_ADAPTER_NAMES, ensureRuntimeHomeLayout, loadAdapterRecord, load import { billingFromCli } from "./billing"; import { maintainRuntimeBeforeLaunch, runtimeUpdatePolicy } from "../runtime-auto-update"; import { DEFAULT_CHECKOUT_CREDIT_UNITS } from "../billing-remediation"; +import { inspectHostHookRegistrations } from "../host-hook-doctor"; export const installCoreFromCli = async (argv: string[], cliScriptPath: string, daemonScriptPath: string): Promise => { const { flags } = parseArgs(argv); @@ -199,10 +200,14 @@ export const statusFromCli = async (argv: string[]): Promise => { export const doctorFromCli = async (argv: string[]): Promise => { const { flags } = parseArgs(argv); - const report = await buildRuntimeDoctorDirect( - typeof flags.home === "string" ? flags.home : undefined, - typeof flags.profile === "string" ? flags.profile : undefined - ); + const home = typeof flags.home === "string" ? flags.home : undefined; + const daemonReport = await buildRuntimeDoctorDirect(home, typeof flags.profile === "string" ? flags.profile : undefined); + // Inside a Claude Code session, CLAUDE_PROJECT_DIR is the project whose settings it loaded. + const workspaceRoot = + typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const hookChecks = inspectHostHookRegistrations(home, workspaceRoot); + const checks = [...daemonReport.checks, ...hookChecks]; + const report = { ...daemonReport, ok: checks.every((check) => check.ok), checks }; return flags.json ? JSON.stringify(report, null, 2) : formatRuntimeDoctor(report); }; diff --git a/src/runtime/cli/quickstart.ts b/src/runtime/cli/quickstart.ts index 3351012..69a08a1 100644 --- a/src/runtime/cli/quickstart.ts +++ b/src/runtime/cli/quickstart.ts @@ -6,8 +6,13 @@ import { nativeHostFromCli } from "../native-host-adapter"; import { parseArgs } from "../local-runtime"; import { ensureRuntimeHomeLayout, loadProfile, loadRuntimeConfig } from "../runtime-persistence"; import { billingFromCli } from "./billing"; -import { installCoreFromCli, keygenFromCli, startFromCli, statusFromCli } from "./core"; +import { installCoreFromCli, keygenFromCli, statusFromCli } from "./core"; import { DEFAULT_CHECKOUT_CREDIT_UNITS } from "../billing-remediation"; +import { realpathSync } from "node:fs"; +import path from "node:path"; +import { readDaemonOwnershipState, readRuntimeDaemonHealth } from "../daemon-ownership"; +import { startDetachedRuntimeDaemon, stopDetachedRuntimeDaemon } from "../local-runtime"; +import { resolveRuntimePaths } from "../runtime-persistence"; export type QuickstartCliPaths = { cliScriptPath: string; @@ -80,6 +85,74 @@ const formatQuickstart = (result: Record): string => { return lines.join("\n"); }; +const canonicalScript = (scriptPath: string): string => { + try { + return realpathSync(scriptPath); + } catch { + return path.resolve(scriptPath); + } +}; + +/** + * Starts the daemon, or reuses a healthy one so quickstart for a second host + * shares it. A daemon still running another release (for example after the + * installer updated the runtime) is restarted on the installed release through + * the ownership-checked stop/start path; if that is not safe, the mismatch is + * reported instead. `--port` is honored on start and restart, and a running + * daemon on another port is reported rather than silently kept. + */ +export const ensureQuickstartDaemon = async (input: { + home?: string; + daemonScriptPath: string; + port?: string; +}): Promise => { + const requestedPort = input.port === undefined ? undefined : Number(input.port); + if (requestedPort !== undefined && (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535)) { + throw new Error("--port must be an integer between 0 and 65535"); + } + const state = readDaemonOwnershipState(resolveRuntimePaths(input.home)); + const health = state ? await readRuntimeDaemonHealth(state) : null; + if (!state || !health) { + const started = await startDetachedRuntimeDaemon({ home: input.home, port: requestedPort, daemonScriptPath: input.daemonScriptPath }); + return { id: "daemon", ok: true, detail: `Started onlybotsd on port ${started.port}` }; + } + const installed = canonicalScript(input.daemonScriptPath); + const running = health.daemonScriptPath ? canonicalScript(health.daemonScriptPath) : null; + if (running === installed) { + if (requestedPort !== undefined && requestedPort !== 0 && requestedPort !== state.port) { + return { + id: "daemon", + ok: false, + detail: `onlybotsd already running on port ${state.port}; --port ${requestedPort} was not applied. Run crosshead stop, then crosshead start --port ${requestedPort}.`, + }; + } + return { id: "daemon", ok: true, detail: `onlybotsd already running on port ${state.port}` }; + } + const runningLabel = running ?? "a release that predates release reporting"; + if (state.organizationalWorkerConfigPath) { + return { + id: "daemon", + ok: false, + detail: `onlybotsd on port ${state.port} runs ${runningLabel}, but ${installed} is installed. It hosts an organizational worker, which needs an explicit operator restart: run crosshead stop, then crosshead start.`, + }; + } + try { + await stopDetachedRuntimeDaemon(input.home); + const restarted = await startDetachedRuntimeDaemon({ + home: input.home, + port: requestedPort ?? state.port ?? undefined, + daemonScriptPath: input.daemonScriptPath, + }); + return { id: "daemon", ok: true, detail: `Restarted onlybotsd on port ${restarted.port}: it was running ${runningLabel}; now ${installed}` }; + } catch (error) { + return { + id: "daemon", + ok: false, + detail: `onlybotsd on port ${state.port} runs ${runningLabel}, but ${installed} is installed, and it could not be restarted safely (${error instanceof Error ? error.message : String(error)}). Run crosshead stop, then crosshead start.`, + }; + } +}; + export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPaths): Promise => { const { flags } = parseArgs(argv); const home = typeof flags.home === "string" ? flags.home : undefined; @@ -113,6 +186,7 @@ export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPath ...flagPair("--claude-home", typeof flags["claude-home"] === "string" ? flags["claude-home"] : undefined), ...flagPair("--workspace-root", typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : undefined), ...flagPair("--claude-bin", typeof flags["claude-bin"] === "string" ? flags["claude-bin"] : undefined), + ...flagPair("--hook-mode", typeof flags["hook-mode"] === "string" ? flags["hook-mode"] : undefined), ...flagPair("--omp-bin", typeof flags["omp-bin"] === "string" ? flags["omp-bin"] : undefined), ...(boolFlagDisabled(flags, "no-envrc") ? ["--no-envrc"] : []), ]; @@ -151,8 +225,13 @@ export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPath }); if (!boolFlagDisabled(flags, "no-start")) { - const startOutput = await startFromCli([...flagPair("--home", home), ...flagPair("--port", typeof flags.port === "string" ? flags.port : undefined)]); - steps.push({ id: "daemon", ok: true, detail: startOutput }); + steps.push( + await ensureQuickstartDaemon({ + home, + daemonScriptPath: paths.daemonScriptPath, + port: typeof flags.port === "string" ? flags.port : undefined, + }) + ); } else { steps.push({ id: "daemon", ok: false, detail: "skipped by --no-start" }); } diff --git a/src/runtime/codex-adapter.ts b/src/runtime/codex-adapter.ts index 5fa4067..ba39a5c 100644 --- a/src/runtime/codex-adapter.ts +++ b/src/runtime/codex-adapter.ts @@ -57,6 +57,22 @@ import { type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; import { resolveCodexHostHome } from "./runtime-env"; +import { PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; +import { + countMatchingHookCommands, + describeHookMode, + detectCodexPlugin, + normalizeHookModeRequest, + normalizeHostHookMode, + parseHookModeFlag, + readPluginInvocation, + pluginInvocationMatches, + readSettingsForInspection, + resolveHostHookMode, + type HookModeRequest, + type HostHookMode, + type PluginDetection, +} from "./host-plugin-mode"; import { loadRuntimeConfig } from "./runtime-persistence"; import type { HandoffCapsule, @@ -80,6 +96,10 @@ export type CodexAdapterConfig = { codexBinary: string; profileName: string; skillName: string; + /** Absent in configs written before host plugins; treated as "settings". */ + hookMode?: HostHookMode; + /** Explicit `--hook-mode` of the last install; reinstalls without the flag repeat it. */ + hookModeRequest?: HostHookMode | null; installedAt: string; updatedAt: string; }; @@ -109,6 +129,10 @@ export type CodexAdapterInstallOptions = { adapterVersion?: string; bridgeScriptPath: string; hookScriptPath: string; + /** "auto" (default) selects "plugin" when the Crosshead Codex plugin is enabled in the user config. */ + hookMode?: HookModeRequest; + /** Project directory used to find a project-level `.codex/config.toml` during plugin detection. */ + workspaceRoot?: string; }; export type CodexAdapterInstallResult = { @@ -116,6 +140,11 @@ export type CodexAdapterInstallResult = { config: CodexAdapterConfig; managedFiles: string[]; preservedFiles: string[]; + pluginDetection: PluginDetection | null; + /** Hook files whose legacy Crosshead hooks were removed because the plugin supplies them. */ + migratedFiles: string[]; + /** Explanation when plugin mode could not be used, or legacy hooks were kept for now. */ + hookModeNote: string | null; }; export type CodexAdapterUninstallOptions = { @@ -252,13 +281,15 @@ const writeManagedExecutable = ( filePath: string, scriptPath: string, runtimeHome: string, - config?: Pick + config?: Pick, + marker?: string ): void => { writeManagedFile( filePath, buildWrapperScript( scriptPath, - config ? { hostHomeVar: "CODEX_HOME", hostHome: config.hostHome, runtimeHome: config.runtimeHome } : undefined + config ? { hostHomeVar: "CODEX_HOME", hostHome: config.hostHome, runtimeHome: config.runtimeHome } : undefined, + marker ), runtimeHome ); @@ -301,7 +332,7 @@ export const resolveCodexAdapterPaths = (runtimeHomeInput?: string, hostHomeInpu const buildHookCommand = ( hookWrapperFile: string, - eventName: "SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop", + eventName: HostHookEvent, config: CodexAdapterConfig ): string => [ @@ -325,7 +356,7 @@ type CodexHookMatcherGroup = { hooks: CodexHookHandler[]; }; -type CodexHooksConfig = { +type CodexHooksConfig = Record & { hooks: Partial< Record< | "SessionStart" @@ -342,6 +373,7 @@ type CodexHooksConfig = { const CODEX_HOOK_MARKER = "crosshead-codex-hook"; +// Keeps every top-level key; only `hooks` is interpreted. const readHooksConfig = (filePath: string): CodexHooksConfig => { if (!existsSync(filePath)) { return { hooks: {} }; @@ -350,9 +382,7 @@ const readHooksConfig = (filePath: string): CodexHooksConfig => { const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { const hooks = (parsed as { hooks?: CodexHooksConfig["hooks"] }).hooks; - if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) { - return { hooks }; - } + return { ...(parsed as Record), hooks: hooks && typeof hooks === "object" && !Array.isArray(hooks) ? hooks : {} }; } } catch { // Replace corrupt or unsupported files with a valid managed merge on install. @@ -360,95 +390,35 @@ const readHooksConfig = (filePath: string): CodexHooksConfig => { return { hooks: {} }; }; +/** True for a hook command an installer-written (settings-mode) Crosshead registration uses. */ +const isLegacyCodexHookCommand = (command: string | undefined, hookWrapperPath: string): boolean => + isManagedHookCommand(command, hookWrapperPath, CODEX_HOOK_MARKER) || + isManagedHookCommand(command, path.join(path.dirname(hookWrapperPath), "onlybots-codex-hook"), "onlybots-codex-hook"); + const filterManagedHooks = (existing: CodexHooksConfig, hookWrapperPath: string): CodexHooksConfig => { const hooks: CodexHooksConfig["hooks"] = {}; for (const [eventName, matcherGroups] of Object.entries(existing.hooks)) { - const remainingGroups = (matcherGroups ?? []) + const remainingGroups = (Array.isArray(matcherGroups) ? matcherGroups : []) + .filter((group): group is CodexHookMatcherGroup => !!group && typeof group === "object" && !Array.isArray(group)) .map((group) => ({ ...group, - hooks: (group.hooks ?? []).filter((hook) => !isManagedHookCommand(hook.command, hookWrapperPath, CODEX_HOOK_MARKER) && !isManagedHookCommand(hook.command, path.join(path.dirname(hookWrapperPath), "onlybots-codex-hook"), "onlybots-codex-hook")), + hooks: (Array.isArray(group.hooks) ? group.hooks : []).filter( + (hook) => !(hook && typeof hook === "object" && isLegacyCodexHookCommand(hook.command, hookWrapperPath)) + ), })) .filter((group) => group.hooks.length > 0); if (remainingGroups.length > 0) { hooks[eventName as keyof CodexHooksConfig["hooks"]] = remainingGroups; } } - return { hooks }; + return { ...existing, hooks }; }; const renderHooksConfig = (existing: CodexHooksConfig, adapterPaths: CodexAdapterPaths, config: CodexAdapterConfig): string => { const next = filterManagedHooks(existing, adapterPaths.hookWrapperFile); - const managedHooks: Array< - readonly ["SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop", CodexHookMatcherGroup] - > = [ - [ - "SessionStart", - { - matcher: "startup|resume|compact", - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "SessionStart", config), - statusMessage: "Crosshead session sync", - }, - ], - }, - ], - [ - "PreCompact", - { - matcher: "manual|auto", - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "PreCompact", config), - statusMessage: "Crosshead pre-compact checkpoint", - timeout: 30, - }, - ], - }, - ], - [ - "PostCompact", - { - matcher: "manual|auto", - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "PostCompact", config), - statusMessage: "Crosshead post-compact sync", - timeout: 30, - }, - ], - }, - ], - [ - "UserPromptSubmit", - { - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "UserPromptSubmit", config), - statusMessage: "Crosshead context", - timeout: 45, - }, - ], - }, - ], - [ - "Stop", - { - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "Stop", config), - statusMessage: "Crosshead memory sync", - timeout: 30, - }, - ], - }, - ], - ]; + const managedHooks = renderHostHookGroups("codex", (eventName) => + buildHookCommand(adapterPaths.hookWrapperFile, eventName, config) + ); for (const [eventName, group] of managedHooks) { const existingGroups = next.hooks[eventName] ?? []; next.hooks[eventName] = [...existingGroups, group]; @@ -544,6 +514,8 @@ const buildCodexAdapterConfig = (options: { profileName: string; skillName: string; adapterVersion: string; + hookMode: HostHookMode; + hookModeRequest: HostHookMode | null; }): CodexAdapterConfig => ({ marker: CODEX_MANAGED_CONFIG_MARKER, adapterName: "codex", @@ -553,6 +525,8 @@ const buildCodexAdapterConfig = (options: { codexBinary: options.codexBinary, profileName: options.profileName, skillName: options.skillName, + hookMode: options.hookMode, + hookModeRequest: options.hookModeRequest, installedAt: nowIso(), updatedAt: nowIso(), }); @@ -562,7 +536,7 @@ const managedCodexFiles = (config: CodexAdapterConfig): string[] => { return [ path.join(config.hostHome, "skills", config.skillName, "SKILL.md"), configFile(config.hostHome), - hooksFile(config.hostHome), + ...(normalizeHostHookMode(config.hookMode) === "settings" ? [hooksFile(config.hostHome)] : []), path.join(config.runtimeHome, "bin", "crosshead-codex"), path.join(config.runtimeHome, "bin", "crosshead-codex-hook"), path.join(config.runtimeHome, "bin", "onlybots-codex"), @@ -647,12 +621,65 @@ export const codexContinuityTranslationContract: ContinuityTranslationContract translation.fidelity, }; +const legacyCodexHookWrapper = (runtimeHome: string): string => path.join(runtimeHome, "bin", "crosshead-codex-hook"); + +/** Number of installer-written Crosshead hook handlers in a Codex hooks.json. */ +export const countLegacyCodexHookCommands = (filePath: string, runtimeHome: string, match?: import("./host-plugin-mode").HookMatch): number => + countMatchingHookCommands(readSettingsForInspection(filePath), (command) => isLegacyCodexHookCommand(command, legacyCodexHookWrapper(runtimeHome)), match); + +/** True when a Codex hooks.json still registers Crosshead's legacy (settings-mode) hooks. */ +export const codexHooksFileHasLegacyHooks = (filePath: string, runtimeHome: string, match?: import("./host-plugin-mode").HookMatch): boolean => + countLegacyCodexHookCommands(filePath, runtimeHome, match) > 0; + +/** + * Removes installer-written Crosshead hooks, keeping other hooks and top-level + * keys. The file is never deleted; with nothing left it holds `{"hooks": {}}`. + */ +const removeLegacyCodexHooks = (filePath: string, hostHome: string, runtimeHome: string): boolean => { + if (!codexHooksFileHasLegacyHooks(filePath, runtimeHome)) return false; + const filtered = filterManagedHooks(readHooksConfig(filePath), legacyCodexHookWrapper(runtimeHome)); + writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, hostHome); + return true; +}; + +export const codexLegacyHooksFile = (hostHome: string): string => hooksFile(hostHome); + export const installCodexAdapter = async (options: CodexAdapterInstallOptions): Promise => { const runtimeLayout = ensureRuntimeHomeLayout(options.home); const hostHome = resolveCodexHome(options.hostHome); + const previousConfig = existsSync(configFile(hostHome)) + ? (() => { + try { + return loadCodexAdapterConfig(runtimeLayout.home, hostHome); + } catch { + return null; + } + })() + : null; + // Plugin mode needs the plugin enabled in $CODEX_HOME/config.toml. Project + // config applies only to trusted projects and never selects the global mode. + const resolution = resolveHostHookMode( + options.hookMode, + () => detectCodexPlugin(hostHome, path.resolve(options.workspaceRoot ?? process.cwd())), + normalizeHookModeRequest(previousConfig?.hookModeRequest) + ); + const { mode: hookMode, detection: pluginDetection } = resolution; + // Codex runs plugin hooks only after the user trusts them in /hooks. Keep the + // installer hooks until a plugin invocation has been observed; until then the + // dispatch rule lets the installer hooks capture and the plugin defer. + const legacyPresent = codexHooksFileHasLegacyHooks(hooksFile(hostHome), runtimeLayout.home); + const pluginObserved = pluginInvocationMatches(readPluginInvocation(runtimeLayout.home, "codex"), hostHome); + const migrateLegacy = hookMode === "plugin" && legacyPresent && pluginObserved; + const writeLegacy = hookMode === "settings" && resolution.writeLegacyHooks; + const hookModeNote = + resolution.note ?? + (hookMode === "plugin" && legacyPresent && !pluginObserved + ? `kept the installer hooks in ${hooksFile(hostHome)} because Codex has not run the plugin hooks yet. Open /hooks in Codex, trust the Crosshead hooks, start a session, then rerun crosshead install codex to remove them.` + : null); assertManagedFilePath(primarySkillFile(hostHome), hostHome); assertManagedFilePath(fallbackSkillFile(hostHome), hostHome); - assertManagedFilePath(hooksFile(hostHome), hostHome); + if (writeLegacy || migrateLegacy) assertManagedFilePath(hooksFile(hostHome), hostHome); + assertManagedFilePath(configFile(hostHome), hostHome); const selectedSkill = selectSkillTarget(hostHome); const adapterPaths = resolveCodexAdapterPaths(runtimeLayout.home, hostHome); const profileName = options.profileName ?? "default"; @@ -666,6 +693,8 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): profileName, skillName: selectedSkill.skillName, adapterVersion, + hookMode, + hookModeRequest: resolution.request, }); writeManagedFile( @@ -683,15 +712,21 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): `${CODEX_ADAPTER_MARKER}\n${JSON.stringify(config, null, 2)}\n`, hostHome ); - writeManagedFile( - adapterPaths.hooksFile, - renderHooksConfig(readHooksConfig(adapterPaths.hooksFile), adapterPaths, config), - hostHome - ); + const migratedFiles: string[] = []; + if (migrateLegacy) { + // The plugin supplies the hooks and Codex has run them. Retire legacy entries so no event runs twice. + if (removeLegacyCodexHooks(adapterPaths.hooksFile, hostHome, runtimeLayout.home)) migratedFiles.push(adapterPaths.hooksFile); + } else if (writeLegacy) { + writeManagedFile( + adapterPaths.hooksFile, + renderHooksConfig(readHooksConfig(adapterPaths.hooksFile), adapterPaths, config), + hostHome + ); + } writeManagedExecutable(adapterPaths.wrapperFile, options.bridgeScriptPath, runtimeLayout.home, config); - writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home, config); + writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home, config, PLUGIN_DISPATCH_CAPABILITY); writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-codex"), options.bridgeScriptPath, runtimeLayout.home, config); - writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-codex-hook"), options.hookScriptPath, runtimeLayout.home, config); + writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-codex-hook"), options.hookScriptPath, runtimeLayout.home, config, PLUGIN_DISPATCH_CAPABILITY); writeManagedFile( adapterPaths.codexLaunchWrapperFile, renderCodexLaunchWrapper(config), @@ -702,7 +737,7 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): const managedFiles = [ adapterPaths.skillFile, adapterPaths.configFile, - adapterPaths.hooksFile, + ...(writeLegacy || (legacyPresent && !migrateLegacy) ? [adapterPaths.hooksFile] : []), adapterPaths.wrapperFile, adapterPaths.hookWrapperFile, path.join(config.runtimeHome, "bin", "onlybots-codex"), @@ -727,6 +762,9 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): config, managedFiles, preservedFiles: selectedSkill.preservedFiles, + pluginDetection, + migratedFiles, + hookModeNote, }; }; @@ -751,14 +789,8 @@ export const uninstallCodexAdapter = async (options: CodexAdapterUninstallOption continue; } if (filePath === hooksFile(hostHome)) { - const filtered = filterManagedHooks(readHooksConfig(filePath), path.join(runtimeHome, "bin", "crosshead-codex-hook")); - const hasRemainingHooks = Object.values(filtered.hooks).some((groups) => (groups ?? []).length > 0); - if (hasRemainingHooks) { - writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, hostHome); - } else { - rmSync(filePath, { force: true }); - removed.push(filePath); - } + // Never delete hooks.json; leave other hooks and keys, or `{"hooks": {}}`. + removeLegacyCodexHooks(filePath, hostHome, runtimeHome); continue; } if (managedBasenames.has(path.basename(filePath)) || isManagedContent(filePath)) { @@ -1261,6 +1293,8 @@ export const installCodexFromCli = async ( adapterVersion: typeof flags.version === "string" ? flags.version : undefined, bridgeScriptPath, hookScriptPath, + hookMode: parseHookModeFlag(flags["hook-mode"]), + workspaceRoot: typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : undefined, }); return [ `Installed Codex adapter ${result.adapter.version}`, @@ -1268,6 +1302,9 @@ export const installCodexFromCli = async ( `runtime_home: ${result.config.runtimeHome}`, `codex_home: ${result.config.hostHome}`, `codex_bin: ${result.config.codexBinary}`, + `hook_mode: ${describeHookMode(result.config.hookMode ?? "settings", result.pluginDetection)}`, + ...(result.hookModeNote ? [`hook_mode_note: ${result.hookModeNote}`] : []), + ...result.migratedFiles.map((filePath) => `migrated_legacy_hooks: ${filePath}`), ...(result.preservedFiles.length === 0 ? [] : [`preserved: ${result.preservedFiles.join(", ")}`]), diff --git a/src/runtime/codex-hook.ts b/src/runtime/codex-hook.ts index f7b646b..86f9359 100644 --- a/src/runtime/codex-hook.ts +++ b/src/runtime/codex-hook.ts @@ -2,7 +2,17 @@ import { ensureHookRuntime, reportHookFailure } from "./hook-runtime"; import { existsSync, readFileSync } from "fs"; import path from "path"; -import { CodexRuntimeAdapter } from "./codex-adapter"; +import { CodexRuntimeAdapter, codexHooksFileHasLegacyHooks, codexLegacyHooksFile, loadCodexAdapterConfig, resolveCodexHome } from "./codex-adapter"; +import { pluginSetupHint } from "./host-hook-spec"; +import { + codexLoadedHome, + decideHookDispatch, + isPluginHookInvocation, + normalizeHostHookMode, + recordPluginInvocation, + resolvePluginHostHome, + writePluginSetupNotice, +} from "./host-plugin-mode"; import { ensureRuntimeHomeLayout, resolveRuntimeHome } from "./local-runtime"; import { compactContinuitySummary } from "./summary-compaction"; import { captureHotFinding } from "./hot-continuity"; @@ -362,7 +372,9 @@ export const runOnlyBotsCodexHookCli = async ( const eventName = positionals[0] ?? "help"; const normalizedEventName = eventName.toLowerCase(); const runtimeHome = resolveRuntimeHome(typeof flags.home === "string" ? flags.home : undefined); - const hostHome = typeof flags["codex-home"] === "string" ? flags["codex-home"] : undefined; + const viaPlugin = isPluginHookInvocation(flags); + const hostHome = + typeof flags["codex-home"] === "string" ? flags["codex-home"] : viaPlugin ? resolvePluginHostHome("codex", runtimeHome) : undefined; const payload = readStdinJson(stdinOverride); const workspaceRoot = resolveHookWorkspaceRoot(payload, flags); const workspaceId = resolveHookWorkspaceId(payload, flags, workspaceRoot); @@ -370,6 +382,43 @@ export const runOnlyBotsCodexHookCli = async ( flags["allow-ephemeral-config"] === true || flags["allow-ephemeral-config"] === "1" || (process.env.CROSSHEAD_CODEX_ALLOW_EPHEMERAL_CONFIG ?? process.env.ONLYBOTS_CODEX_ALLOW_EPHEMERAL_CONFIG) === "1"; + if (eventName !== "help") { + let installedConfig: ReturnType | null = null; + try { + installedConfig = loadCodexAdapterConfig(runtimeHome, hostHome, { allowEphemeralConfig }); + } catch (error) { + if (!viaPlugin) throw error; + } + if (!installedConfig) { + // Plugin installed before setup: tell the user how to finish, never fail the session. + if (normalizedEventName === "sessionstart" || normalizedEventName === "session_start") { + writePluginSetupNotice(pluginSetupHint("codex")); + } + return; + } + if (viaPlugin) { + // Proof that Codex runs (so the user trusted) the plugin hooks; migration waits for it. + try { + recordPluginInvocation(runtimeHome, "codex", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null, codexLoadedHome(resolveCodexHome(hostHome))); + } catch { + // A receipt is diagnostic only; never fail the hook over it. + } + } + // The hooks.json Codex loaded: an installer hook names it with --codex-home; + // the plugin launcher passes the CODEX_HOME Codex ran with. + const loadedHooksFile = codexLegacyHooksFile( + viaPlugin ? codexLoadedHome(resolveCodexHome(hostHome)) : resolveCodexHome(hostHome) + ); + const decision = decideHookDispatch({ + viaPlugin, + mode: normalizeHostHookMode(installedConfig.hookMode), + legacyHooksPresent: () => codexHooksFileHasLegacyHooks(loadedHooksFile, runtimeHome, { + event: eventName, + matcherValue: normalizedEventName.replace(/_/g, "") === "sessionstart" ? pickString(payload, ["source", "payload.source"]) ?? undefined : pickCompactionTrigger(payload) ?? undefined, + }), + }); + if (decision === "skip-duplicate") return; + } const adapter = CodexRuntimeAdapter.fromInstalled({ home: runtimeHome, hostHome, diff --git a/src/runtime/daemon-ownership.ts b/src/runtime/daemon-ownership.ts index 0c3fe37..f3aaa9d 100644 --- a/src/runtime/daemon-ownership.ts +++ b/src/runtime/daemon-ownership.ts @@ -51,34 +51,53 @@ export const daemonOwnerIsLive = (state: RuntimeDaemonState | null): boolean => catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } }; -/** A bounded health response belongs to the recorded owner, not just a port. */ -export const probeRuntimeDaemonHealth = async (state: RuntimeDaemonState, timeoutMs = 1_000): Promise => { - if (state.status !== "running" || !state.port || !daemonOwnerIsLive(state)) return false; +export type RuntimeDaemonHealth = { + coreVersion?: string; + /** Entry script of the running daemon (absent in daemons that predate it). */ + daemonScriptPath?: string; +}; + +/** + * Authenticated health of the recorded owner, or null when it is not the live + * recorded owner. A bounded health response belongs to the owner, not just a port. + */ +export const readRuntimeDaemonHealth = async (state: RuntimeDaemonState, timeoutMs = 1_000): Promise => { + if (state.status !== "running" || !state.port || !daemonOwnerIsLive(state)) return null; try { const response = await fetch(`http://127.0.0.1:${state.port}/health`, { redirect: "error", signal: AbortSignal.timeout(Math.max(1, timeoutMs)), headers: { "x-onlybots-local-token": state.controlToken }, }); - if (!response.ok) return false; - if (!state.ownerId) return true; // Read-only compatibility for legacy runtimes. + if (!response.ok) return null; const reader = response.body?.getReader(); - if (!reader) return false; + if (!reader) return state.ownerId ? null : {}; const chunks: Uint8Array[] = []; let bytes = 0; while (true) { const chunk = await reader.read(); if (chunk.done) break; bytes += chunk.value.byteLength; - if (bytes > 4_096) { await reader.cancel(); return false; } + if (bytes > 4_096) { await reader.cancel(); return null; } chunks.push(chunk.value); } - const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { ownerId?: string; ownerProcess?: HostedControllerProcess }; + let body: { ownerId?: string; ownerProcess?: HostedControllerProcess; coreVersion?: unknown; daemonScriptPath?: unknown }; + try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } + catch { if (!state.ownerId) return {}; return null; } + const health: RuntimeDaemonHealth = { + ...(typeof body.coreVersion === "string" ? { coreVersion: body.coreVersion } : {}), + ...(typeof body.daemonScriptPath === "string" ? { daemonScriptPath: body.daemonScriptPath } : {}), + }; + if (!state.ownerId) return health; // Read-only compatibility for legacy runtimes. return body.ownerId === state.ownerId && body.ownerProcess?.pid === state.ownerProcess?.pid && body.ownerProcess?.startedAtTicks === state.ownerProcess?.startedAtTicks && - body.ownerProcess?.bootId === state.ownerProcess?.bootId && daemonOwnerIsLive(state); - } catch { return false; } + body.ownerProcess?.bootId === state.ownerProcess?.bootId && daemonOwnerIsLive(state) ? health : null; + } catch { return null; } }; +/** A bounded health response belongs to the recorded owner, not just a port. */ +export const probeRuntimeDaemonHealth = async (state: RuntimeDaemonState, timeoutMs = 1_000): Promise => + (await readRuntimeDaemonHealth(state, timeoutMs)) !== null; + export class RuntimeDaemonOwnership { readonly ownerId = randomUUID(); readonly ownerProcess: HostedControllerProcess = readRuntimeProcess(process.pid); diff --git a/src/runtime/host-hook-doctor.ts b/src/runtime/host-hook-doctor.ts new file mode 100644 index 0000000..89a4dfc --- /dev/null +++ b/src/runtime/host-hook-doctor.ts @@ -0,0 +1,147 @@ +import path from "path"; + +import { countLegacyClaudeHookCommands, findLegacyClaudeHookFiles, loadClaudeCodeAdapterConfig } from "./claude-code-adapter"; +import { codexHooksFileHasLegacyHooks, codexLegacyHooksFile, loadCodexAdapterConfig } from "./codex-adapter"; +import type { HookHost } from "./host-hook-spec"; +import { + detectClaudeCodePlugin, + detectCodexPlugin, + normalizeHookModeRequest, + normalizeHostHookMode, + readPluginInvocation, + pluginInvocationMatches, + type HostHookMode, + type PluginDetection, + type PluginInvocationRecord, +} from "./host-plugin-mode"; +import type { RuntimeDoctorCheck } from "./local-runtime"; +import { loadAdapterRecord, resolveRuntimePaths } from "./runtime-persistence"; + +export type HostHookInspection = { + host: HookHost; + hostHome?: string; + mode: HostHookMode; + plugin: PluginDetection; + /** Installer-written Crosshead hooks the host loads for the inspected workspace. */ + legacyHookFiles: string[]; + /** Last observed plugin hook run, or null if the host never ran one. */ + pluginInvocation?: PluginInvocationRecord | null; + /** The last install asked for plugin hooks (`--hook-mode plugin`). */ + pluginRequested?: boolean; +}; + +const installCommand = (host: HookHost): string => `crosshead install ${host}`; + +const CODEX_TRUST_HINT = + "Codex runs plugin hooks only after you trust them: open /hooks in Codex, review and trust the Crosshead hooks (again after each plugin update), then start a session."; + +const describePlugin = (plugin: PluginDetection): string => { + const state = plugin.enabled + ? `plugin enabled${plugin.source ? ` in ${plugin.source}` : ""}${plugin.enabledAtUserScope ? "" : " (this project only)"}` + : plugin.source + ? `plugin disabled in ${plugin.source}` + : "plugin not enabled"; + return plugin.ignored ? `${state} (${plugin.ignored})` : state; +}; + +/** Doctor verdict for one host's hook registration. Exported for tests. */ +export const evaluateHostHookInspection = (inspection: HostHookInspection): RuntimeDoctorCheck => { + const { host, mode, plugin, legacyHookFiles } = inspection; + const id = `${host === "codex" ? "codex" : "claude_code"}_hooks`; + const legacyState = legacyHookFiles.length > 0 ? `legacy hooks in ${legacyHookFiles.join(", ")}` : "no legacy hooks"; + const summary = `mode=${mode}; ${describePlugin(plugin)}; ${legacyState}.`; + const hasLegacy = legacyHookFiles.length > 0; + // Codex: plugin hooks that never ran are most likely untrusted. + const codexUnseen = host === "codex" && plugin.enabled && !pluginInvocationMatches(inspection.pluginInvocation, inspection.hostHome ?? ""); + const fail = (message: string): RuntimeDoctorCheck => ({ id, ok: false, message: `${summary} ${message}` }); + const ok = (message?: string): RuntimeDoctorCheck => ({ id, ok: true, message: message ? `${summary} ${message}` : summary }); + + if (mode === "plugin") { + if (!plugin.enabled) { + return hasLegacy + ? fail(`The plugin is not enabled here, so only the installer hooks capture: enable the Crosshead plugin, or run ${installCommand(host)} --hook-mode settings.`) + : fail(`No Crosshead hooks are active: enable the Crosshead plugin, or run ${installCommand(host)} --hook-mode settings.`); + } + if (hasLegacy && codexUnseen) { + return fail(`Plugin mode is configured, but Codex has not run a Crosshead plugin hook yet, so the installer hooks are kept and capture meanwhile. ${CODEX_TRUST_HINT} Then run ${installCommand(host)} to remove the installer hooks.`); + } + if (hasLegacy) { + return fail(`Duplicate registration (the plugin defers to the installer hooks while they exist): run ${installCommand(host)} to remove them.`); + } + if (codexUnseen) { + return fail(`Plugin mode is configured, but no Crosshead plugin hook invocation has been seen, so nothing may be capturing. ${CODEX_TRUST_HINT}`); + } + return ok(); + } + const requested = inspection.pluginRequested + ? ` Plugin hooks were requested; enable the plugin at user scope and run ${installCommand(host)} --hook-mode plugin to migrate.` + : ""; + if (plugin.enabled && hasLegacy) { + if (!plugin.enabledAtUserScope) { + return ok(`The plugin is enabled only for this project, so the installer hooks stay in place for other projects; here the plugin defers to them and each event is captured once.${requested}`); + } + return fail( + `Duplicate registration (the plugin defers to the installer hooks here): run ${installCommand(host)} --hook-mode plugin to migrate${codexUnseen ? ` once Codex has run the plugin hooks. ${CODEX_TRUST_HINT}` : "."}` + ); + } + if (plugin.enabled && codexUnseen) { + return fail(`No Crosshead plugin hook invocation has been seen and no installer hooks are present, so nothing may be capturing. ${CODEX_TRUST_HINT}`); + } + if (!plugin.enabled && !hasLegacy) { + return fail(`No Crosshead hooks found for this workspace: install the Crosshead plugin or rerun ${installCommand(host)}.${requested}`); + } + return ok(requested ? requested.trim() : undefined); +}; + +/** Hook registration checks for each installed Claude Code / Codex adapter. */ +export const inspectHostHookRegistrations = (home?: string, workspaceRoot: string = process.cwd()): RuntimeDoctorCheck[] => { + const paths = resolveRuntimePaths(home); + const workspace = path.resolve(workspaceRoot); + const checks: RuntimeDoctorCheck[] = []; + const claudeRecord = loadAdapterRecord(paths, "claude-code"); + if (claudeRecord) { + try { + const config = loadClaudeCodeAdapterConfig(paths.home, claudeRecord.hostHome ?? undefined); + const mode = normalizeHostHookMode(config.hookMode); + // Exactly the files Claude Code loads for a session in this workspace. + const legacy = findLegacyClaudeHookFiles(config.hostHome, workspace, paths.home); + // In plugin mode, also report what a migration left in the configured workspace. + const configured = path.join(config.workspaceRoot, ".claude", "settings.json"); + if (mode === "plugin" && !legacy.includes(configured) && countLegacyClaudeHookCommands(configured, paths.home) > 0) legacy.push(configured); + checks.push( + evaluateHostHookInspection({ + host: "claude-code", + hostHome: config.hostHome, + mode, + plugin: detectClaudeCodePlugin(config.hostHome, workspace), + legacyHookFiles: legacy, + pluginInvocation: readPluginInvocation(paths.home, "claude-code"), + pluginRequested: normalizeHookModeRequest(config.hookModeRequest) === "plugin", + }) + ); + } catch (error) { + checks.push({ id: "claude_code_hooks", ok: false, message: error instanceof Error ? error.message : String(error) }); + } + } + const codexRecord = loadAdapterRecord(paths, "codex"); + if (codexRecord) { + try { + const config = loadCodexAdapterConfig(paths.home, codexRecord.hostHome ?? undefined); + const hooksFile = codexLegacyHooksFile(config.hostHome); + checks.push( + evaluateHostHookInspection({ + host: "codex", + hostHome: config.hostHome, + mode: normalizeHostHookMode(config.hookMode), + plugin: detectCodexPlugin(config.hostHome, workspace), + legacyHookFiles: codexHooksFileHasLegacyHooks(hooksFile, paths.home) ? [hooksFile] : [], + pluginInvocation: readPluginInvocation(paths.home, "codex"), + pluginRequested: normalizeHookModeRequest(config.hookModeRequest) === "plugin", + }) + ); + } catch (error) { + checks.push({ id: "codex_hooks", ok: false, message: error instanceof Error ? error.message : String(error) }); + } + } + return checks; +}; diff --git a/src/runtime/host-hook-spec.ts b/src/runtime/host-hook-spec.ts new file mode 100644 index 0000000..deb66d3 --- /dev/null +++ b/src/runtime/host-hook-spec.ts @@ -0,0 +1,104 @@ +// Single source of truth for the Claude Code and Codex lifecycle hooks Crosshead +// registers. The legacy installers (`crosshead install claude-code|codex`) and the +// generated host plugins (`plugins/`, see scripts/host-plugins.mjs) both render +// their hook files from this table, so the two modes cannot drift apart. + +export type HookHost = "claude-code" | "codex"; + +export type HostHookEvent = "SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop"; + +export type HostHookHandler = { + type: "command"; + command: string; + statusMessage?: string; + timeout?: number; +}; + +export type HostHookMatcherGroup = { + matcher?: string; + hooks: HostHookHandler[]; +}; + +type HostHookEventSpec = { + event: HostHookEvent; + matcher?: string; + /** Codex shows this while the hook runs. Claude Code registrations omit it. */ + codexStatusMessage: string; + /** Codex timeout in seconds. Claude Code uses its own defaults. */ + codexTimeout?: number; +}; + +export const HOST_HOOK_EVENTS: readonly HostHookEventSpec[] = [ + { event: "SessionStart", matcher: "startup|resume|clear|compact|fork", codexStatusMessage: "Crosshead session sync" }, + { event: "PreCompact", matcher: "manual|auto", codexStatusMessage: "Crosshead pre-compact checkpoint", codexTimeout: 30 }, + { event: "PostCompact", matcher: "manual|auto", codexStatusMessage: "Crosshead post-compact sync", codexTimeout: 30 }, + { event: "UserPromptSubmit", codexStatusMessage: "Crosshead context", codexTimeout: 45 }, + { event: "Stop", codexStatusMessage: "Crosshead memory sync", codexTimeout: 30 }, +]; + +/** Plugin identity shared by both hosts' marketplaces: `crosshead@crosshead`. */ +export const CROSSHEAD_PLUGIN_NAME = "crosshead"; +export const CROSSHEAD_MARKETPLACE_NAME = "crosshead"; +/** Bump when any generated plugin file changes; hosts pin installed plugins by version. */ +export const CROSSHEAD_HOST_PLUGIN_VERSION = "1.0.1"; + +/** Path, relative to the plugin root, of the launcher the plugin hooks run. */ +export const PLUGIN_HOOK_LAUNCHER = "scripts/crosshead-hook"; + +/** Flag the plugin launcher adds so the runtime can tell plugin and legacy invocations apart. */ +export const PLUGIN_HOOK_VIA_FLAG = ["--via", "plugin"] as const; + +export const pluginRootVariable = (host: HookHost): string => (host === "codex" ? "PLUGIN_ROOT" : "CLAUDE_PLUGIN_ROOT"); + +/** + * Marker in the runtime's Claude Code and Codex hook wrappers. A wrapper without + * it runs a runtime that predates plugin dispatch, which the launcher must not + * call (it would treat a plugin invocation as an installer hook). + */ +export const PLUGIN_DISPATCH_CAPABILITY = "crosshead-hook-capability: plugin-dispatch-v1"; + +/** Flag the launcher adds so invocation receipts record which plugin version ran. */ +export const PLUGIN_VERSION_FLAG = "--plugin-version"; + +const setupCommand = (host: HookHost): string => (host === "codex" ? "type $crosshead:setup" : "run /crosshead:setup"); + +// SessionStart notices are shown to the user only (systemMessage); they are not +// added to model context, so the model is never invited to run setup itself. +export const pluginSetupHint = (host: HookHost): string => `Crosshead isn't set up. To set it up, ${setupCommand(host)}.`; + +export const pluginUpdateHint = (host: HookHost): string => + `The installed Crosshead runtime predates this plugin. To update it, ${setupCommand(host)}.`; + +export const PLUGIN_HOME_CONFLICT_HINT = + "Crosshead found separate ~/.crosshead and ~/.onlybots installations. Set CROSSHEAD_HOME to choose one; no state was moved."; + +/** Ordered `[event, matcher group]` pairs for one host, with the caller's command per event. */ +export const renderHostHookGroups = ( + host: HookHost, + commandFor: (event: HostHookEvent) => string +): Array => + HOST_HOOK_EVENTS.map((spec) => { + const handler: HostHookHandler = { type: "command", command: commandFor(spec.event) }; + if (host === "codex") { + handler.statusMessage = spec.codexStatusMessage; + if (spec.codexTimeout !== undefined) handler.timeout = spec.codexTimeout; + } + const group: HostHookMatcherGroup = spec.matcher === undefined ? { hooks: [handler] } : { matcher: spec.matcher, hooks: [handler] }; + return [spec.event, group] as const; + }); + +/** + * The command a plugin hook runs. The launcher resolves the installed runtime at + * run time. It is run through `sh` so a lost executable bit cannot break it. + */ +export const pluginHookCommand = (host: HookHost, event: HostHookEvent): string => + `sh "\${${pluginRootVariable(host)}}/${PLUGIN_HOOK_LAUNCHER}" ${host} ${event}`; + +/** Contents of a plugin's `hooks/hooks.json`. */ +export const renderPluginHooksFile = (host: HookHost): string => { + const hooks: Partial> = {}; + for (const [event, group] of renderHostHookGroups(host, (name) => pluginHookCommand(host, name))) { + hooks[event] = [group]; + } + return `${JSON.stringify({ description: "Crosshead persistent memory hooks. They run the locally installed Crosshead runtime.", hooks }, null, 2)}\n`; +}; diff --git a/src/runtime/host-plugin-mode.ts b/src/runtime/host-plugin-mode.ts new file mode 100644 index 0000000..ff4d640 --- /dev/null +++ b/src/runtime/host-plugin-mode.ts @@ -0,0 +1,542 @@ +import { existsSync, readFileSync } from "fs"; +import os from "os"; +import path from "path"; + +import { CROSSHEAD_HOST_PLUGIN_VERSION, CROSSHEAD_MARKETPLACE_NAME, CROSSHEAD_PLUGIN_NAME, PLUGIN_HOOK_VIA_FLAG, type HookHost } from "./host-hook-spec"; +import { ensureRuntimeHomeLayout, loadAdapterRecord, parseJsonFile, resolveRuntimePaths, writeJsonFile } from "./runtime-persistence"; + +// Hook registration modes for Claude Code and Codex: +// - "settings": the legacy installer writes hooks into .claude/settings.json or +// $CODEX_HOME/hooks.json. +// - "plugin": the Crosshead host plugin supplies the hooks; the installer writes +// only the adapter config and wrappers, and removes legacy hook entries. +// Plugin mode is recorded only when the plugin is enabled at user scope, because +// the mode and the legacy hooks it removes apply to every project. The hook +// entrypoint makes sure exactly one registration handles each event. + +export type HostHookMode = "settings" | "plugin"; + +export type PluginScope = "user" | "project" | "local"; + +export type PluginDetection = { + /** Effective state for the inspected workspace. */ + enabled: boolean; + /** Config file that decided the effective state, or null when no loaded file mentions the plugin. */ + source: string | null; + scope: PluginScope | null; + /** True when the user-scope config enables the plugin, so it applies in every project. */ + enabledAtUserScope: boolean; + /** Why a project-level setting was not applied (Codex ignores untrusted project config). */ + ignored?: string; +}; + +/** The only plugin id this runtime recognizes: this repository's marketplace. */ +export const CROSSHEAD_PLUGIN_ID = `${CROSSHEAD_PLUGIN_NAME}@${CROSSHEAD_MARKETPLACE_NAME}`; + +const readJson = (filePath: string): Record | null => { + try { + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : null; + } catch { + return null; + } +}; + +const nonEmpty = (value: string | undefined): string | undefined => (value && value.length > 0 ? value : undefined); + +/** Nearest ancestor (including `start`) that contains a `.git` entry. */ +const findGitRoot = (start: string): string | null => { + let current = path.resolve(start); + for (;;) { + if (existsSync(path.join(current, ".git"))) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +// ---- Claude Code ------------------------------------------------------------ + +/** The user settings directory Claude Code itself loads: `CLAUDE_CONFIG_DIR`, else `~/.claude`. */ +export const claudeUserConfigDir = (env: NodeJS.ProcessEnv = process.env): string => + path.resolve(nonEmpty(env.CLAUDE_CONFIG_DIR) ?? path.join(os.homedir(), ".claude")); + +/** The project directory of a Claude Code session: `CLAUDE_PROJECT_DIR` when set, else the payload cwd. */ +export const claudeSessionProjectDir = (fallback: string, env: NodeJS.ProcessEnv = process.env): string => + path.resolve(nonEmpty(env.CLAUDE_PROJECT_DIR) ?? fallback); + +export type ClaudeSettingsSource = { scope: PluginScope; file: string }; + +/** + * The settings files Claude Code loads for a session whose project directory is + * `projectDir`, highest precedence first (managed settings excluded): + * - `.claude/settings.local.json` at the git repository root (Claude Code 2.1.211+; + * not when the root is the home directory), and in the project directory; + * - `.claude/settings.json` in the project directory only (no ancestor lookup); + * - `settings.json` in the user settings directory. + * See https://code.claude.com/docs/en/settings.md. + */ +export const claudeLoadedSettingsFiles = (userConfigDir: string, projectDir: string): ClaudeSettingsSource[] => { + const project = path.resolve(projectDir); + const gitRoot = findGitRoot(project); + const localDirs = gitRoot && gitRoot !== path.resolve(os.homedir()) ? [gitRoot, project] : [project]; + const userFile = path.join(path.resolve(userConfigDir), "settings.json"); + const sources: ClaudeSettingsSource[] = [ + ...localDirs.map((dir) => ({ scope: "local" as const, file: path.join(dir, ".claude", "settings.local.json") })), + { scope: "project", file: path.join(project, ".claude", "settings.json") }, + ]; + // In the home directory the project file is the user file; report it once, as user settings. + const seen = new Set([userFile]); + return [...sources.filter((source) => (seen.has(source.file) ? false : (seen.add(source.file), true))), { scope: "user", file: userFile }]; +}; + +const claudeEnabledPluginValue = (filePath: string): boolean | null => { + const enabledPlugins = readJson(filePath)?.enabledPlugins; + if (!enabledPlugins || typeof enabledPlugins !== "object" || Array.isArray(enabledPlugins)) return null; + const value = (enabledPlugins as Record)[CROSSHEAD_PLUGIN_ID]; + return typeof value === "boolean" ? value : null; +}; + +export const detectClaudeCodePlugin = (userConfigDir: string, projectDir: string): PluginDetection => { + const sources = claudeLoadedSettingsFiles(userConfigDir, projectDir); + const userSource = sources.find((source) => source.scope === "user")!; + const enabledAtUserScope = claudeEnabledPluginValue(userSource.file) === true; + for (const source of sources) { + const value = claudeEnabledPluginValue(source.file); + if (value === null) continue; + return { enabled: value, source: source.file, scope: source.scope, enabledAtUserScope }; + } + return { enabled: false, source: null, scope: null, enabledAtUserScope }; +}; + +// ---- Codex: a small TOML reader for the keys Crosshead needs --------------- + +/** Removes a `#` comment that is outside a string. */ +const stripTomlComment = (line: string): string => { + let quote: '"' | "'" | null = null; + for (let index = 0; index < line.length; index++) { + const char = line[index]!; + if (quote === '"' && char === "\\") { + index++; + continue; + } + if (quote) { + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") quote = char; + else if (char === "#") return line.slice(0, index); + } + return line; +}; + +/** Splits a dotted TOML key (`a."b.c".d`) into its parts, or null if it is malformed. */ +const parseTomlKey = (raw: string): string[] | null => { + const parts: string[] = []; + let rest = raw.trim(); + while (rest.length > 0) { + let part: string; + const quoted = /^"((?:[^"\\]|\\.)*)"|^'([^']*)'/.exec(rest); + if (quoted) { + part = quoted[1] !== undefined ? quoted[1].replace(/\\(.)/g, "$1") : quoted[2]!; + rest = rest.slice(quoted[0].length).trimStart(); + } else { + const bare = /^[A-Za-z0-9_-]+/.exec(rest); + if (!bare) return null; + part = bare[0]; + rest = rest.slice(part.length).trimStart(); + } + parts.push(part); + if (rest.length === 0) break; + if (!rest.startsWith(".")) return null; + rest = rest.slice(1).trimStart(); + } + return parts.length > 0 ? parts : null; +}; + +type TomlScalar = boolean | string; +type TomlEntries = { tables: string[][]; values: Array<{ key: string[]; value: TomlScalar }> }; + +const parseTomlScalar = (raw: string): TomlScalar | undefined => { + const value = raw.trim(); + if (value === "true") return true; + if (value === "false") return false; + const quoted = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(value); + if (quoted) return quoted[1] !== undefined ? quoted[1].replace(/\\(.)/g, "$1") : quoted[2]!; + return undefined; +}; + +/** Index of the first `ch` outside strings and brackets, or -1. */ +const indexOutside = (text: string, ch: string, trackDepth: boolean): number => { + let depth = 0; + let quote: string | null = null; + for (let index = 0; index < text.length; index++) { + const char = text[index]!; + if (quote) { + if (quote === '"' && char === "\\") index++; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") quote = char; + else if (trackDepth && (char === "{" || char === "[")) depth++; + else if (trackDepth && (char === "}" || char === "]")) depth--; + else if (char === ch && depth === 0) return index; + } + return -1; +}; + +/** Splits `a = 1, b = { c = 2 }` at top-level commas. */ +const splitInlineTable = (body: string): string[] => { + const parts: string[] = []; + let rest = body; + for (;;) { + const comma = indexOutside(rest, ",", true); + if (comma < 0) break; + parts.push(rest.slice(0, comma)); + rest = rest.slice(comma + 1); + } + parts.push(rest); + return parts.filter((part) => part.trim().length > 0); +}; + +const collectTomlAssignment = (prefix: string[], assignment: string, entries: TomlEntries): void => { + const equals = indexOutside(assignment, "=", false); + if (equals < 0) return; + const key = parseTomlKey(assignment.slice(0, equals)); + if (!key) return; + const full = [...prefix, ...key]; + const rawValue = assignment.slice(equals + 1).trim(); + if (rawValue.startsWith("{") && rawValue.endsWith("}")) { + entries.tables.push(full); + for (const part of splitInlineTable(rawValue.slice(1, -1))) collectTomlAssignment(full, part, entries); + return; + } + const scalar = parseTomlScalar(rawValue); + if (scalar !== undefined) entries.values.push({ key: full, value: scalar }); +}; + +const bracketBalance = (text: string): number => { + let balance = 0; + let quote: string | null = null; + for (let index = 0; index < text.length; index++) { + const char = text[index]!; + if (quote) { + if (quote === '"' && char === "\\") index++; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") quote = char; + else if (char === "[" || char === "{") balance++; + else if (char === "]" || char === "}") balance--; + } + return balance; +}; + +/** + * Collects tables and boolean/string values from a Codex config.toml. Enough of + * TOML for `[plugins."id"]`, `[projects."path"]`, dotted keys and inline tables; + * multi-line strings and arrays are skipped without being misread as tables. + */ +export const parseTomlEntries = (content: string): TomlEntries => { + const entries: TomlEntries = { tables: [], values: [] }; + let table: string[] = []; + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + let line = stripTomlComment(lines[index]!).trim(); + if (line.length === 0) continue; + const multiline = /("""|''')/.exec(line); + if (multiline && line.indexOf(multiline[1]!, multiline.index + 3) < 0) { + // Skip to the end of a multi-line string. + while (++index < lines.length && !lines[index]!.includes(multiline[1]!)); + continue; + } + if (line.startsWith("[[")) { + // Arrays of tables are not used for these keys; ignore their contents. + table = ["\u0000array"]; + continue; + } + if (line.startsWith("[")) { + const header = /^\[([^\]]*)\]$/.exec(line); + const key = header ? parseTomlKey(header[1]!) : null; + table = key ?? ["\u0000invalid"]; + if (key) entries.tables.push(key); + continue; + } + // Join a value that spans lines (multi-line arrays or inline tables). + while (bracketBalance(line) > 0 && index + 1 < lines.length) { + line = `${line} ${stripTomlComment(lines[++index]!).trim()}`; + } + collectTomlAssignment(table, line, entries); + } + return entries; +}; + +const keyEquals = (left: string[], right: string[]): boolean => left.length === right.length && left.every((part, index) => part === right[index]); + +const tomlValue = (entries: TomlEntries, key: string[]): TomlScalar | undefined => { + let result: TomlScalar | undefined; + for (const entry of entries.values) if (keyEquals(entry.key, key)) result = entry.value; + return result; +}; + +/** + * Reads `[plugins."crosshead@crosshead"] enabled = true|false` from a Codex + * config.toml. A listed plugin is enabled unless `enabled = false`. Returns null + * when the file does not mention the plugin. + */ +export const readCodexPluginEnabled = (content: string): boolean | null => { + const entries = parseTomlEntries(content); + const key = ["plugins", CROSSHEAD_PLUGIN_ID]; + const enabled = tomlValue(entries, [...key, "enabled"]); + if (typeof enabled === "boolean") return enabled; + const mentioned = + entries.tables.some((table) => keyEquals(table, key)) || + entries.values.some((entry) => entry.key.length > 2 && keyEquals(entry.key.slice(0, 2), key)); + return mentioned ? true : null; +}; + +/** True when the user config marks `dir` as a trusted project (`[projects.""] trust_level = "trusted"`). */ +export const readCodexProjectTrusted = (content: string, dir: string): boolean => + tomlValue(parseTomlEntries(content), ["projects", path.resolve(dir), "trust_level"]) === "trusted"; + +const readText = (filePath: string): string | null => { + try { + return existsSync(filePath) ? readFileSync(filePath, "utf8") : null; + } catch { + return null; + } +}; + +/** Project-level Codex config files for a workspace: the workspace and its git root. */ +export const codexProjectConfigFiles = (workspaceRoot: string): Array<{ dir: string; file: string }> => { + const workspace = path.resolve(workspaceRoot); + const gitRoot = findGitRoot(workspace); + const dirs = [workspace, ...(gitRoot && gitRoot !== workspace ? [gitRoot] : [])]; + return dirs.map((dir) => ({ dir, file: path.join(dir, ".codex", "config.toml") })); +}; + +/** + * Codex plugin state for a workspace. The user config (`$CODEX_HOME/config.toml`) + * applies everywhere. A project's `.codex/config.toml` applies only when the user + * config trusts that project; otherwise Codex ignores it. + */ +export const detectCodexPlugin = (codexHome: string, workspaceRoot: string = process.cwd()): PluginDetection => { + const userFile = path.join(path.resolve(codexHome), "config.toml"); + const userContent = readText(userFile); + const userValue = userContent === null ? null : readCodexPluginEnabled(userContent); + const enabledAtUserScope = userValue === true; + let ignored: string | undefined; + for (const { dir, file } of codexProjectConfigFiles(workspaceRoot)) { + if (file === userFile) continue; + const content = readText(file); + if (content === null) continue; + const value = readCodexPluginEnabled(content); + if (value === null) continue; + const trusted = userContent !== null && readCodexProjectTrusted(userContent, dir); + if (!trusted) { + ignored = `${file} is ignored until Codex trusts ${dir}`; + continue; + } + return { enabled: value, source: file, scope: "project", enabledAtUserScope }; + } + const extra = ignored ? { ignored } : {}; + if (userValue !== null) return { enabled: userValue, source: userFile, scope: "user", enabledAtUserScope, ...extra }; + return { enabled: false, source: null, scope: null, enabledAtUserScope, ...extra }; +}; + +// ---- Hook mode selection ---------------------------------------------------- + +export type HookModeRequest = HostHookMode | "auto"; + +export type HookModeResolution = { + mode: HostHookMode; + detection: PluginDetection; + /** The explicit request this install honored, kept so later reinstalls (for example `crosshead update`) repeat it. */ + request: HostHookMode | null; + /** False when plugin hooks were requested but cannot be used globally: write no installer hooks either. */ + writeLegacyHooks: boolean; + note: string | null; +}; + +/** + * `--hook-mode plugin|settings|auto` (default auto, or the explicit request of a + * previous install). Plugin mode needs the plugin enabled at user scope: it + * removes the installer hooks, which can cover every project. When plugin hooks + * are requested but the plugin is enabled only for some projects (or not yet), + * the mode stays "settings", existing installer hooks are kept, no new ones are + * written, and the plugin runs wherever no installer hooks load. + */ +export const resolveHostHookMode = ( + requested: string | boolean | undefined, + detect: () => PluginDetection, + previousRequest?: HostHookMode | null +): HookModeResolution => { + const request = parseHookModeFlag(requested) ?? previousRequest ?? "auto"; + const detection = detect(); + if (request === "settings") return { mode: "settings", detection, request: "settings", writeLegacyHooks: true, note: null }; + if (detection.enabledAtUserScope) { + return { mode: "plugin", detection, request: request === "plugin" ? "plugin" : null, writeLegacyHooks: false, note: null }; + } + if (request === "auto") return { mode: "settings", detection, request: null, writeLegacyHooks: true, note: null }; + const where = detection.enabled && detection.source ? `enabled only in ${detection.source}` : "not enabled at user scope"; + return { + mode: "settings", + detection, + request: "plugin", + writeLegacyHooks: false, + note: `the Crosshead plugin is ${where}; existing installer hooks are kept, no new ones are written, and the plugin runs where none load. Enable the plugin at user scope and rerun to migrate.`, + }; +}; + +export const parseHookModeFlag = (value: string | boolean | undefined): HookModeRequest | undefined => { + if (value === undefined) return undefined; + if (value === "auto" || value === "plugin" || value === "settings") return value; + throw new Error("--hook-mode must be one of: auto, plugin, settings"); +}; + +export const describeHookMode = (mode: HostHookMode, detection: PluginDetection | null): string => + mode === "plugin" + ? `plugin (hooks come from the Crosshead plugin${detection?.source ? `; enabled in ${detection.source}` : ""})` + : "settings (hooks written by this installer)"; + +export const normalizeHostHookMode = (value: unknown): HostHookMode => (value === "plugin" ? "plugin" : "settings"); + +export const normalizeHookModeRequest = (value: unknown): HostHookMode | null => + value === "plugin" || value === "settings" ? value : null; + +/** + * True when a Claude Code or Codex adapter config file (marker line, then JSON) + * relies on plugin hooks: plugin mode, or plugin hooks requested where the plugin + * is not enabled at user scope (no installer hooks are written then). + */ +export const adapterConfigUsesPluginHooks = (configFile: string): boolean => { + try { + const content = readFileSync(configFile, "utf8"); + const parsed = JSON.parse(content.slice(content.indexOf("{"))) as { hookMode?: unknown; hookModeRequest?: unknown }; + return normalizeHostHookMode(parsed.hookMode) === "plugin" || normalizeHookModeRequest(parsed.hookModeRequest) === "plugin"; + } catch { + return false; + } +}; + +// ---- Dispatch ----------------------------------------------------------------- + +export const isPluginHookInvocation = (flags: Record): boolean => + flags[PLUGIN_HOOK_VIA_FLAG[0].slice(2)] === PLUGIN_HOOK_VIA_FLAG[1]; + +export type HookDispatchDecision = "run" | "skip-duplicate"; + +/** + * Exactly one registration handles each event. `legacyHooksPresent` answers + * whether the host loaded installer-written Crosshead hooks for this session. + * - Installer hooks loaded: they run and the plugin defers, in either mode. + * This covers projects the migration never saw and, for Codex, the window + * before the user trusts the plugin hooks. + * - No installer hooks loaded: the plugin runs. In plugin mode a stale + * installer invocation (for example from a session that started before the + * migration) is skipped; in settings mode it still runs. + */ +export const decideHookDispatch = (input: { + viaPlugin: boolean; + mode: HostHookMode; + legacyHooksPresent: () => boolean; +}): HookDispatchDecision => { + if (input.viaPlugin) return input.legacyHooksPresent() ? "skip-duplicate" : "run"; + if (input.mode === "settings") return "run"; + return input.legacyHooksPresent() ? "run" : "skip-duplicate"; +}; + +/** + * Counts hook handlers whose command `isCrosshead` recognizes. Tolerates any + * shape: non-object hooks, non-array events, null or non-object groups and + * handlers are ignored, so empty arrays never count. + */ +export type HookMatch = { event: string; matcherValue?: string }; + +export const countMatchingHookCommands = (settings: unknown, isCrosshead: (command: string) => boolean, match?: HookMatch): number => { + if (!settings || typeof settings !== "object" || Array.isArray(settings)) return 0; + const hooks = (settings as { hooks?: unknown }).hooks; + if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return 0; + let count = 0; + for (const [event, groups] of Object.entries(hooks as Record)) { + if (match && event.toLowerCase().replace(/_/g, "") !== match.event.toLowerCase().replace(/_/g, "")) continue; + if (!Array.isArray(groups)) continue; + for (const group of groups) { + if (!group || typeof group !== "object" || Array.isArray(group)) continue; + if (match) { + const matcher = (group as { matcher?: unknown }).matcher; + if (typeof matcher === "string" && matcher !== "" && matcher !== "*") { + if (match.matcherValue === undefined) continue; + try { if (!new RegExp(matcher).test(match.matcherValue)) continue; } catch { continue; } + } + } + const handlers = (group as { hooks?: unknown }).hooks; + if (!Array.isArray(handlers)) continue; + for (const handler of handlers) { + const command = handler && typeof handler === "object" ? (handler as { command?: unknown }).command : undefined; + if (typeof command === "string" && isCrosshead(command)) count++; + } + } + } + return count; +}; + +/** Reads a JSON settings file for inspection; missing or invalid files read as null. */ +export const readSettingsForInspection = (filePath: string): unknown => (existsSync(filePath) ? readJson(filePath) : null); + +// ---- Plugin invocation receipts ----------------------------------------------- + +export type PluginInvocationRecord = { lastSeenAt: string; pluginVersion: string | null; hostHome: string | null }; + +const invocationFile = (runtimeHome: string): string => path.join(path.resolve(runtimeHome), "state", "host-plugin-invocations.json"); + +export const readPluginInvocation = (runtimeHome: string, host: HookHost): PluginInvocationRecord | null => { + const records = parseJsonFile | null>(invocationFile(runtimeHome), null); + const record = records?.[host] as Partial | undefined; + return record && typeof record.lastSeenAt === "string" + ? { lastSeenAt: record.lastSeenAt, pluginVersion: typeof record.pluginVersion === "string" ? record.pluginVersion : null, hostHome: typeof record.hostHome === "string" ? record.hostHome : null } + : null; +}; + +/** + * Records that the host ran a plugin hook (for Codex, proof that the user trusted + * the plugin hooks). Rewritten at most every 10 minutes per host, or when the + * plugin version changes. + */ +export const pluginInvocationMatches = (record: PluginInvocationRecord | null | undefined, hostHome: string): boolean => + record?.pluginVersion === CROSSHEAD_HOST_PLUGIN_VERSION && record.hostHome === path.resolve(hostHome); + +export const recordPluginInvocation = (runtimeHome: string, host: HookHost, pluginVersion: string | null, hostHome: string, now: Date = new Date()): void => { + const previous = readPluginInvocation(runtimeHome, host); + const resolvedHome = path.resolve(hostHome); + if (previous && previous.pluginVersion === pluginVersion && previous.hostHome === resolvedHome && now.getTime() - Date.parse(previous.lastSeenAt) < 10 * 60_000) return; + ensureRuntimeHomeLayout(runtimeHome); + const file = invocationFile(runtimeHome); + const records = parseJsonFile | null>(file, null) ?? {}; + writeJsonFile(file, { ...records, [host]: { lastSeenAt: now.toISOString(), pluginVersion, hostHome: resolvedHome } }); +}; + +/** + * Host config directory for a plugin invocation, which carries no + * `--claude-home`/`--codex-home`: the host's own environment variable when set, + * otherwise the directory the adapter was installed into (so `--claude-home` + * and `--codex-home` installs work), otherwise the default. + */ +export const resolvePluginHostHome = (host: HookHost, runtimeHome: string, env: NodeJS.ProcessEnv = process.env): string | undefined => { + const fromEnv = host === "codex" ? nonEmpty(env.CODEX_HOME) : nonEmpty(env.CLAUDE_CONFIG_DIR) ?? nonEmpty(env.CLAUDE_CODE_HOME); + if (fromEnv) return path.resolve(fromEnv); + try { + return loadAdapterRecord(resolveRuntimePaths(runtimeHome), host)?.hostHome ?? undefined; + } catch { + return undefined; + } +}; + +/** The Codex home whose hooks.json the host loaded, as the plugin launcher saw it before the wrapper's environment. */ +export const codexLoadedHome = (fallback: string, env: NodeJS.ProcessEnv = process.env): string => + path.resolve(nonEmpty(env.CROSSHEAD_HOST_CODEX_HOME) ?? fallback); + +/** One-line SessionStart JSON both hosts accept: a notice for the user only, with no model context. */ +export const writePluginSetupNotice = (message: string): void => { + process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`); +}; diff --git a/src/runtime/hosted-client.ts b/src/runtime/hosted-client.ts index f9e7ff9..a5fe35d 100644 --- a/src/runtime/hosted-client.ts +++ b/src/runtime/hosted-client.ts @@ -246,6 +246,42 @@ export class HostedOnlyBotsRequestError extends Error { } } +/** + * Classify a failed hosted request for local diagnostics without copying its + * message, URL, headers or body: a timeout, an HTTP status/code, or an error name. + */ +const SAFE_FAILURE_LABEL = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; +const safeFailureLabel = (value: string | null): string | null => (value !== null && SAFE_FAILURE_LABEL.test(value) ? value : null); + +export const describeHostedFailure = (error: unknown): Record => { + // The server chooses `code`; validate it like an error name so it cannot carry message text. + if (error instanceof HostedOnlyBotsRequestError) return { errorClass: "http", status: error.status, code: safeFailureLabel(error.code) }; + const name = error instanceof Error ? error.name : typeof error; + if (name === "TimeoutError") return { errorClass: "timeout" }; + if (name === "AbortError") return { errorClass: "aborted" }; + return { errorClass: safeFailureLabel(name) ?? "unknown" }; +}; + +/** + * Warning for a bounded recall that fell back to free hot memory. Only a timeout, abort, + * network failure or unconfirmed server error leaves open whether the paid request settled. + */ +export const describeBoundedRecallFallback = (error: unknown): string => { + const prefix = "Saved-context retrieval unavailable; continuing with free hot memory."; + if (error instanceof HostedOnlyBotsRequestError) { + const details = error.details && typeof error.details === "object" ? (error.details as Record) : null; + if (error.code === "paid_operation_commit_unknown") { + return `${prefix} The payment outcome is unknown; retry only with the same idempotency key to resolve it without a duplicate charge.`; + } + if (error.code === "recall_deadline_exceeded" && details?.charged_sats === 0) { + return `${prefix} The server missed its response budget (HTTP ${error.status}) and charged nothing; a retry is safe.`; + } + if (details?.charged_sats === 0) return `${prefix} The server could not deliver the request (HTTP ${error.status}) and charged nothing; a retry is safe.`; + if (error.status < 500) return `${prefix} The server rejected the request (HTTP ${error.status}).`; + } + return `${prefix} A timed-out or failed paid request may still settle; inspect billing before retrying.`; +}; + const extractHostedError = (data: unknown): { message: string | null; code: string | null; details: unknown } => { if (!data || typeof data !== "object" || Array.isArray(data)) { return { message: null, code: null, details: undefined }; diff --git a/src/runtime/lifecycle-engine.ts b/src/runtime/lifecycle-engine.ts index a9c517d..0948fc2 100644 --- a/src/runtime/lifecycle-engine.ts +++ b/src/runtime/lifecycle-engine.ts @@ -19,7 +19,7 @@ import { pickHotContextValidationCommand, } from "./hot-context-cues"; import { buildRuntimeBillingRemediation, formatRuntimeBillingRemediation, isBillingBlockedError } from "./billing-remediation"; -import { HostedOnlyBotsClient, type HostedSession } from "./hosted-client"; +import { describeBoundedRecallFallback, describeHostedFailure, HostedOnlyBotsClient, type HostedSession } from "./hosted-client"; import { isLocalOnlyRuntimeProfile, readLocalHotMemory, writeLocalHotMemory } from "./local-hot-memory"; import { workspaceHotScope, selectWorkspaceHotState } from "./workspace-hot-memory"; import { buildRuntimeContinuityOutcome, parseRuntimeContinuityOutcomeContent } from "./continuity-outcomes"; @@ -1795,10 +1795,10 @@ export class RuntimeLifecycleManager { } catch (error) { if (!isBillingBlockedError(error) && packRequest.recallPolicy === "bounded") { contextPack = buildLocalOnlyContextPack(event, packRequest); - warnings.push("Saved-context retrieval unavailable; continuing with free hot memory. A timed-out paid request may still settle; inspect billing before retrying."); + warnings.push(describeBoundedRecallFallback(error)); actions.push(this.recordAction({ eventId: event.eventId, profileName, type: "context_pack", status: "failed", mode: profile.memoryMode, reason: "Bounded recall unavailable; preserved free hot-memory delivery", queueItemId: null, - details: { type: event.type, freeFallback: true } })); + details: { type: event.type, freeFallback: true, failure: describeHostedFailure(error) } })); } else { if (!isBillingBlockedError(error)) throw error; contextPack = buildLocalOnlyContextPack(event, packRequest); diff --git a/src/runtime/runtime-env.ts b/src/runtime/runtime-env.ts index bd87926..93aaa7d 100644 --- a/src/runtime/runtime-env.ts +++ b/src/runtime/runtime-env.ts @@ -15,14 +15,18 @@ export const resolveDefaultRuntimeHome = (userHome: string): string => { return existsSync(current) ? current : existsSync(legacy) ? legacy : current; }; +// An empty environment variable counts as unset, as it does for the installer +// and the plugin launcher (`${CROSSHEAD_HOME:-...}`). +const envValue = (name: string): string | undefined => process.env[name] || undefined; + export const resolveCrossheadHome = (home?: string): string => - path.resolve(home ?? process.env.CROSSHEAD_HOME ?? process.env.CROSSHEAD_RUNTIME_HOME ?? process.env.ONLYBOTS_HOME ?? process.env.ONLYBOTS_RUNTIME_HOME ?? resolveDefaultRuntimeHome(os.homedir())); + path.resolve(home ?? envValue("CROSSHEAD_HOME") ?? envValue("CROSSHEAD_RUNTIME_HOME") ?? envValue("ONLYBOTS_HOME") ?? envValue("ONLYBOTS_RUNTIME_HOME") ?? resolveDefaultRuntimeHome(os.homedir())); export const resolveCodexHostHome = (hostHome?: string): string => - path.resolve(hostHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex")); + path.resolve(hostHome ?? envValue("CODEX_HOME") ?? path.join(os.homedir(), ".codex")); export const resolveClaudeCodeHostHome = (hostHome?: string): string => - path.resolve(hostHome ?? (process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CODE_HOME) ?? path.join(os.homedir(), ".claude")); + path.resolve(hostHome ?? envValue("CLAUDE_CONFIG_DIR") ?? envValue("CLAUDE_CODE_HOME") ?? path.join(os.homedir(), ".claude")); export const resolveClaudeCodeBinaryPath = (binary?: string): string => binary ?? process.env.CLAUDE_CODE_BIN ?? "claude"; diff --git a/src/runtime/runtime-http.ts b/src/runtime/runtime-http.ts index 0a7e338..3ad9a8e 100644 --- a/src/runtime/runtime-http.ts +++ b/src/runtime/runtime-http.ts @@ -2,6 +2,7 @@ // socket lifecycle; every route delegates to a daemon method, so this module is a // routing layer over the daemon's public surface. +import path from "path"; import { IncomingMessage, ServerResponse } from "http"; import type { RuntimeLifecycleEventRequest } from "./lifecycle-engine"; @@ -212,6 +213,8 @@ export const handleRuntimeDaemonRequest = async ( sendJson(response, 200, { ok: true, coreVersion: loadRuntimeConfig(paths).coreVersion, + // The release this daemon runs, so quickstart can spot a daemon left on an older install. + daemonScriptPath: path.join(__dirname, "daemon.js"), ownerId: owner.ownerId, ownerProcess: owner.ownerProcess, }); diff --git a/src/runtime/runtime-status.ts b/src/runtime/runtime-status.ts index e62513a..b4ec531 100644 --- a/src/runtime/runtime-status.ts +++ b/src/runtime/runtime-status.ts @@ -53,6 +53,7 @@ import type { RuntimeDoctorReport, RuntimeStatus, } from "./local-runtime"; +import { adapterConfigUsesPluginHooks } from "./host-plugin-mode"; const isSessionFresh = (session: HostedSession | null): boolean => { if (!session) { @@ -114,12 +115,15 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim const hasManagedFile = (predicate: (filePath: string) => boolean): boolean => [...managedFiles].some((filePath) => predicate(filePath)); const basename = (name: string) => (filePath: string): boolean => path.basename(filePath) === name; + // In plugin mode the host plugin supplies the hooks; the _hooks doctor check verifies it. + const pluginMode = (configName: string): boolean => + [...managedFiles].some((filePath) => path.basename(filePath) === configName && adapterConfigUsesPluginHooks(filePath)); if (adapter.name === "codex") { const requirements = [ { label: "repo-local codex launcher", ok: hasManagedFile(basename("codex")) }, { label: "Codex hook wrapper", ok: (hasManagedFile(basename("crosshead-codex-hook")) || hasManagedFile(basename("onlybots-codex-hook"))) }, - { label: "Codex hooks.json", ok: hasManagedFile((filePath) => path.basename(filePath) === "hooks.json") }, + { label: "Codex hooks.json", ok: pluginMode("codex-adapter.json") || hasManagedFile((filePath) => path.basename(filePath) === "hooks.json") }, ]; const missing = requirements.filter((entry) => !entry.ok).map((entry) => entry.label); return { @@ -129,7 +133,9 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim adapter.status === "failed" ? `Codex automatic integration is broken: ${adapter.lastError ?? "adapter attach failed"}.` : missing.length === 0 - ? "Codex automatic integration is healthy through the repo-local launcher and managed hooks." + ? pluginMode("codex-adapter.json") + ? "Codex automatic integration uses the Crosshead plugin's hooks (see codex_hooks)." + : "Codex automatic integration is healthy through the repo-local launcher and managed hooks." : `Codex automatic integration is degraded; missing ${missing.join(", ")}.`, }; } @@ -140,7 +146,9 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim { label: "Claude hook wrapper", ok: (hasManagedFile(basename("crosshead-claude-code-hook")) || hasManagedFile(basename("onlybots-claude-code-hook"))) }, { label: "project Claude settings hooks", - ok: hasManagedFile((filePath) => path.basename(filePath) === "settings.json" && filePath.includes(`${path.sep}.claude${path.sep}`)), + ok: + pluginMode("claude-code-adapter.json") || + hasManagedFile((filePath) => path.basename(filePath) === "settings.json" && filePath.includes(`${path.sep}.claude${path.sep}`)), }, ]; const missing = requirements.filter((entry) => !entry.ok).map((entry) => entry.label); @@ -151,7 +159,9 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim adapter.status === "failed" ? `Claude Code automatic integration is broken: ${adapter.lastError ?? "adapter attach failed"}.` : missing.length === 0 - ? "Claude Code automatic integration is healthy through repo-local project hooks and the managed claude launcher." + ? pluginMode("claude-code-adapter.json") + ? "Claude Code automatic integration uses the Crosshead plugin's hooks (see claude_code_hooks)." + : "Claude Code automatic integration is healthy through repo-local project hooks and the managed claude launcher." : `Claude Code automatic integration is degraded; missing ${missing.join(", ")}.`, }; } diff --git a/tests/runtime/host-plugins.test.ts b/tests/runtime/host-plugins.test.ts new file mode 100644 index 0000000..a4e0069 --- /dev/null +++ b/tests/runtime/host-plugins.test.ts @@ -0,0 +1,816 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + countLegacyClaudeHookCommands, + findLegacyClaudeHookFiles, + installClaudeCodeAdapter, + loadClaudeCodeAdapterConfig, + uninstallClaudeCodeAdapter, +} from "../../src/runtime/claude-code-adapter"; +import { runOnlyBotsClaudeCodeHookCli } from "../../src/runtime/claude-code-hook"; +import { ensureQuickstartDaemon } from "../../src/runtime/cli/quickstart"; +import { countLegacyCodexHookCommands, installCodexAdapter, loadCodexAdapterConfig, uninstallCodexAdapter } from "../../src/runtime/codex-adapter"; +import { runOnlyBotsCodexHookCli } from "../../src/runtime/codex-hook"; +import { readRuntimeDaemonHealth } from "../../src/runtime/daemon-ownership"; +import { evaluateHostHookInspection, inspectHostHookRegistrations } from "../../src/runtime/host-hook-doctor"; +import * as spec from "../../src/runtime/host-hook-spec"; +import { startDetachedRuntimeDaemon, stopDetachedRuntimeDaemon } from "../../src/runtime/local-runtime"; +import { ensureRuntimeHomeLayout, loadDaemonState } from "../../src/runtime/runtime-persistence"; +import { evaluateAdapterAutomation } from "../../src/runtime/runtime-status"; +import { + claudeLoadedSettingsFiles, + countMatchingHookCommands, + decideHookDispatch, + detectClaudeCodePlugin, + detectCodexPlugin, + readCodexPluginEnabled, + readPluginInvocation, + recordPluginInvocation, +} from "../../src/runtime/host-plugin-mode"; +// @ts-expect-error -- plain ESM generator script without type declarations +import { renderHostPluginFiles } from "../../scripts/host-plugins.mjs"; + +const repoRoot = path.resolve(__dirname, "../.."); +const dirs: string[] = []; +const savedEnv = { ...process.env }; +// Host variables from the shell running the suite (for example inside a Claude Code +// session) must not leak into the fixtures. +const hostVariables = ["CLAUDE_PROJECT_DIR", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_HOME", "CODEX_HOME", "CROSSHEAD_HOST_CODEX_HOME"]; +beforeEach(() => { + for (const name of hostVariables) delete process.env[name]; + // Default host paths (~/.claude, ~/.codex, ~/.crosshead) resolve inside a scratch home, + // never the real one. + process.env.HOME = tempDir("home"); +}); +afterEach(() => { + dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })); + process.env = { ...savedEnv }; +}); + +const tempDir = (label: string): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), `crosshead-plugins-${label}-`)); + dirs.push(dir); + return dir; +}; + +const fixture = (label: string) => { + const root = tempDir(label); + const home = path.join(root, "runtime"); + const claudeHome = path.join(root, "claude"); + const codexHome = path.join(root, "codex"); + const workspace = path.join(root, "project"); + for (const dir of [claudeHome, codexHome, workspace]) mkdirSync(dir, { recursive: true }); + return { root, home, claudeHome, codexHome, workspace }; +}; + +const scripts = { bridgeScriptPath: path.join(repoRoot, "dist/runtime/claude-code-bridge.js"), hookScriptPath: path.join(repoRoot, "dist/runtime/claude-code-hook.js") }; +const readJson = (filePath: string) => JSON.parse(readFileSync(filePath, "utf8")); +const enableClaudePlugin = (claudeHome: string, enabled = true) => + writeFileSync(path.join(claudeHome, "settings.json"), JSON.stringify({ enabledPlugins: { "crosshead@crosshead": enabled } })); +const enableCodexPlugin = (codexHome: string, enabled = true) => + writeFileSync(path.join(codexHome, "config.toml"), `model = "x"\n\n[plugins."crosshead@crosshead"]\nenabled = ${enabled}\n`); +const writeJson = (filePath: string, value: unknown) => { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(value)); +}; +const managedCommands = (hooks: Record }>> | undefined, marker: string) => + Object.values(hooks ?? {}).flatMap((groups) => groups.flatMap((group) => group.hooks.map((hook) => hook.command))).filter((command) => command.includes(marker)); + +describe("host plugin files", () => { + it("checked-in plugin and marketplace files match the shared hook spec", () => { + const files = renderHostPluginFiles(spec) as Record; + expect(Object.keys(files).sort()).toEqual([ + ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", + "plugins/claude-code/.claude-plugin/plugin.json", + "plugins/claude-code/hooks/hooks.json", + "plugins/claude-code/scripts/crosshead-hook", + "plugins/claude-code/skills/setup/SKILL.md", + "plugins/codex/.codex-plugin/plugin.json", + "plugins/codex/hooks/hooks.json", + "plugins/codex/scripts/crosshead-hook", + "plugins/codex/skills/setup/SKILL.md", + "plugins/codex/skills/setup/agents/openai.yaml", + ]); + for (const [relative, content] of Object.entries(files)) { + expect(readFileSync(path.join(repoRoot, relative), "utf8"), `${relative} is stale; run npm run plugins:generate`).toBe(content); + } + }); + + // Hosts pin an installed plugin to its manifest version (Codex also re-asks for + // hook trust when a version changes). Changing generated plugin content without + // raising CROSSHEAD_HOST_PLUGIN_VERSION would ship different files under an + // already-installed version. Pin each version's content here; add a new entry, + // never edit an old one. 1.0.0 is unreleased while PR #14 is open. + const PLUGIN_CONTENT_SHA256: Record = { + "1.0.0": "1c6295e2195529599233fae8d5a46b5671ef18998beaf39652e7bc6d955dc4d4", + "1.0.1": "1fcdad297292b4ac92cf109a9b29da8f26928710099f028e53c12ae9c45ab5e6", + }; + it("generated plugin content is pinned to CROSSHEAD_HOST_PLUGIN_VERSION", () => { + const files = renderHostPluginFiles(spec) as Record; + const hash = createHash("sha256"); + for (const relative of Object.keys(files).filter((name) => name.startsWith("plugins/")).sort()) hash.update(`${relative}\0${files[relative]}\0`); + const digest = hash.digest("hex"); + expect( + PLUGIN_CONTENT_SHA256[spec.CROSSHEAD_HOST_PLUGIN_VERSION], + `plugin content changed (sha256 ${digest}): raise CROSSHEAD_HOST_PLUGIN_VERSION and pin the new digest` + ).toBe(digest); + }); + + it("plugin hooks cover exactly the events the legacy installer registers", () => { + for (const host of ["claude-code", "codex"] as const) { + const hooks = JSON.parse(spec.renderPluginHooksFile(host)).hooks as Record }>>; + expect(Object.keys(hooks)).toEqual(spec.HOST_HOOK_EVENTS.map((entry) => entry.event)); + for (const entry of spec.HOST_HOOK_EVENTS) { + expect(hooks[entry.event]).toHaveLength(1); + expect(hooks[entry.event]![0]!.matcher).toBe(entry.matcher); + expect(hooks[entry.event]![0]!.hooks[0]!.command).toBe(spec.pluginHookCommand(host, entry.event)); + } + } + }); + + it("skills never download code other than the digest-pinned installer", () => { + const files = renderHostPluginFiles(spec) as Record; + for (const host of ["claude-code", "codex"]) { + const skill = files[`plugins/${host}/skills/setup/SKILL.md`]!; + expect(skill.match(/https?:\/\/[^\s)`]+/g)?.filter((url) => !url.startsWith("https://crosshead.sh"))).toEqual([]); + expect(skill).toContain(`--adapter ${host} --workspace-root . --hook-mode plugin`); + expect(skill).toContain("ask for explicit consent"); + } + // The setup skill is user-invoked only. + expect(files["plugins/claude-code/skills/setup/SKILL.md"]).toMatch(/^---\n[^]*\ndisable-model-invocation: true\n---\n/); + expect(files["plugins/codex/skills/setup/agents/openai.yaml"]).toBe("policy:\n allow_implicit_invocation: false\n"); + // Notices are for the user: no model context, and no instruction aimed at the model. + for (const host of ["claude-code", "codex"] as const) { + for (const hint of [spec.pluginSetupHint(host), spec.pluginUpdateHint(host)]) { + expect(hint).toMatch(/To (set it up|update it), (run \/crosshead:setup|type \$crosshead:setup)\.$/); + expect(hint).not.toMatch(/ask (Codex|Claude)|use the .* skill/); + } + } + expect(files["plugins/codex/scripts/crosshead-hook"]).not.toContain("additionalContext"); + const launcherCode = files["plugins/codex/scripts/crosshead-hook"]!.split("\n").filter((line) => !line.trimStart().startsWith("#")); + expect(launcherCode.join("\n")).not.toMatch(/curl|wget|npm |npx |node /); + }); +}); + +describe("legacy hook rendering", () => { + it("keeps the settings-mode hook layout unchanged", async () => { + const f = fixture("legacy-claude"); + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "settings", ...scripts }); + const hookWrapper = path.join(f.home, "bin", "crosshead-claude-code-hook"); + const command = (event: string) => `'${hookWrapper}' '${event}' --home '${f.home}' --claude-home '${f.claudeHome}'`; + // The layout is unchanged except that SessionStart also matches `clear` (both + // hosts start a fresh context after /clear, like startup), changed deliberately. + expect(readJson(path.join(f.workspace, ".claude", "settings.json"))).toEqual({ + hooks: { + SessionStart: [{ matcher: "startup|resume|clear|compact|fork", hooks: [{ type: "command", command: command("SessionStart") }] }], + PreCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PreCompact") }] }], + PostCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PostCompact") }] }], + UserPromptSubmit: [{ hooks: [{ type: "command", command: command("UserPromptSubmit") }] }], + Stop: [{ hooks: [{ type: "command", command: command("Stop") }] }], + }, + }); + }); + + it("keeps the Codex settings-mode hook layout unchanged", async () => { + const f = fixture("legacy-codex"); + await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, hookMode: "settings", ...scripts }); + const hookWrapper = path.join(f.home, "bin", "crosshead-codex-hook"); + const command = (event: string) => `'${hookWrapper}' '${event}' --home '${f.home}' --codex-home '${f.codexHome}'`; + expect(readJson(path.join(f.codexHome, "hooks.json"))).toEqual({ + hooks: { + SessionStart: [{ matcher: "startup|resume|clear|compact|fork", hooks: [{ type: "command", command: command("SessionStart"), statusMessage: "Crosshead session sync" }] }], + PreCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PreCompact"), statusMessage: "Crosshead pre-compact checkpoint", timeout: 30 }] }], + PostCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PostCompact"), statusMessage: "Crosshead post-compact sync", timeout: 30 }] }], + UserPromptSubmit: [{ hooks: [{ type: "command", command: command("UserPromptSubmit"), statusMessage: "Crosshead context", timeout: 45 }] }], + Stop: [{ hooks: [{ type: "command", command: command("Stop"), statusMessage: "Crosshead memory sync", timeout: 30 }] }], + }, + }); + }); +}); + + +const install = (f: ReturnType, extra: Partial[0]> = {}) => + installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts, ...extra }); +const installCodex = (f: ReturnType, extra: Partial[0]> = {}) => + installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, workspaceRoot: f.workspace, ...scripts, ...extra }); + +describe("plugin mode install and migration", () => { + it("Claude Code: detects the plugin at user scope, skips hook writing and removes legacy hooks idempotently", async () => { + const f = fixture("claude-migrate"); + const settings = path.join(f.workspace, ".claude", "settings.json"); + await install(f); + const legacy = readJson(settings); + legacy.hooks.Stop.push({ hooks: [{ type: "command", command: "echo unrelated" }] }); + legacy.model = "keep-me"; + writeFileSync(settings, JSON.stringify(legacy)); + expect(loadClaudeCodeAdapterConfig(f.home, f.claudeHome).hookMode).toBe("settings"); + + enableClaudePlugin(f.claudeHome); + const first = await install(f); + expect(first.config.hookMode).toBe("plugin"); + expect(first.pluginDetection?.source).toBe(path.join(f.claudeHome, "settings.json")); + expect(first.migratedFiles).toEqual([settings]); + const migrated = readJson(settings); + expect(managedCommands(migrated.hooks, "crosshead-claude-code-hook")).toEqual([]); + expect(migrated.hooks.Stop).toEqual([{ hooks: [{ type: "command", command: "echo unrelated" }] }]); + expect(migrated.model).toBe("keep-me"); + expect(first.managedFiles).not.toContain(settings); + + const second = await install(f); + expect(second.migratedFiles).toEqual([]); + expect(readJson(settings)).toEqual(migrated); + }); + + it("Claude Code: never deletes a settings file; it is left as {} (also on uninstall)", async () => { + const f = fixture("claude-keep-file"); + const settings = path.join(f.workspace, ".claude", "settings.json"); + await install(f); + enableClaudePlugin(f.claudeHome); + expect((await install(f)).migratedFiles).toEqual([settings]); + expect(readJson(settings)).toEqual({}); + + const forced = await install(f, { hookMode: "settings" }); + expect(forced.config.hookMode).toBe("settings"); + expect(managedCommands(readJson(settings).hooks, "crosshead-claude-code-hook")).toHaveLength(5); + await uninstallClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome }); + expect(readJson(settings)).toEqual({}); + }); + + it("Claude Code: project-scope enablement keeps settings mode and never removes the global hooks", async () => { + const f = fixture("claude-project-scope"); + // The default install: workspace = the user's home, so the hooks land in the user settings file. + const userHome = path.join(f.root, "user"); + const claudeHome = path.join(userHome, ".claude"); + const userSettings = path.join(claudeHome, "settings.json"); + mkdirSync(claudeHome, { recursive: true }); + await install(f, { hostHome: claudeHome, workspaceRoot: userHome }); + expect(countLegacyClaudeHookCommands(userSettings, f.home)).toBe(5); + writeJson(path.join(f.workspace, ".claude", "settings.json"), { enabledPlugins: { "crosshead@crosshead": true } }); + + for (const hookMode of [undefined, "plugin"] as const) { + const result = await install(f, { hostHome: claudeHome, workspaceRoot: userHome, hookMode }); + expect(result.config.hookMode).toBe("settings"); + expect(result.migratedFiles).toEqual([]); + expect(countLegacyClaudeHookCommands(userSettings, f.home)).toBe(5); + } + const explicit = await install(f, { hostHome: claudeHome, workspaceRoot: f.workspace, hookMode: "plugin" }); + expect(explicit.config.hookMode).toBe("settings"); + expect(explicit.hookModeNote).toContain("user scope"); + expect(existsSync(path.join(f.workspace, ".claude", "settings.json")) && countLegacyClaudeHookCommands(path.join(f.workspace, ".claude", "settings.json"), f.home)).toBe(0); + // A later reinstall without the flag (for example `crosshead update`) repeats the request. + expect(loadClaudeCodeAdapterConfig(f.home, claudeHome).hookModeRequest).toBe("plugin"); + + const [check] = inspectHostHookRegistrations(f.home, f.workspace); + expect(check).toMatchObject({ id: "claude_code_hooks", ok: true }); + expect(check!.message).toContain("this project only"); + expect(check!.message).toContain("each event is captured once"); + }); + + it("Claude Code: refuses a symlinked settings file before writing anything", async () => { + const f = fixture("claude-symlink"); + const other = path.join(f.root, "other"); + await install(f, { workspaceRoot: other }); + const otherSettings = path.join(other, ".claude", "settings.json"); + const real = path.join(f.root, "dotfiles-settings.json"); + writeFileSync(real, readFileSync(otherSettings)); + rmSync(otherSettings); + symlinkSync(real, otherSettings); + // Legacy hooks in the new workspace as well, so a partial migration would be visible. + await install(f, { hookMode: "settings" }); + const configPath = path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"); + const workspaceSettings = path.join(f.workspace, ".claude", "settings.json"); + // The previous workspace is recorded as `other` only in the config written by the first install. + const config = readFileSync(configPath, "utf8"); + writeFileSync(configPath, config.replace(JSON.stringify(f.workspace), JSON.stringify(other))); + const before = { config: readFileSync(configPath, "utf8"), workspace: readFileSync(workspaceSettings, "utf8"), real: readFileSync(real, "utf8") }; + enableClaudePlugin(f.claudeHome); + await expect(install(f, { hookMode: "plugin" })).rejects.toThrow(/Refusing to migrate Crosshead hooks in .*other.*No files were changed/); + expect(readFileSync(configPath, "utf8")).toBe(before.config); + expect(readFileSync(workspaceSettings, "utf8")).toBe(before.workspace); + expect(readFileSync(real, "utf8")).toBe(before.real); + }); + + it("Codex: keeps legacy hooks until a plugin invocation is seen, then migrates and keeps unknown keys", async () => { + const f = fixture("codex-migrate"); + const hooksFile = path.join(f.codexHome, "hooks.json"); + await installCodex(f); + const legacy = readJson(hooksFile); + legacy.hooks.PreToolUse = [{ matcher: "Bash", hooks: [{ type: "command", command: "echo unrelated" }] }]; + legacy.$schema = "keep-me"; + writeFileSync(hooksFile, JSON.stringify(legacy)); + + enableCodexPlugin(f.codexHome); + const pending = await installCodex(f); + expect(pending.config.hookMode).toBe("plugin"); + expect(pending.migratedFiles).toEqual([]); + expect(pending.hookModeNote).toContain("/hooks"); + expect(countLegacyCodexHookCommands(hooksFile, f.home)).toBe(5); + + recordPluginInvocation(f.home, "codex", spec.CROSSHEAD_HOST_PLUGIN_VERSION, f.codexHome); + const result = await installCodex(f); + expect(result.migratedFiles).toEqual([hooksFile]); + expect(readJson(hooksFile)).toEqual({ $schema: "keep-me", hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo unrelated" }] }] } }); + expect(loadCodexAdapterConfig(f.home, f.codexHome).hookMode).toBe("plugin"); + expect((await installCodex(f)).migratedFiles).toEqual([]); + await uninstallCodexAdapter({ home: f.home, hostHome: f.codexHome }); + expect(readJson(hooksFile).$schema).toBe("keep-me"); + }); + + it("Codex: a disabled plugin, or one enabled only in project config, keeps settings mode", async () => { + const f = fixture("codex-disabled"); + enableCodexPlugin(f.codexHome, false); + const result = await installCodex(f); + expect(result.config.hookMode).toBe("settings"); + expect(managedCommands(readJson(path.join(f.codexHome, "hooks.json")).hooks, "crosshead-codex-hook")).toHaveLength(5); + + writeFileSync(path.join(f.codexHome, "config.toml"), `[projects."${f.workspace}"]\ntrust_level = "trusted"\n`); + mkdirSync(path.join(f.workspace, ".codex")); + writeFileSync(path.join(f.workspace, ".codex", "config.toml"), '[plugins."crosshead@crosshead"]\nenabled = true\n'); + const project = await installCodex(f, { hookMode: "plugin" }); + expect(project.pluginDetection).toMatchObject({ enabled: true, scope: "project", enabledAtUserScope: false }); + expect(project.config.hookMode).toBe("settings"); + expect(project.migratedFiles).toEqual([]); + expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(5); + }); + + it.each(["older-version", "different-home", "unbound-receipt"])("Codex: %s receipt cannot authorize global hook removal", async (kind) => { + const f = fixture(`codex-receipt-${kind}`); + await installCodex(f); + enableCodexPlugin(f.codexHome); + if (kind === "unbound-receipt") { + writeJson(path.join(f.home, "state", "host-plugin-invocations.json"), { codex: { lastSeenAt: new Date().toISOString(), pluginVersion: spec.CROSSHEAD_HOST_PLUGIN_VERSION } }); + } else { + recordPluginInvocation(f.home, "codex", kind === "older-version" ? "1.0.0" : spec.CROSSHEAD_HOST_PLUGIN_VERSION, kind === "different-home" ? path.join(f.root, "other-codex") : f.codexHome); + } + const pending = await installCodex(f); + expect(pending.migratedFiles).toEqual([]); + expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(5); + expect(inspectHostHookRegistrations(f.home, f.workspace).find((check) => check.id === "codex_hooks")).toMatchObject({ ok: false }); + recordPluginInvocation(f.home, "codex", spec.CROSSHEAD_HOST_PLUGIN_VERSION, f.codexHome); + expect((await installCodex(f)).migratedFiles).toEqual([path.join(f.codexHome, "hooks.json")]); + }); + + it("Codex: refuses a symlinked hooks.json before writing anything", async () => { + const f = fixture("codex-symlink"); + const hooksFile = path.join(f.codexHome, "hooks.json"); + await installCodex(f); + const real = path.join(f.root, "hooks-real.json"); + writeFileSync(real, readFileSync(hooksFile)); + rmSync(hooksFile); + symlinkSync(real, hooksFile); + const configPath = path.join(f.codexHome, "onlybots", "codex-adapter.json"); + const before = readFileSync(configPath, "utf8"); + enableCodexPlugin(f.codexHome); + recordPluginInvocation(f.home, "codex", spec.CROSSHEAD_HOST_PLUGIN_VERSION, f.codexHome); + await expect(installCodex(f)).rejects.toThrow("regular file"); + expect(readFileSync(configPath, "utf8")).toBe(before); + expect(countLegacyCodexHookCommands(real, f.home)).toBe(5); + }); +}); + +describe("plugin detection", () => { + it("reads Codex plugin tables without a TOML dependency", () => { + expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"]\nenabled = true\n')).toBe(true); + expect(readCodexPluginEnabled("[plugins.'crosshead@crosshead']\nenabled = false # off\n")).toBe(false); + expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"]\nenabled = false#c\n')).toBe(false); + expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"] # comment\nenabled = true\n')).toBe(true); + expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"]\n')).toBe(true); + expect(readCodexPluginEnabled('[plugins]\n"crosshead@crosshead" = { enabled = false }\n')).toBe(false); + expect(readCodexPluginEnabled('plugins."crosshead@crosshead".enabled = false\n')).toBe(false); + expect(readCodexPluginEnabled('notes = """\n[plugins."crosshead@crosshead"]\n"""\n')).toBeNull(); + expect(readCodexPluginEnabled('list = [\n "a",\n]\n[plugins."crosshead@crosshead"]\nenabled = false\n')).toBe(false); + expect(readCodexPluginEnabled('x = "#"\n[plugins."crosshead@crosshead"]\nenabled = false\n')).toBe(false); + // Only this repository's marketplace counts. + expect(readCodexPluginEnabled("[plugins.'crosshead@local']\nenabled = true\n")).toBeNull(); + expect(readCodexPluginEnabled('[plugins."other@x"]\nenabled = true\n[features]\nenabled = true\n')).toBeNull(); + expect(readCodexPluginEnabled('[plugins."crosshead-extra@crosshead"]\nenabled = true\n')).toBeNull(); + }); + + it("Codex ignores a project config until the project is trusted", () => { + const f = fixture("codex-trust"); + mkdirSync(path.join(f.workspace, ".codex")); + writeFileSync(path.join(f.workspace, ".codex", "config.toml"), '[plugins."crosshead@crosshead"]\nenabled = true\n'); + const untrusted = detectCodexPlugin(f.codexHome, f.workspace); + expect(untrusted).toMatchObject({ enabled: false, enabledAtUserScope: false }); + expect(untrusted.ignored).toContain("until Codex trusts"); + writeFileSync(path.join(f.codexHome, "config.toml"), `[projects."${f.workspace}"]\ntrust_level = "trusted"\n`); + expect(detectCodexPlugin(f.codexHome, f.workspace)).toMatchObject({ enabled: true, scope: "project", enabledAtUserScope: false }); + enableCodexPlugin(f.codexHome); + expect(detectCodexPlugin(f.codexHome, f.workspace)).toMatchObject({ enabled: true, scope: "user", enabledAtUserScope: true }); + }); + + it("applies Claude Code settings precedence (local over project over user) and only this marketplace", () => { + const f = fixture("detect"); + enableClaudePlugin(f.claudeHome, true); + writeJson(path.join(f.workspace, ".claude", "settings.local.json"), { enabledPlugins: { "crosshead@crosshead": false } }); + expect(detectClaudeCodePlugin(f.claudeHome, f.workspace)).toEqual({ + enabled: false, + source: path.join(f.workspace, ".claude", "settings.local.json"), + scope: "local", + enabledAtUserScope: true, + }); + writeJson(path.join(f.claudeHome, "settings.json"), { enabledPlugins: { "crosshead@elsewhere": true } }); + rmSync(path.join(f.workspace, ".claude", "settings.local.json")); + expect(detectClaudeCodePlugin(f.claudeHome, f.workspace)).toMatchObject({ enabled: false, source: null }); + }); + + it("lists exactly the settings files Claude Code loads, with no ancestor lookup", () => { + const f = fixture("loaded-files"); + const sub = path.join(f.workspace, "sub"); + mkdirSync(sub); + // The session's project directory is a git repository root. + mkdirSync(path.join(sub, ".git")); + expect(claudeLoadedSettingsFiles(f.claudeHome, sub).map((entry) => entry.file)).toEqual([ + path.join(sub, ".claude", "settings.local.json"), + path.join(sub, ".claude", "settings.json"), + path.join(f.claudeHome, "settings.json"), + ]); + // A session in a subdirectory of a repository: the local file also lives at the repository root, + // but the shared project file is read only from the session's directory. + rmSync(path.join(sub, ".git"), { recursive: true }); + mkdirSync(path.join(f.workspace, ".git")); + expect(claudeLoadedSettingsFiles(f.claudeHome, sub).map((entry) => entry.file)).toEqual([ + path.join(f.workspace, ".claude", "settings.local.json"), + path.join(sub, ".claude", "settings.local.json"), + path.join(sub, ".claude", "settings.json"), + path.join(f.claudeHome, "settings.json"), + ]); + }); + + it("counts Crosshead commands: empty arrays, empty groups and null groups never count or throw", () => { + const f = fixture("counting"); + const isCrosshead = (command: string) => command.includes("crosshead-claude-code-hook"); + for (const shape of [ + { hooks: { Stop: [] } }, + { hooks: { Stop: [null, {}, { hooks: null }, { hooks: [] }, { hooks: [null, { command: 3 }] }], PreCompact: null, Bad: "x" } }, + { hooks: [] }, + { hooks: null }, + null, + [], + ]) { + expect(countMatchingHookCommands(shape, isCrosshead)).toBe(0); + } + expect(countMatchingHookCommands({ hooks: { Stop: [null, { hooks: [{ command: "/x/bin/crosshead-claude-code-hook Stop" }] }] } }, isCrosshead)).toBe(1); + + writeJson(path.join(f.workspace, ".claude", "settings.json"), { hooks: { Stop: [] } }); + expect(findLegacyClaudeHookFiles(f.claudeHome, f.workspace, f.home)).toEqual([]); + writeJson(path.join(f.codexHome, "hooks.json"), { hooks: { Stop: [null, { hooks: null }] } }); + expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(0); + }); +}); + +const captureStdout = async (run: () => Promise): Promise => { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + await run(); + } finally { + process.stdout.write = original; + } + return chunks.join(""); +}; + +// A hook that runs reaches the (absent) daemon and fails; a skipped one resolves. +const RAN = "daemon is unavailable"; + +describe("hook dispatch", () => { + it("runs exactly one registration per event", () => { + const present = () => true; + const absent = () => false; + // Installer hooks loaded: they run and the plugin defers, in either mode. + expect(decideHookDispatch({ viaPlugin: false, mode: "settings", legacyHooksPresent: present })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: true, mode: "settings", legacyHooksPresent: present })).toBe("skip-duplicate"); + expect(decideHookDispatch({ viaPlugin: false, mode: "plugin", legacyHooksPresent: present })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: true, mode: "plugin", legacyHooksPresent: present })).toBe("skip-duplicate"); + // None loaded: the plugin runs; a stale installer invocation is skipped in plugin mode. + expect(decideHookDispatch({ viaPlugin: true, mode: "settings", legacyHooksPresent: absent })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: true, mode: "plugin", legacyHooksPresent: absent })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: false, mode: "plugin", legacyHooksPresent: absent })).toBe("skip-duplicate"); + expect(decideHookDispatch({ viaPlugin: false, mode: "settings", legacyHooksPresent: absent })).toBe("run"); + }); + + it("plugin invocations before setup print a user-only setup notice on SessionStart", async () => { + const f = fixture("unconfigured"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CODEX_HOME = f.codexHome; + const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); + const claudeStart = await captureStdout(() => runOnlyBotsClaudeCodeHookCli(["SessionStart", "--home", f.home, "--claude-home", f.claudeHome, "--via", "plugin"], payload)); + expect(JSON.parse(claudeStart)).toEqual({ systemMessage: spec.pluginSetupHint("claude-code") }); + expect(await captureStdout(() => runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", f.claudeHome, "--via", "plugin"], payload))).toBe(""); + const codexStart = await captureStdout(() => runOnlyBotsCodexHookCli(["SessionStart", "--home", f.home, "--via", "plugin"], payload)); + expect(JSON.parse(codexStart)).toEqual({ systemMessage: spec.pluginSetupHint("codex") }); + // Legacy invocations keep failing loudly when the adapter is missing. + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", f.claudeHome], payload)).rejects.toThrow("not installed"); + }); + + it("plugin invocations find a --claude-home install through the adapter record", async () => { + const f = fixture("claude-home"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + await install(f, { workspaceRoot: path.join(f.root, "elsewhere") }); + // No CLAUDE_CONFIG_DIR and no --claude-home: the plugin launcher passes neither. + const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], payload)).rejects.toThrow(RAN); + }); + + it("Claude Code: checks the session's project directory, not its ancestors, plus the user settings", async () => { + const f = fixture("claude-dirs"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + const sub = path.join(f.workspace, "sub"); + mkdirSync(sub); + await install(f); + const hook = (projectDir: string, cwd: string, ...extra: string[]) => { + process.env.CLAUDE_PROJECT_DIR = projectDir; + return runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, ...extra], JSON.stringify({ session_id: "s", cwd })); + }; + // Session in the workspace: the legacy hooks load, so the plugin defers. + await expect(hook(f.workspace, f.workspace, "--via", "plugin")).resolves.toBeUndefined(); + await expect(hook(f.workspace, f.workspace, "--claude-home", f.claudeHome)).rejects.toThrow(RAN); + // After `cd sub` inside that session the project directory is unchanged. + await expect(hook(f.workspace, sub, "--via", "plugin")).resolves.toBeUndefined(); + // A session started in a subdirectory does not load W/.claude/settings.json: the plugin captures. + await expect(hook(sub, sub, "--via", "plugin")).rejects.toThrow(RAN); + }); + + it("Claude Code: legacy hooks in the user settings cover a workspace outside $HOME", async () => { + const f = fixture("claude-outside-home"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + // Default install layout: the workspace is the user's home, the hooks land in ~/.claude/settings.json. + const userHome = path.join(f.root, "user"); + const claudeHome = path.join(userHome, ".claude"); + mkdirSync(claudeHome, { recursive: true }); + await install(f, { hostHome: claudeHome, workspaceRoot: userHome }); + process.env.CLAUDE_CONFIG_DIR = claudeHome; + const outside = path.join(f.root, "srv", "project"); + mkdirSync(outside, { recursive: true }); + process.env.CLAUDE_PROJECT_DIR = outside; + const payload = JSON.stringify({ session_id: "s", cwd: outside }); + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", claudeHome], payload)).rejects.toThrow(RAN); + const [check] = inspectHostHookRegistrations(f.home, outside); + expect(check!.message).toContain(path.join(claudeHome, "settings.json")); + }); + + it("empty hook arrays do not make the plugin defer", async () => { + const f = fixture("claude-empty"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + enableClaudePlugin(f.claudeHome); + await install(f, { hookMode: "plugin" }); + writeJson(path.join(f.workspace, ".claude", "settings.json"), { hooks: { Stop: [], SessionStart: [null, { hooks: [] }] } }); + process.env.CLAUDE_PROJECT_DIR = f.workspace; + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin"], JSON.stringify({ session_id: "s", cwd: f.workspace }))).rejects.toThrow(RAN); + expect(inspectHostHookRegistrations(f.home, f.workspace)[0]).toMatchObject({ ok: true }); + + process.env.CODEX_HOME = f.codexHome; + await installCodex(f, { hookMode: "settings" }); + writeJson(path.join(f.codexHome, "hooks.json"), { hooks: { Stop: [null, { hooks: null }] } }); + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin"], JSON.stringify({ session_id: "s", cwd: f.workspace }))).rejects.toThrow(RAN); + }); + + it.each(["claude-code", "codex"] as const)("%s: partial legacy registrations defer only matching events and sources", async (host) => { + const f = fixture(`${host}-partial`); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + process.env.CLAUDE_PROJECT_DIR = f.workspace; + process.env.CODEX_HOME = f.codexHome; + if (host === "codex") await installCodex(f); else await install(f); + const file = host === "codex" ? path.join(f.codexHome, "hooks.json") : path.join(f.workspace, ".claude", "settings.json"); + const legacy = readJson(file); + legacy.hooks = { SessionStart: [{ ...legacy.hooks.SessionStart[0], matcher: "startup|resume" }] }; + writeJson(file, legacy); + const hook = host === "codex" ? runOnlyBotsCodexHookCli : runOnlyBotsClaudeCodeHookCli; + const invoke = (event: string, source?: string) => hook([event, "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], JSON.stringify({ session_id: "partial", cwd: f.workspace, source })); + await expect(invoke("SessionStart", "startup")).resolves.toBeUndefined(); + await expect(invoke("SessionStart", "fork")).rejects.toThrow(RAN); + await expect(invoke("UserPromptSubmit")).rejects.toThrow(RAN); + await expect(invoke("Stop")).rejects.toThrow(RAN); + }); + + it("Claude Code: plugin mode defers to leftover installer hooks and skips stale ones", async () => { + const f = fixture("claude-plugin-mode"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + process.env.CLAUDE_PROJECT_DIR = f.workspace; + const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); + const args = (...extra: string[]) => ["Stop", "--home", f.home, ...extra]; + enableClaudePlugin(f.claudeHome); + await install(f, { hookMode: "plugin" }); + await expect(runOnlyBotsClaudeCodeHookCli(args("--claude-home", f.claudeHome), payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(args("--via", "plugin", "--plugin-version", "9.9.9"), payload)).rejects.toThrow(RAN); + expect(readPluginInvocation(f.home, "claude-code")?.pluginVersion).toBe("9.9.9"); + // Hooks an older install left in this project still load: they capture, the plugin defers. + await install(f, { hookMode: "settings" }); + writeFileSync( + path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"), + readFileSync(path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"), "utf8").replace('"hookMode": "settings"', '"hookMode": "plugin"') + ); + await expect(runOnlyBotsClaudeCodeHookCli(args("--via", "plugin"), payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(args("--claude-home", f.claudeHome), payload)).rejects.toThrow(RAN); + }); + + it("Codex: until Codex has run the plugin hooks, the installer hooks capture and doctor explains /hooks trust", async () => { + const f = fixture("codex-trust-window"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CODEX_HOME = f.codexHome; + const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); + await installCodex(f); + enableCodexPlugin(f.codexHome); + const pending = await installCodex(f, { hookMode: "plugin" }); + expect(pending.config.hookMode).toBe("plugin"); + expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(5); + const window = inspectHostHookRegistrations(f.home, f.workspace).find((check) => check.id === "codex_hooks")!; + expect(window.ok).toBe(false); + expect(window.message).toContain("/hooks"); + expect(window.message).toContain("capture meanwhile"); + + // Untrusted plugin hooks never fire; the installer hook still captures. + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--codex-home", f.codexHome], payload)).rejects.toThrow(RAN); + // Once trusted, both fire: the plugin defers and records that it ran. + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], payload)).resolves.toBeUndefined(); + expect(readPluginInvocation(f.home, "codex")).not.toBeNull(); + const migrated = await installCodex(f); + expect(migrated.migratedFiles).toEqual([path.join(f.codexHome, "hooks.json")]); + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], payload)).rejects.toThrow(RAN); + // A Codex session that loaded hooks.json before the migration: its installer hook is skipped. + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--codex-home", f.codexHome], payload)).resolves.toBeUndefined(); + expect(inspectHostHookRegistrations(f.home, f.workspace).find((check) => check.id === "codex_hooks")).toMatchObject({ ok: true }); + }); + + it("Codex: doctor warns when plugin mode is configured but no plugin hook has run", () => { + const check = evaluateHostHookInspection({ + host: "codex", + mode: "plugin", + plugin: { enabled: true, source: "/c/config.toml", scope: "user", enabledAtUserScope: true }, + legacyHookFiles: [], + pluginInvocation: null, + }); + expect(check.ok).toBe(false); + expect(check.message).toContain("no Crosshead plugin hook invocation has been seen"); + expect(check.message).toContain("/hooks"); + expect( + evaluateHostHookInspection({ + host: "codex", + hostHome: "/c", + mode: "plugin", + plugin: { enabled: true, source: "/c/config.toml", scope: "user", enabledAtUserScope: true }, + legacyHookFiles: [], + pluginInvocation: { lastSeenAt: new Date().toISOString(), pluginVersion: spec.CROSSHEAD_HOST_PLUGIN_VERSION, hostHome: "/c" }, + }).ok + ).toBe(true); + }); +}); + +describe("plugin launcher", () => { + const launcher = path.join(repoRoot, "plugins", "claude-code", spec.PLUGIN_HOOK_LAUNCHER); + const run = (env: NodeJS.ProcessEnv, ...args: string[]) => + spawnSync("sh", [launcher, ...args], { input: '{"session_id":"s"}', encoding: "utf8", env: { PATH: process.env.PATH, ...env } }); + const fakeWrapper = (home: string, name: string, body: string) => { + mkdirSync(path.join(home, "bin"), { recursive: true }); + writeFileSync(path.join(home, "bin", name), body, { mode: 0o755 }); + }; + + it("prints one user-only setup line on SessionStart and exits 0 without a runtime", () => { + const f = fixture("launcher"); + for (const host of ["claude-code", "codex"] as const) { + const start = run({ HOME: f.root }, host, "SessionStart"); + expect(start.status).toBe(0); + expect(start.stdout.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(start.stdout)).toEqual({ systemMessage: spec.pluginSetupHint(host) }); + const stop = run({ HOME: f.root }, host, "Stop"); + expect(stop.status).toBe(0); + expect(stop.stdout).toBe(""); + } + expect(run({ HOME: f.root }, "unknown-host", "SessionStart").stdout).toBe(""); + }); + + it("asks for an update when the runtime's hook wrapper predates plugin dispatch", () => { + const f = fixture("launcher-old"); + const home = path.join(f.root, ".crosshead"); + fakeWrapper(home, "crosshead-claude-code-hook", "#!/bin/sh\necho ran\n"); + const start = run({ HOME: f.root }, "claude-code", "SessionStart"); + expect(JSON.parse(start.stdout).systemMessage).toBe(spec.pluginUpdateHint("claude-code")); + expect(run({ HOME: f.root }, "claude-code", "Stop").stdout).toBe(""); + }); + + it("runs the wrapper without reading host config itself, so --claude-home and --codex-home installs work", () => { + const f = fixture("launcher-current"); + const home = path.join(f.root, ".crosshead"); + for (const name of ["crosshead-claude-code-hook", "crosshead-codex-hook"]) { + fakeWrapper(home, name, `#!/bin/sh\n# ${spec.PLUGIN_DISPATCH_CAPABILITY}\necho "$@" "codex_home=$CROSSHEAD_HOST_CODEX_HOME"\n`); + } + // No adapter config at the default host paths. + const claude = run({ HOME: f.root }, "claude-code", "Stop"); + expect(claude.stdout).toBe(`Stop --home ${home} --via plugin --plugin-version ${spec.CROSSHEAD_HOST_PLUGIN_VERSION} codex_home=\n`); + const codex = run({ HOME: f.root, CODEX_HOME: "/elsewhere/codex" }, "codex", "Stop"); + expect(codex.stdout).toContain("codex_home=/elsewhere/codex"); + expect(run({ HOME: f.root }, "codex", "Stop").stdout).toContain(`codex_home=${f.root}/.codex`); + }); + + it("treats empty home variables as unset and keeps the runtime's two-homes check", () => { + const f = fixture("launcher-homes"); + const home = path.join(f.root, ".crosshead"); + fakeWrapper(home, "crosshead-claude-code-hook", `#!/bin/sh\n# ${spec.PLUGIN_DISPATCH_CAPABILITY}\necho "$3"\n`); + expect(run({ HOME: f.root, CROSSHEAD_HOME: "" }, "claude-code", "Stop").stdout).toBe(`${home}\n`); + // The runtime agrees: an empty CROSSHEAD_HOME is unset, not the current directory. + const resolved = spawnSync(process.execPath, ["-e", 'process.stdout.write(require(process.argv[1]).resolveCrossheadHome())', path.join(repoRoot, "dist/runtime/runtime-env.js")], { + cwd: f.workspace, + encoding: "utf8", + env: { PATH: process.env.PATH, HOME: f.root, CROSSHEAD_HOME: "", CROSSHEAD_RUNTIME_HOME: "", ONLYBOTS_HOME: "", ONLYBOTS_RUNTIME_HOME: "" }, + }); + expect(resolved.stdout).toBe(home); + + mkdirSync(path.join(f.root, ".onlybots")); + const conflict = run({ HOME: f.root }, "claude-code", "SessionStart"); + expect(JSON.parse(conflict.stdout).systemMessage).toBe(spec.PLUGIN_HOME_CONFLICT_HINT); + expect(run({ HOME: f.root }, "claude-code", "Stop").stdout).toBe(""); + // An explicit choice wins. + expect(run({ HOME: f.root, CROSSHEAD_HOME: home }, "claude-code", "Stop").stdout).toBe(`${home}\n`); + }); + + it("is the same file in both plugins and passes sh -n", () => { + const codexLauncher = path.join(repoRoot, "plugins", "codex", spec.PLUGIN_HOOK_LAUNCHER); + expect(readFileSync(codexLauncher, "utf8")).toBe(readFileSync(launcher, "utf8")); + execFileSync("sh", ["-n", launcher]); + }); +}); + +describe("doctor hook checks", () => { + it("reports the active mode and flags duplicates or missing hooks", async () => { + const ok = evaluateHostHookInspection({ host: "claude-code", mode: "plugin", plugin: { enabled: true, source: "/u/settings.json", scope: "user", enabledAtUserScope: true }, legacyHookFiles: [] }); + expect(ok).toMatchObject({ id: "claude_code_hooks", ok: true }); + expect(ok.message).toBe("mode=plugin; plugin enabled in /u/settings.json; no legacy hooks."); + expect(evaluateHostHookInspection({ host: "codex", mode: "plugin", plugin: { enabled: false, source: null, scope: null, enabledAtUserScope: false }, legacyHookFiles: [] }).ok).toBe(false); + const duplicate = evaluateHostHookInspection({ + host: "codex", + mode: "settings", + plugin: { enabled: true, source: null, scope: "user", enabledAtUserScope: true }, + legacyHookFiles: ["/c/hooks.json"], + pluginInvocation: { lastSeenAt: new Date().toISOString(), pluginVersion: null, hostHome: null }, + }); + expect(duplicate.ok).toBe(false); + expect(duplicate.message).toContain("--hook-mode plugin"); + + const f = fixture("doctor"); + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + await install(f); + enableClaudePlugin(f.claudeHome); + const [check] = inspectHostHookRegistrations(f.home, f.workspace); + expect(check).toMatchObject({ id: "claude_code_hooks", ok: false }); + expect(check!.message).toContain("Duplicate registration"); + const pluginInstall = await install(f); + expect(inspectHostHookRegistrations(f.home, f.workspace)[0]).toMatchObject({ ok: true }); + expect(evaluateAdapterAutomation(pluginInstall.adapter)).toMatchObject({ id: "adapter_claude_code_automation", ok: true }); + enableCodexPlugin(f.codexHome); + const codexInstall = await installCodex(f, { hookMode: "plugin" }); + expect(codexInstall.config.hookMode).toBe("plugin"); + expect(evaluateAdapterAutomation(codexInstall.adapter)).toMatchObject({ id: "adapter_codex_automation", ok: true }); + await uninstallClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome }); + expect(inspectHostHookRegistrations(f.home, f.workspace).map((entry) => entry.id)).toEqual(["codex_hooks"]); + }); +}); + +describe("quickstart daemon", () => { + it("restarts a daemon left on another release through the ownership-checked path, and reports --port", async () => { + const f = fixture("stale-daemon"); + // A copy of the built runtime stands in for the release the daemon was started from. + const oldRelease = path.join(f.root, "old-release"); + cpSync(path.join(repoRoot, "dist"), path.join(oldRelease, "dist"), { recursive: true }); + cpSync(path.join(repoRoot, "package.json"), path.join(oldRelease, "package.json")); + symlinkSync(path.join(repoRoot, "node_modules"), path.join(oldRelease, "node_modules")); + const oldDaemon = path.join(oldRelease, "dist", "runtime", "daemon.js"); + const installedDaemon = path.join(repoRoot, "dist", "runtime", "daemon.js"); + const paths = ensureRuntimeHomeLayout(f.home); + try { + const first = await startDetachedRuntimeDaemon({ home: f.home, daemonScriptPath: oldDaemon }); + expect((await readRuntimeDaemonHealth(first))?.daemonScriptPath).toBe(oldDaemon); + + const restarted = await ensureQuickstartDaemon({ home: f.home, daemonScriptPath: installedDaemon }); + expect(restarted).toMatchObject({ id: "daemon", ok: true }); + expect(restarted.detail).toMatch(/^Restarted onlybotsd on port \d+: it was running .*old-release/); + const state = loadDaemonState(paths); + expect(state.pid).not.toBe(first.pid); + expect((await readRuntimeDaemonHealth(state))?.daemonScriptPath).toBe(installedDaemon); + + const reused = await ensureQuickstartDaemon({ home: f.home, daemonScriptPath: installedDaemon }); + expect(reused).toEqual({ id: "daemon", ok: true, detail: `onlybotsd already running on port ${state.port}` }); + const otherPort = await ensureQuickstartDaemon({ home: f.home, daemonScriptPath: installedDaemon, port: String(state.port! === 65535 ? 65534 : state.port! + 1) }); + expect(otherPort.ok).toBe(false); + expect(otherPort.detail).toContain("was not applied"); + expect(loadDaemonState(paths).pid).toBe(state.pid); + } finally { + await stopDetachedRuntimeDaemon(f.home); + } + }, 60_000); +}); diff --git a/tests/runtime/hosted-client-security.test.ts b/tests/runtime/hosted-client-security.test.ts index 9f09c34..e575404 100644 --- a/tests/runtime/hosted-client-security.test.ts +++ b/tests/runtime/hosted-client-security.test.ts @@ -4,7 +4,7 @@ import { schnorr } from "@noble/curves/secp256k1"; import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { HostedOnlyBotsClient } from "../../src/runtime/hosted-client"; +import { describeBoundedRecallFallback, describeHostedFailure, HostedOnlyBotsClient, HostedOnlyBotsRequestError } from "../../src/runtime/hosted-client"; const servers: Server[] = []; afterEach(async () => { @@ -208,3 +208,46 @@ describe("hosted client security boundaries", () => { expect(receivedBody).toEqual({ code: "obj_test", agent_id: "agent-test" }); }); }); + +describe("describeHostedFailure", () => { + it("distinguishes a recall timeout from an HTTP failure without copying messages", () => { + const timeout = new DOMException("The operation was aborted due to timeout https://secret.example/?token=x", "TimeoutError"); + expect(describeHostedFailure(timeout)).toEqual({ errorClass: "timeout" }); + const http = new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "secret detail" }); + expect(describeHostedFailure(http)).toEqual({ errorClass: "http", status: 503, code: "recall_deadline_exceeded" }); + expect(describeHostedFailure(new TypeError("fetch failed: secret.example"))).toEqual({ errorClass: "TypeError" }); + expect(JSON.stringify(describeHostedFailure(Object.assign(new Error("x"), { name: "bad name with token=1" })))).not.toContain("token"); + // A server-chosen code that is not a plain identifier is dropped, not copied. + const unsafeCode = new HostedOnlyBotsRequestError({ status: 500, code: "oops token=secret https://x", message: "m" }); + expect(describeHostedFailure(unsafeCode)).toEqual({ errorClass: "http", status: 500, code: null }); + expect(describeHostedFailure(new HostedOnlyBotsRequestError({ status: 502, code: "a".repeat(65), message: "m" })).code).toBeNull(); + }); +}); + +describe("describeBoundedRecallFallback", () => { + it("keeps unknown commits distinct from confirmed free failures and contradicting charge claims", () => { + const uncertain = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 503, code: "paid_operation_commit_unknown", message: "private", details: { charged_sats: 0 } })); + expect(uncertain).toContain("same idempotency key"); + expect(uncertain).not.toContain("charged nothing"); + const contradictory = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "private", details: { charged_sats: 2 } })); + expect(contradictory).toContain("may still settle"); + const rejected = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 400, code: "bad_request", message: "private", details: { charged_sats: 0 } })); + expect(rejected).toContain("charged nothing"); + expect(rejected).not.toContain("missed its response budget"); + }); + it("says nothing was charged only when the server confirms it, and keeps the settle caution otherwise", () => { + const deadline = new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "late", + details: { charged_sats: 0, retryable: true } }); + expect(describeBoundedRecallFallback(deadline)).toContain("charged nothing"); + expect(describeBoundedRecallFallback(deadline)).not.toContain("may still settle"); + const timeout = new DOMException("timed out", "TimeoutError"); + expect(describeBoundedRecallFallback(timeout)).toContain("may still settle"); + expect(describeBoundedRecallFallback(new TypeError("fetch failed"))).toContain("may still settle"); + const gateway = new HostedOnlyBotsRequestError({ status: 504, code: null, message: "gateway timeout" }); + expect(describeBoundedRecallFallback(gateway)).toContain("may still settle"); + const rejected = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 400, code: "bad_request", message: "secret detail" })); + expect(rejected).toContain("HTTP 400"); + expect(rejected).not.toContain("may still settle"); + expect(rejected).not.toContain("secret detail"); + }); +}); diff --git a/tests/runtime/hosted-lifecycle-security.test.ts b/tests/runtime/hosted-lifecycle-security.test.ts index b195e64..bf21938 100644 --- a/tests/runtime/hosted-lifecycle-security.test.ts +++ b/tests/runtime/hosted-lifecycle-security.test.ts @@ -27,7 +27,7 @@ const jsonResponse = (body: unknown, status = 200): Response => headers: { "content-type": "application/json" }, }); -const installFetchMock = (npub: string, contextStatus: number) => { +const installFetchMock = (npub: string, contextStatus: number, contextPack?: () => Promise) => { vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); if (url.pathname === "/v1/auth/challenge") { @@ -52,6 +52,7 @@ const installFetchMock = (npub: string, contextStatus: number) => { }); } if (url.pathname === "/v1/context/pack") { + if (contextPack) return contextPack(); if (contextStatus !== 200) { return jsonResponse({ error: { code: "payment_required", message: "funding required" } }, contextStatus); } @@ -173,4 +174,63 @@ describe("hosted lifecycle prompt boundary", () => { expect(promptSurface).not.toContain("SERVER_AUTHORED_FRAGMENT_MUST_NOT_REACH_THE_PROMPT"); if (contextStatus === 200) expect(result.contextPack?.packId).toBe("hosted-pack-safe-id"); }); + + it.each([ + { + label: "the server's recall deadline", + contextPack: async () => jsonResponse({ error: { code: "recall_deadline_exceeded", message: "late", + details: { deadline_ms: 2500, charged_sats: 0, retryable: true } } }, 503), + failure: { errorClass: "http", status: 503, code: "recall_deadline_exceeded" }, + warning: "charged nothing", + }, + { + label: "the client's recall timeout", + contextPack: async () => { throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); }, + failure: { errorClass: "timeout" }, + warning: "may still settle", + }, + { + label: "an unacknowledged paid commit", + contextPack: async () => jsonResponse({ error: { code: "paid_operation_commit_unknown", message: "uncertain", details: { retry_with_same_key: true } } }, 503), + failure: { errorClass: "http", status: 503, code: "paid_operation_commit_unknown" }, + warning: "same idempotency key", + }, + ])("records why bounded recall fell back to free hot memory after $label", async ({ contextPack, failure, warning }) => { + // Local-first sessions deliberately skip paid automatic recall. Exercise the + // still-supported hosted path explicitly rather than depending on defaults. + vi.stubEnv("CROSSHEAD_HOT_SYNC_ENABLED", "false"); + const signer = identity(); + const home = mkdtempSync(path.join(os.tmpdir(), "crosshead-bounded-fallback-")); + homes.push(home); + installFetchMock(signer.npub, 200, contextPack); + await setupRuntimeProfile({ + home, + profileName: "default", + apiBase: "https://crosshead.sh", + npub: signer.npub, + nsec: signer.nsec, + memoryMode: "assist", + }); + + const daemon = new OnlyBotsLocalRuntimeDaemon(home); + const result = await daemon.submitLifecycleEvent({ + runtime: "claude-code", + type: "turn.started", + workspaceId: "repo:security-test", + sessionId: "current-session", + turnId: "current-turn", + payload: { + goal: "Continue the verified local work", + recallPolicy: "bounded", + hotMemorySnapshot: verifiedHotSnapshot, + }, + }); + + const fallback = result.actions.find((action) => action.type === "context_pack" && action.status === "failed"); + expect(fallback?.details).toMatchObject({ freeFallback: true, failure }); + expect(result.warnings.filter((entry) => entry.startsWith("Saved-context retrieval unavailable"))).toEqual([ + expect.stringContaining(warning), + ]); + expect(JSON.stringify(result.contextPack)).toContain("CLIENT_VERIFIED_HOT_MEMORY_MUST_REACH_THE_PROMPT"); + }); });