Skip to content

fix(agent-hooks): make hook remove() a no-op when nothing is managed#9436

Draft
bbingz wants to merge 9 commits into
stablyai:mainfrom
bbingz:bbingz/hook-remove-noop-gate
Draft

fix(agent-hooks): make hook remove() a no-op when nothing is managed#9436
bbingz wants to merge 9 commits into
stablyai:mainfrom
bbingz:bbingz/hook-remove-noop-gate

Conversation

@bbingz

@bbingz bbingz commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

fix(agent-hooks): make hook remove() a no-op when nothing is managed (six services)

Stacked on #9416 (reuses its no-op write machinery — updateHooksJsonWithRetry's
null-mutate). Its base branch orca/claude-config-dir-hooks is already pushed as PR
#9416; review the two commits after that PR's tip (a5826ea24d):
3e2e7e9598 (the six-service no-op change) and 804368b323 (review follow-up —
hermes enabled-membership gate + grok GROK_HOME test isolation). Refs #9414.

Bug

On a pristine machine (an agent CLI was never installed), toggling agent hooks OFF
runs each hook service's remove(). Six services — cursor, gemini, grok, command-code,
droid (Factory), and hermes
— created their config dir + settings file from scratch
instead of doing nothing:

  • The read primitives treat a missing file as empty (readHooksJson() returns {},
    readConfigFile() returns { ok: true, config: {} }).
  • The services then wrote unconditionally. Both write primitives call
    mkdirSync(dir, { recursive: true }) before anything else
    (hooks-json-file.ts:writeHooksJson, hermes/hook-service.ts:writeConfigFile), so a
    remove() on a home that never had the agent installed would create e.g. ~/.cursor/,
    ~/.gemini/, ~/.grok/, ~/.commandcode/, ~/.factory/, or ~/.hermes/ plus its
    settings file.

The same unconditional write also reformatted a user settings file that held no managed
entries
and rolled its .bak, even though Orca had nothing to remove.

remove() must be a strict no-op when there is nothing managed to remove: no dir creation,
no file creation, no reformat of a user file without managed entries, no backup roll.

Repro

  1. Start from a home where the agent CLI has never run (no ~/.cursor, ~/.gemini, … —
    whichever service).
  2. Toggle that agent's Orca status hooks OFF (the settings UI calls the service's
    remove()).
  3. Before this fix: the config dir + an Orca-serialized settings file appear from nowhere.
    After this fix: nothing is created or touched.

Fix

Apply the same no-op contract #9416 gave Claude/OpenClaude:

  • Five Claude-shaped JSON services (cursor, gemini, grok, command-code, droid) route
    their removal through updateHooksJsonWithRetry's null-mutate. The mutate strips managed
    commands from every event, and returns null when no managed hook is present — which
    skips the write entirely (no dir/file creation, no reformat, no .bak roll) and, as a
    bonus, gives remove() the same concurrent-writer stale-check install() already uses.
    Managed presence is detected with hookDefinitionHasManagedCommand (matches by managed
    script filename, so it still sweeps stale managed entries from older builds).
  • Hermes (bespoke YAML write path) gates writeConfigFile on whether orca-status is
    actually in plugins.enabled — the only key disablePlugin() removes. The managed plugin
    dir is still deleted independently (rmSync), but the config.yaml write no longer keys
    off the plugin files: gating on those would recreate a config.yaml Orca had deleted, or
    reformat + .bak-roll a user file that never enabled orca-status, after the plugin dir
    is gone. Membership is checked directly (not via getConfigEnablement()) so a malformed
    plugins.disabled cannot mask an enabled orca-status.

No other refactoring; the install / status / remote paths are untouched.

Per-service verdict

Service Config file Had the bug? Fix
cursor ~/.cursor/hooks.json Yes updateHooksJsonWithRetry null-mutate
gemini ~/.gemini/settings.json Yes updateHooksJsonWithRetry null-mutate
grok $GROK_HOME/hooks/orca-status.json (or ~/.grok/…) Yes updateHooksJsonWithRetry null-mutate
command-code ~/.commandcode/settings.json Yes updateHooksJsonWithRetry null-mutate
droid (Factory) ~/.factory/settings.json Yes updateHooksJsonWithRetry null-mutate
hermes ~/.hermes/config.yaml (+ plugin dir) Yes write config.yaml only when orca-status is in plugins.enabled

All six were bugged; none were already safe.

Tests

Per service, three remove() tests (mirroring the Claude no-op tests on the base branch),
all using injected/mocked home dirs — no real ~/.* touched:

  1. pristine homeremove() creates neither the config dir nor the settings file
    (state not_installed).
  2. user file without managed entries → stays byte-identical (deliberately non-Orca
    serialization: tabs / no trailing newline) and no .bak is written.
  3. real install → remove → managed entries stripped, user-authored entries preserved.

Repro-first check: the no-op tests were confirmed failing against the pre-fix source
(verified by reverting one service to base) and passing with the fix.

Review-round hardening

A follow-up review flagged two real gaps in the first cut, now fixed:

  • Hermes partial-install write — the original pluginManaged || enabled gate captured
    pluginManaged before the plugin rmSync, so remove() on a managed-plugin home with a
    missing or unrelated config.yaml still created/reformatted it. Fixed by gating purely on
    orca-status ∈ plugins.enabled. Two regression tests were added (managed plugin + deleted
    config.yaml; managed plugin + byte-irregular user YAML) — both fail on the old gate, pass
    on the new one.
  • Grok test isolationgetConfigPath() honors $GROK_HOME over the mocked homedir(),
    so an ambient GROK_HOME would have let the grok suite install/remove against the real
    Grok config. The suite now saves, clears, and restores GROK_HOME in beforeEach/afterEach.
  • The Hermes pristine test now points HERMES_HOME at a not-yet-existing dir and asserts the
    config root is never created; the install→remove test preseeds user content (model, an
    unrelated enabled plugin) and asserts it survives.

Verification

  • tsc --noEmit -p tsconfig.node.json (covers src/main/**/*, incl. the tests): no errors.
  • oxlint on the touched files: clean (grok stays under the 300-line max-lines cap —
    no disable added).
  • vitest run on the six hook-service.test.ts files: 40 passed / 0 failed / 5 skipped
    (Windows-only #6078 cases).
  • Not independently re-run this round: the full test suite, adjacent importer suites,
    and Windows-only cases (the 5 skipped).

bbingz added 9 commits July 19, 2026 10:31
…l convention

Home-dir entries matching .claude-<name> / .claude.<name> are treated as
Claude config dirs when a marker file (settings.json | .claude.json |
.credentials.json) exists inside. stat/readdir only — contents are never
read and listings are never logged (these dirs hold credentials). Works
over local fs and any SFTP-shaped transport (SSH, WSL fs bridge). The
shared flavor-label helper turns a configDir path into the sidebar label
suffix (~/.claude-grok -> grok).
- updateHooksJsonWithRetry: read-modify-write with a compare-and-retry
  stale check (re-read before replace, bounded attempts, guard dropped on
  the final attempt so install converges). ClaudeHookService install/remove
  now re-merge instead of clobbering keys a concurrent writer (the CLI
  itself, a second Orca instance) added between read and replace.
- Remote (SFTP) writes take a one-shot .orca-backup of an existing
  settings file before Orca's first modification; never rotated later.
- Widen ClaudeCompatibleHookSettings.configDirName to string so discovered
  CLAUDE_CONFIG_DIR flavor dirs can reuse the per-dir service shape.
…g dirs (local)

One ClaudeHookService per discovered ~/.claude-<name> dir — N dirs mean N
settings.json writes all pointing at the one shared claude-hook script.
The lifecycle rides the existing claude entry in the managed
install/remove/status flow (same agentStatusHooksEnabled gate).

Install ledger under userData/agent-hooks records every dir Orca wrote
into (union across runs, written before installing), so uninstall cleans
exactly the touched dirs even when discovery results change later.
Ledger entries are re-validated against the naming convention on read.
Status folds per-dir health into the claude row as a count-only detail —
dir names never appear in status text or logs.
…and WSL remotes

installRemoteManagedAgentHooks now runs config-dir discovery over the same
SFTP-shaped transport after the static agents, so SSH remotes and the WSL
fs bridge both inherit it with no per-host wiring. POSIX remotes only, as
before (Windows remotes stay skipped upstream). Discovery/install failures
degrade status reporting only and log no dir names. The static-agent
parity invariant is unchanged by design — dynamic dirs are instances of
the registered claude agent, covered by their own remote + WSL tests.
…into modules

Mechanical extraction, no behavior change: the hooks settings JSON contract
(types + read/write/retry primitives) moves to hooks-json-file.ts and the
timeout-guarded ssh2 wrappers move to sftp-file-primitives.ts. Restores the
max-lines budget the concurrency guard exceeded; installer-utils re-exports
the JSON surface so call sites keep their single historical import.
…sidebar

The POSIX hook script and the Windows .cmd variant post a configDir form
field (path string only — never tokens; empty for default installs). The
listener threads it into the parsed status payload for claude posts, it
persists/hydrates like other payload fields, and the compact sidebar agent
row renders 'Claude · <flavor>' (~/.claude-grok -> grok) when an entry
carries a non-default config dir. Payloads without the field behave
exactly as before (back-compat with already-installed scripts).
- Make claude-config-dir-hook-controls electron-free: the module rides in
  the standalone orca CLI build (ELECTRON_RUN_AS_NODE, no electron in
  packaged node_modules), so a static electron import crashed every CLI
  command. Lazy-require electron and fall back to the shared userData
  convention, extracted to src/shared/orca-user-data-path.ts and reused by
  src/cli/runtime/metadata.ts. Guard test scans the whole tsconfig.cli.json
  include set for static electron imports.
- Reinstate the remove() no-op gate: updateHooksJsonWithRetry mutate may
  return null (nothing to change) so a no-op removal no longer creates a
  missing settings.json, reformats/.bak-rolls a user file without managed
  hooks, or resurrects a deleted flavor dir.
- Prune ledger entries whose dir was deleted entirely (install persists the
  prune, status skips them) so a legitimately deleted dir cannot pin the
  aggregated claude status at 'partial' forever.
- Emit one name-free console.warn when a discovered-dir install fails,
  matching the remote flow's diagnosability.
On a pristine machine (an agent CLI never installed), toggling agent hooks
OFF ran each service's remove(), and cursor, gemini, grok, command-code,
droid (Factory), and hermes each created their config dir + settings file
from scratch: readHooksJson()/readConfigFile() treat a missing file as {},
then the code wrote unconditionally (writeHooksJson/writeConfigFile always
mkdirSync the config dir). remove() also reformatted a user settings file
that held no managed entries and rolled its .bak.

Apply the same no-op contract stablyai#9416 gave Claude/OpenClaude:

- The five Claude-shaped JSON services (cursor, gemini, grok, command-code,
  droid) now run their removal through updateHooksJsonWithRetry's null-mutate
  — returning null when no managed hook is present skips the write entirely
  (no dir/file creation, no reformat, no .bak roll) and adds the same
  concurrent-writer stale-check install() already uses.
- Hermes (bespoke YAML write path) gates writeConfigFile behind a
  managed-present check: rewrite config.yaml only when the managed plugin was
  on disk or orca-status was in plugins.enabled.

Per service, add remove() tests: a pristine home creates neither the config
dir nor the settings file; a user file without managed entries stays
byte-identical (no .bak); a real install->remove still strips managed entries
and preserves user content.

Refs stablyai#9414.
…isolate GROK_HOME in grok tests

Review follow-up to the six-service remove() no-op change.

- hermes remove() gated the config.yaml write on a pluginManaged flag captured
  before the plugin rmSync, so a managed-plugin home with a missing or unrelated
  config.yaml still had it created/reformatted (and .bak-rolled) after the plugin
  dir was deleted. Gate the write purely on orca-status being in plugins.enabled
  (the only key disablePlugin removes), checked directly so a malformed
  plugins.disabled cannot mask an enabled orca-status. Plugin removal stays
  independent. Add two regressions: managed plugin + deleted config.yaml, and
  managed plugin + byte-irregular user YAML — both must delete the plugin but
  neither create/rewrite config.yaml nor roll a .bak.

- grok tests: getConfigPath() honors $GROK_HOME over the mocked homedir(), so an
  ambient GROK_HOME let the suite install/remove against the real Grok config.
  Save, clear, and restore GROK_HOME per test so the mocked temp home always wins.

- Strengthen hermes tests: pristine case points HERMES_HOME at a not-yet-existing
  dir and asserts the config root is never created; install->remove preseeds user
  content and asserts it survives.

Refs stablyai#9414.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant