fix(agent-hooks): make hook remove() a no-op when nothing is managed#9436
Draft
bbingz wants to merge 9 commits into
Draft
fix(agent-hooks): make hook remove() a no-op when nothing is managed#9436bbingz wants to merge 9 commits into
bbingz wants to merge 9 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(agent-hooks): make hook remove() a no-op when nothing is managed (six services)
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:
readHooksJson()returns{},readConfigFile()returns{ ok: true, config: {} }).mkdirSync(dir, { recursive: true })before anything else(
hooks-json-file.ts:writeHooksJson,hermes/hook-service.ts:writeConfigFile), so aremove()on a home that never had the agent installed would create e.g.~/.cursor/,~/.gemini/,~/.grok/,~/.commandcode/,~/.factory/, or~/.hermes/plus itssettings 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
~/.cursor,~/.gemini, … —whichever service).
remove()).After this fix: nothing is created or touched.
Fix
Apply the same no-op contract #9416 gave Claude/OpenClaude:
their removal through
updateHooksJsonWithRetry's null-mutate. The mutate strips managedcommands from every event, and returns
nullwhen no managed hook is present — whichskips the write entirely (no dir/file creation, no reformat, no
.bakroll) and, as abonus, gives
remove()the same concurrent-writer stale-checkinstall()already uses.Managed presence is detected with
hookDefinitionHasManagedCommand(matches by managedscript filename, so it still sweeps stale managed entries from older builds).
writeConfigFileon whetherorca-statusisactually in
plugins.enabled— the only keydisablePlugin()removes. The managed plugindir is still deleted independently (
rmSync), but theconfig.yamlwrite no longer keysoff the plugin files: gating on those would recreate a
config.yamlOrca had deleted, orreformat +
.bak-roll a user file that never enabledorca-status, after the plugin diris gone. Membership is checked directly (not via
getConfigEnablement()) so a malformedplugins.disabledcannot mask an enabledorca-status.No other refactoring; the install / status / remote paths are untouched.
Per-service verdict
~/.cursor/hooks.jsonupdateHooksJsonWithRetrynull-mutate~/.gemini/settings.jsonupdateHooksJsonWithRetrynull-mutate$GROK_HOME/hooks/orca-status.json(or~/.grok/…)updateHooksJsonWithRetrynull-mutate~/.commandcode/settings.jsonupdateHooksJsonWithRetrynull-mutate~/.factory/settings.jsonupdateHooksJsonWithRetrynull-mutate~/.hermes/config.yaml(+ plugin dir)config.yamlonly whenorca-statusis inplugins.enabledAll 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:remove()creates neither the config dir nor the settings file(state
not_installed).serialization: tabs / no trailing newline) and no
.bakis written.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:
pluginManaged || enabledgate capturedpluginManagedbefore the pluginrmSync, soremove()on a managed-plugin home with amissing or unrelated
config.yamlstill created/reformatted it. Fixed by gating purely onorca-status ∈ plugins.enabled. Two regression tests were added (managed plugin + deletedconfig.yaml; managed plugin + byte-irregular user YAML) — both fail on the old gate, passon the new one.
getConfigPath()honors$GROK_HOMEover the mockedhomedir(),so an ambient
GROK_HOMEwould have let the grok suite install/remove against the realGrok config. The suite now saves, clears, and restores
GROK_HOMEinbeforeEach/afterEach.HERMES_HOMEat a not-yet-existing dir and asserts theconfig root is never created; the install→remove test preseeds user content (
model, anunrelated enabled plugin) and asserts it survives.
Verification
tsc --noEmit -p tsconfig.node.json(coverssrc/main/**/*, incl. the tests): no errors.oxlinton the touched files: clean (grok stays under the 300-linemax-linescap —no disable added).
vitest runon the sixhook-service.test.tsfiles: 40 passed / 0 failed / 5 skipped(Windows-only
#6078cases).and Windows-only cases (the 5 skipped).