feat(plugin): a Claude Code plugin for context-guru, with the #141 review's blockers fixed - #160
feat(plugin): a Claude Code plugin for context-guru, with the #141 review's blockers fixed#160amiddavid wants to merge 5 commits into
Conversation
3627248 to
e9be584
Compare
e9be584 to
56790db
Compare
Review of #161 found the same defect this PR is themed on — claims that are not true — in seven more places, one of them in shipped code. All seven fixed, plus the `HasOffload` unit test the reviewer raised without asking for. Rebased onto current main first, so #142's preset-table guard and the preset pass below cannot re-fix or re-break each other. ## Merge-blocking **1. `proxy/proxy.go` named `mcp` as offloader-free.** It is not: `smartcrush` implements components.Offload, which this PR's own test asserts (`wantAdd: true`) and its own mirror-image mutation proves. I had corrected the test and left the comment wrong — the copy a future reader actually trusts. The fix deletes the list rather than correcting it. The comment now names the shapes affected (`off`, `safe`, any cachesplit-only configuration) and then says why enumerating presets here is the wrong move: a list in a comment is a second source of truth, and this one was wrong about `mcp` on its first draft. That is the whole argument for gating on the interface. **2. Five sites named the `cache` preset, which does not exist on this base.** It is #141's, and it reached here in the wholesale copy of `proxy/proxy.go` this PR already admits to, then travelled into the test files when they were split out of that branch. Reworded to name configurations that exist here; the underlying defect they describe is unchanged and still reproduces on `off` and `safe`. **3. `proxy/counttokens_test.go` carried copy-paste artifacts vet and gofmt cannot see.** A duplicated 3-line doc comment, and a 20-line orphan documenting a function that lives in `expandgate_test.go` under a different name and citing a test that exists nowhere. Both from the same cause: my splitter took each test's doc comment by scanning back to the previous blank line, which swallowed the FOLLOWING test's comment as a trailing block. A third artifact the review did not list is fixed too — `expandgate_test.go`'s doc comment still described "the preset's promise" and cited `docs/how-to/install-plugin.md` and an install skill, both of which belong to #160. ## The rest **4.** `docs/reference/config.md` and `docs/components.md` said `auto` injection has exactly two conditions. It has three. Both now say so, and say what the third is for. The cache-stability argument those passages make is unaffected — a pipeline does not change turn to turn either — so it gained a member rather than needing a rewrite. **5. `make build` now sets `CGO_ENABLED=0`.** The docs could claim "no C toolchain" all they liked while step 1 of the quickstart was `make build`, which needed one because the Makefile exported `CGO_ENABLED=1` for every target. Pointing readers at `build-static` would have fixed the sentence; making the DEFAULT build pure Go makes the claim true of the command the docs tell people to run. `CGO_ENABLED=1` stays for the test targets, where `-race` requires it, and the comment says exactly that. Verified: `CC=/nonexistent make build` produces a statically linked binary. README, CLAUDE.md and the quickstart no longer require a C toolchain. All five remaining `codesmart`-is-the-default sites are corrected — including two in `config/config.go`, which is how the claim spread to five documents: it sat three lines from the flag that disproves it. **6.** `docs/setup.md` overstated its own evidence, which is the exact sin this PR is about. It claimed CI removes the C compiler from `PATH` (with cgo off the toolchain never consults `CC`; that variable is a tripwire, not the mechanism) and that cross-compilation to four targets is asserted, when CI builds native linux/amd64 only. Now says what CI actually does, and states separately that the other three targets were verified by hand and are asserted at release time. Same overstatement fixed in the `ci.yaml` comment. **7.** `ci.yaml` promised a linked issue and linked nothing, and named a different flake than the PR body did. Both are real; the comment is about the campaign one, and now links #163. ## HasOffload unit tests `./components`: nil-safe, empty pipeline (the A/B control arm), reformatters-only, and an offloader in three positions. Revert-verified both ways — always-true fails the empty and reformatter cases, always-false fails the offloader cases. A registry-walking test was supposed to make it rot-proof, and **it skipped**: registrations happen in `components/all`, so a test inside `components` can neither see them nor import the package that does. A test that skips reads as coverage and is not, so it moved to `components/all`, where it runs — 21 of 21 registered components, 13 implementing Offload. It fails if either count is zero, because an all-false or all-true population would agree with a broken HasOffload. Full `go test ./...`, `go vet ./...` and `gofmt -l` clean; doc link/anchor checker re-run over every document touched. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
56790db to
295f551
Compare
…pand-tool gate, count_tokens, C-toolchain claim, preset facts) (#161) * fix: defects that surfaced while building the distribution funnel None of this is distribution work. Every item is a defect in code or docs that already shipped, found while doing #141, and split out at review request so it can be judged on its own — and so it can land whether or not the funnel does. ## 1. The expand tool was advertised where no marker can exist `expand.Inject` under `auto` gated on "the request declares tools" and "the store persists". Nothing asked whether the pipeline could produce a `<<cg:HASH>>` marker at all, so an offloader-free pipeline declared `context_guru_expand` to the provider — and every call against it must fail, because there is nothing in the Store to resolve. Measured on the real gateway route: tools SENT by client : [Read Bash] tools FORWARDED upstream: [Read Bash context_guru_expand] Affected `safe` and any cachesplit-only configuration, and — the one that matters most — **`off`, the A/B control arm**. A control that carries an extra tool declaration is not a control, and every measurement taken against it was comparing two arms that differed by more than the pipeline. The cost when it fires is a wasted round trip and a step of the user's turn: on a transcript containing marker-shaped text (this repo's own docs contain literal `<<cg:HASH>>`), a model calls the tool and gets "[expand: original for id ... is no longer available]". It was also a code-vs-comment contradiction, which is why nobody noticed: `Options.InjectExpand` documented the gate as requiring "an expandable marker", while `expand/inject.go` says "No marker condition, deliberately" three lines from the code. Both now describe what happens. `components.Pipeline.HasOffload()` answers by TYPE ASSERTION, not a list of component names: a name list is a second copy of "which components are lossy" and drifts the moment somebody adds one. `components.Offload` cannot be implemented by accident — it requires returning cache keys proving the original was stashed. Marker independence is preserved (the property that keeps the tools array byte-stable across a session, and hence the prefix cached): a pipeline does not change turn to turn. **Ten existing tests changed fixture.** Every test of the expand loop hand-seeds the Store to simulate an offload, but built its handler with `pipeline: []` — which cannot offload anything. Harmless while injection ignored the pipeline; now they use `offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies). No assertion was weakened; each fixture now matches its own premise. ## 2. `POST /v1/messages/count_tokens` was not served Absent it, a client asking how big its context is gets a 404 and falls back to working it out with **inference requests** — billed calls, caused by a proxy whose purpose is to reduce them. Cheap to add, and it costs every routed user, not only the funnel. Forwarded verbatim, with no pipeline. Returning the compacted count would be smaller and would be wrong in the dangerous direction: the client budgets its own transcript from this number, and because every component fails open, the next request could forward the full body and take a 400. Over-reporting is recoverable; under-reporting is a failed turn. The cost of that choice is now documented in `docs/reference/routes.md`, where the route was absent entirely — a routed session self-compacts earlier than it needs to (115,933 reported vs 32,802 forwarded on a measured body). The hosted branch has tests, because that branch is the only thing standing between the multi-tenant service and an unmetered open forwarder that would send OUR credential upstream. ## 3. Our own docs said the binary needs a C toolchain `docs/setup.md`, `docs/hosted.md` and `docs/get-started/quickstart-proxy.md` all told evaluators to install one. It is needed for `go test -race` and for the optional `cg_skeleton` tag, not for the binary. setup.md went further and named **bifrost's tokenizer** as a cgo dependency, which it never was — o200k_base is embedded (`internal/tokens/tokens.go`). Asserted rather than re-claimed: a new `purego` CI job builds with `CGO_ENABLED=0` and `CC=/nonexistent-c-compiler`, checks the artifact is statically linked, starts it and probes /healthz. It also runs the packages whose behaviour depends on which components compile in — because `build-test` runs exclusively with `CGO_ENABLED=1` (the race detector needs it), so `TestEveryPresetBuilds` had **never executed in the configuration a user would build**. That guard exists for exactly the `preset: coding` / `unknown component "skeleton"` breakage. ## 4. Preset facts stated outside the guarded files (#143, #145) - The binary defaults to **`house`**; five sites said `codesmart` (README x3, `docs/reference/config.md`, `docs/get-started/quickstart-proxy.md` — the last is step 2 of the first page anyone runs). Anyone running the binary bare while reading those measured a different configuration than the published SWE-bench numbers describe. - README's `codesmart`/`codesafe` pipeline lists and `docs/get-started/connect-ibm-service.md`'s "Default pipeline" were stale — naming `toon`, retired after acting 0 of 5,752 production requests, and omitting components that do run. The IBM page's omission of `toolfilter` matters most: that page is what a prospective hosted tenant reads to decide what the service does to their traffic. All regenerated from the `presets` map. The two tables inside #142's drift guard are untouched here; these are the sites that guard cannot reach. ## Verification Five mutations, each proven to have landed in the source before its result was allowed to count: expand injection ungated -> TestExpandToolIsAdvertisedOnlyWhereMarkersCanExist FAIL on cachesplit-only, `safe`, and `off` HasOffload always false -> same test FAIL on `mcp` and the offloader pipeline: "mints markers but no longer advertises the expand tool, so a model cannot recover what it offloaded" count_tokens route unregistered -> TestCountTokensIsServed FAIL (404) count_tokens rewrites the body -> TestCountTokensIsServed FAIL hosted auth removed -> TestCountTokensHostedRequiresAuth FAIL (502, want 401) The second is the mirror-image check: it proves the gate did not trade one silent defect for another, an offloader whose output nothing can expand. Two things I got wrong on the way, recorded because both were caught by tests rather than by me: - I first asserted `mcp` had no offloader. `smartcrush` implements `components.Offload` (`components/offload/smartcrush.go`), so that pipeline genuinely mints markers and genuinely needs the tool. The case now asserts the opposite, with the reason — and it is the argument for asking the interface rather than keeping a hand-written list. - Copying `proxy/proxy.go` wholesale from the older distribution branch onto current main silently reverted #155's `effPreset`/`notePreset` work. `TestCompactRowNamesThePresetThatRan` — a test I had never read — failed with "the dashboard names a pipeline that did not run". The file was restored from main and the two edits re-applied on top; #155's change is intact. `go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean. One unrelated flake seen once and not reproduced: `TestConcurrentCallsDoNotRaceOnTheGateHistogram` failed in a full-suite run with "no single-flight follower ran ... the race was never exercised", then passed 8/8 in isolation and in two further full suites, and passes on clean main. Reported separately rather than papered over. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> * ci(purego): run one package binary at a time The new job runs `go test` over five package trees, and `go test` starts up to GOMAXPROCS package binaries in parallel. On a 2-core CI runner that added a second heavily-parallel run of the proxy package per PR, and under that contention a timing-sensitive control-plane test from #150 (TestCtlGetCampaignAggregatesPredictedAndRealPerTenant) failed on two unrelated PRs — then passed on a re-run of the same commit, and passes 3/3 whole-package on a 16-core box against both main and the affected branch. Filed as #163. Hunting that flake is not this job's business. Not provoking it is: `-p 1` costs about a minute and removes the contention this job introduced, without dropping any coverage. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> * fix: make this PR's own prose true, and unit-test HasOffload Review of #161 found the same defect this PR is themed on — claims that are not true — in seven more places, one of them in shipped code. All seven fixed, plus the `HasOffload` unit test the reviewer raised without asking for. Rebased onto current main first, so #142's preset-table guard and the preset pass below cannot re-fix or re-break each other. ## Merge-blocking **1. `proxy/proxy.go` named `mcp` as offloader-free.** It is not: `smartcrush` implements components.Offload, which this PR's own test asserts (`wantAdd: true`) and its own mirror-image mutation proves. I had corrected the test and left the comment wrong — the copy a future reader actually trusts. The fix deletes the list rather than correcting it. The comment now names the shapes affected (`off`, `safe`, any cachesplit-only configuration) and then says why enumerating presets here is the wrong move: a list in a comment is a second source of truth, and this one was wrong about `mcp` on its first draft. That is the whole argument for gating on the interface. **2. Five sites named the `cache` preset, which does not exist on this base.** It is #141's, and it reached here in the wholesale copy of `proxy/proxy.go` this PR already admits to, then travelled into the test files when they were split out of that branch. Reworded to name configurations that exist here; the underlying defect they describe is unchanged and still reproduces on `off` and `safe`. **3. `proxy/counttokens_test.go` carried copy-paste artifacts vet and gofmt cannot see.** A duplicated 3-line doc comment, and a 20-line orphan documenting a function that lives in `expandgate_test.go` under a different name and citing a test that exists nowhere. Both from the same cause: my splitter took each test's doc comment by scanning back to the previous blank line, which swallowed the FOLLOWING test's comment as a trailing block. A third artifact the review did not list is fixed too — `expandgate_test.go`'s doc comment still described "the preset's promise" and cited `docs/how-to/install-plugin.md` and an install skill, both of which belong to #160. ## The rest **4.** `docs/reference/config.md` and `docs/components.md` said `auto` injection has exactly two conditions. It has three. Both now say so, and say what the third is for. The cache-stability argument those passages make is unaffected — a pipeline does not change turn to turn either — so it gained a member rather than needing a rewrite. **5. `make build` now sets `CGO_ENABLED=0`.** The docs could claim "no C toolchain" all they liked while step 1 of the quickstart was `make build`, which needed one because the Makefile exported `CGO_ENABLED=1` for every target. Pointing readers at `build-static` would have fixed the sentence; making the DEFAULT build pure Go makes the claim true of the command the docs tell people to run. `CGO_ENABLED=1` stays for the test targets, where `-race` requires it, and the comment says exactly that. Verified: `CC=/nonexistent make build` produces a statically linked binary. README, CLAUDE.md and the quickstart no longer require a C toolchain. All five remaining `codesmart`-is-the-default sites are corrected — including two in `config/config.go`, which is how the claim spread to five documents: it sat three lines from the flag that disproves it. **6.** `docs/setup.md` overstated its own evidence, which is the exact sin this PR is about. It claimed CI removes the C compiler from `PATH` (with cgo off the toolchain never consults `CC`; that variable is a tripwire, not the mechanism) and that cross-compilation to four targets is asserted, when CI builds native linux/amd64 only. Now says what CI actually does, and states separately that the other three targets were verified by hand and are asserted at release time. Same overstatement fixed in the `ci.yaml` comment. **7.** `ci.yaml` promised a linked issue and linked nothing, and named a different flake than the PR body did. Both are real; the comment is about the campaign one, and now links #163. ## HasOffload unit tests `./components`: nil-safe, empty pipeline (the A/B control arm), reformatters-only, and an offloader in three positions. Revert-verified both ways — always-true fails the empty and reformatter cases, always-false fails the offloader cases. A registry-walking test was supposed to make it rot-proof, and **it skipped**: registrations happen in `components/all`, so a test inside `components` can neither see them nor import the package that does. A test that skips reads as coverage and is not, so it moved to `components/all`, where it runs — 21 of 21 registered components, 13 implementing Offload. It fails if either count is zero, because an all-false or all-true population would agree with a broken HasOffload. Full `go test ./...`, `go vet ./...` and `gofmt -l` clean; doc link/anchor checker re-run over every document touched. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> --------- Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
a2c50f5 to
ceff711
Compare
…view's blockers fixed Split out of #141 as its own PR: all six of the review's blocking findings were in the plugin, and the release plumbing and conformance work should not wait behind them. The core lands in #141. `/plugin marketplace add rossoctl/context-guru` → `/plugin install` → `/context-guru:install`. Three skills over four scripts and two hooks. Default routing scope is `.claude/settings.local.json`: one repo, gitignored, `--global` an explicit opt-in — a base URL pointing at localhost breaks Claude Code everywhere a dead proxy is routed. **1. `/context-guru:uninstall` killed the user's own session and left the proxy running.** It ran `pkill -f "context-guru-proxy.*${PORT}"`. The port was passed through `LISTEN_ADDR` in the environment, so it appeared nowhere in the proxy's command line and the pattern matched no proxy — while it DID match the shell running the `pkill`, i.e. the session's own Bash tool. A user runs uninstall *because* their sessions are broken; this killed the session mid-command, reported nothing removed, and left the port held. Fixed with a handle rather than a better pattern: the starter passes `--listen` (so the port is in `argv` and `ps` is honest) and writes a pidfile under `~/.local/state/context-guru`; uninstall kills that PID, falls back to the socket's owner via `lsof`/`ss`, and confirms the process is ours before killing anything. The skill also no longer offers a broader pattern as a fallback — on a host running a production instance or a benchmark arm, that would take those down too. **2. `install.sh` could not install anything, and its documented fallback was missing.** Strict checksums now; `download_failed` (a tag with no assets) is documented as an outcome; the `go install` fallback the header comment described is implemented; curl's stderr no longer breaks the `key=value` contract the skill parses. **3. A dead proxy is a silent, indefinite hang** — no output on either stream — and `/context-guru:status` cannot diagnose it, because invoking a skill needs a model call, which is the broken thing. New `check-proxy.sh` on `UserPromptSubmit`: it probes `/healthz`, tries to restart, and otherwise prints what to do. A hook is the only thing that runs without a model turn. It never blocks a prompt. **4. The `cache` preset advertised `context_guru_expand`.** Fixed in #141 (the gate belongs in the proxy); the docs and the install skill here no longer claim otherwise where they were wrong. **5. `settings.py` destroyed the user's undo, and uninstall did not restore what it replaced.** The backup stamp was second-granularity with an overwriting `copy2`, so an install→uninstall round trip wrote both backups to the same path and the survivor held the POST-install state — the value it existed to protect was gone from the file AND the backup. Now microsecond-stamped and created with `O_EXCL`. And `replaced` was reported then forgotten, so after a `--force` install over somebody's gateway, uninstall left them with no base URL at all; the replaced value is now recorded and restored. `is_ours` deserves a note. The review suggested matching `http://(127.0.0.1|localhost|[::1]):\\d+ /anthropic` as ours, to stop a port change reporting a conflict against context-guru itself. A test caught why that is wrong: litellm's default is `http://127.0.0.1:4000/anthropic`, so a URL-shape rule would let uninstall delete somebody else's routing. Two local proxies are indistinguishable by URL, so `add` records the URL it wrote and later runs read that record. Anything unrecorded stays a conflict — for both add and remove. **6. The atomic write widened a credential-bearing file's mode** from 600 to 644 under the common umask, and `os.replace` onto a symlinked `settings.json` replaced the LINK with a regular file, so a dotfile-managed setup silently never received the edit. Mode is preserved; the path is resolved first. - **`start-proxy.sh` printed a dead dashboard link** — it advertised `/dashboard/` and never passed `--dashboard`, so the first line the plugin ever prints was a 404. Now passed, with `--dashboard-db` under the state directory: the default would write `./context-guru-dashboard.db` into the user's repository. - **Backups accumulated forever** (one per add and per remove). Pruned to the newest 10. - **The zero-value cases are now stated** where a first-run user reads them, and `status` checks the one that is both commonest and previously undocumented: **outside a git repository** there is no environment snapshot, so `cachesplit` skips and the saving is exactly zero. The status skill also no longer treats `acted: 0` / `savings_pct: 0` as a verdict — those count content removal, and this component relocates a breakpoint. - **`--idle-exit`'s 24h is the plugin's value, not the flag's default** (which is 0 = never). Said so, along with probes not counting as activity. - Upgrade path documented (`CONTEXT_GURU_UPGRADE=1`, `CONTEXT_GURU_VERSION`). The scripts are tested from Go (`context-guru-plugin/plugin_test.go`) so `go test ./...` and CI cover them. Seven mutations, each proven to have landed before its result counted: backup() back to overwriting copy2 -> TestBackupsDoNotClobberEachOther FAIL "both operations reported the same backup path ..., so one overwrote the other" uninstall stops restoring -> TestUninstallRestoresTheBaseURLItReplaced FAIL restored="" want "https://gateway.corp.example/anthropic"; env left {ANTHROPIC_AUTH_TOKEN:keep} mode no longer preserved -> TestSettingsPreservesFileMode FAIL realpath removed -> TestSettingsFollowsASymlink FAIL checksum fail-open again -> TestInstallRefusesAnUnverifiedDownload FAIL port back in the environment -> TestHookMakesTheProxyIdentifiable FAIL pidfile no longer written -> TestHookMakesTheProxyIdentifiable FAIL One of those is worth recording as a process note: my first attempt at the backup mutation reverted only the timestamp granularity and left the `O_EXCL` retry loop in place, so the name was still unique and the test passed — proving nothing. Reverting half a fix is its own way to get a vacuous result. The run above restores the original function whole. Pre-existing coverage still passes: settings merge/conflict/removal/backup, and the hook's silence in unrouted projects, idempotence, non-failure when the binary is missing, and its wait for `/healthz`. **Still not verified end to end in a real Claude Code session**, because `install.sh` resolves a GitHub release and no tag has published assets yet. That is the first thing to do once #141 merges and a tag exists. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
… path #141 sets goreleaser's `wrap_in_directory: true`, so the release archive unpacks into a directory and the binary is no longer at the root — which is exactly what `[ -f "$TMP/$BIN" ]` assumed. It is wrapped for a reason worth not undoing: a flat archive plus the documented `tar xzf` with no `-C` overwrites the README.md and LICENSE of whatever directory the user is standing in, which for somebody evaluating a proxy for their agent is their own project. So find the binary rather than assume it. That works for either layout, and the failure it avoids is the worst-placed one available: a stranger's very first install, reporting `binary_not_in_tarball` — which reads as a broken release rather than a file that moved. Found by checking #141's packaging change against this script rather than by waiting for it to fail. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
ceff711 to
5d37e0c
Compare
amiddavid
left a comment
There was a problem hiding this comment.
Review of #160 — plugin only, 2 commits, 17 files
Reviewed at 5d37e0c in a separate worktree, rebased scope (origin/main...HEAD), so #141's
changes are excluded. I took your steer and spent the depth on settings.py and the
SessionStart/UserPromptSubmit hooks rather than the docs.
The six blocking findings from #141 are genuinely fixed, and the tests are not vacuous — each
one drives the real script and asserts an observable (the sentinel file, the recorded argv, the
pidfile naming a live PID, the file mode, the symlink target). TestInstallRefusesAnUnverified Download in particular tests the fail-closed property rather than the happy path. The
is_ours-by-record decision is right, and your reasoning for declining the URL-shape suggestion
holds: litellm's 127.0.0.1:4000/anthropic really does make a shape rule unsafe.
Two findings below are, I think, blocking, and both are on the surfaces you named. I verified each
by running the scripts rather than by reading — measurements are in the comments.
1 — the UserPromptSubmit hook cannot finish inside its own timeout. Measured 18s on the exact
dead-proxy path it exists for, against a timeout: 10. The hook is the whole fix for blocking
finding #3, and in the scenario it was written for it is killed before it prints anything.
2 — settings.py remove without --url deletes any base URL, including a stranger's. Measured:
a corp gateway with no context-guru record was removed with result=removed, exit 0. The safety
property currently lives only in the uninstall skill's prompt — a model remembering a flag — while
this script is the layer that is supposed to be deterministic and to "refuse to guess."
Four medium items and a tail of small ones follow. Nothing here needs a real install to close
except the go install PATH item, which I could not exercise: there is no Go toolchain on this
machine, so I could not run go test ./.... CI's build-test was still pending when I checked;
make cover does run ./... so the new package is genuinely gated (the piped go test | sed step
at ci.yaml:34 is only the coverage log, not the gate).
| { | ||
| "type": "command", | ||
| "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/check-proxy.sh", | ||
| "timeout": 10 |
There was a problem hiding this comment.
Blocking — this timeout is shorter than the work the hook does, on the path the hook exists for.
Measured, with routing configured, no proxy listening, and a binary present that never binds:
$ time check-proxy.sh # via CLAUDE_PLUGIN_ROOT, routed port, CONTEXT_GURU_BIN=a sleep script
ELAPSED=18s (UserPromptSubmit timeout is 10s)
The 18s is structural, not a fluke: check-proxy.sh probes /healthz, then invokes
start-proxy.sh, which probes again and then runs its own 15s health-wait loop. The
diagnostic cat <<EOF block is the last thing in the script, so at 10s the hook is killed and
the user sees nothing at all — the identical symptom (a prompt that produces no output) that
this hook was added to replace with an explanation.
The comment at start-proxy.sh:99 says "the hook's own timeout (60s in hooks.json) is the real
backstop." That is true for SessionStart and false here; this path has 10s.
Two fixes, and I think you want both:
- Print the note before attempting recovery, not after. The note is the deliverable; the
restart is opportunistic. Emitting it first means a kill at any point still leaves the user
informed. - Bound the wait when invoked from this hook — e.g. have
start-proxy.shhonour a
CONTEXT_GURU_HEALTH_BUDGET(seconds) thatcheck-proxy.shsets to ~5, and raise this
timeoutto 30 so a slow-but-successful recovery still gets to report success.
A test here would have caught it; see my comment on check-proxy.sh.
| # Ours is the URL passed in, or the one we recorded at install time — which covers the case | ||
| # where the configured port changed since. It is NOT "any loopback /anthropic URL": litellm's | ||
| # default is one of those, and uninstall must not delete somebody else's routing. | ||
| if args.url and current != args.url and not is_ours(data, current): |
There was a problem hiding this comment.
Blocking — remove with no --url deletes a base URL that is not ours, and reports success.
args.url defaults to "" (line 286), and this guard is if args.url and .... With --url
omitted the entire conflict check is skipped and the key is deleted unconditionally. Measured
against a file with someone else's gateway and no context-guru record:
$ settings.py remove --file settings.json # note: no --url
result=removed
was=https://gateway.corp.example.com
restored=
exit=0
The gateway is gone, restored= is empty because there was no record to restore from, and the
exit code says success. The module docstring advertises exactly this invocation as supported —
settings.py remove --file PATH [--url URL] — and states two lines later that a base URL that is
not ours "is a CONFLICT and exits non-zero."
What makes this blocking rather than cosmetic is where the safety currently lives. uninstall/SKILL.md
does pass --url, and its prose says "Passing --url is what keeps this safe" — so the property
that protects the user's gateway is a model remembering a flag in a prompt. This script is the
deterministic half precisely so that it is not. It is also the failure mode with the worst blast
radius you named: it edits ~/.claude/settings.json.
Fix, either way round:
- make
--urlrequired forremove(mirroring theaddcheck inmain()), or - treat a missing
--urlas "remove only ifis_ours(data, current)" — i.e. fail toward leaving
the user's configuration alone, which is whatis_ours' own docstring says it is for.
Worth a test: remove with no --url over a foreign URL must exit 2 and leave the file byte-identical.
|
|
||
| # Up to ~15s. A cold start is well under a second; the budget is for a loaded laptop, and the | ||
| # hook's own timeout (60s in hooks.json) is the real backstop. | ||
| for _ in $(seq 1 60); do |
There was a problem hiding this comment.
Medium — worst-case loop duration is ~138s, against a 60s SessionStart timeout.
The comment says "Up to ~15s", which holds only when the port is refused — then curl returns
instantly and 60 × 0.25s ≈ 15s. If something is listening but not answering (a hung proxy, a
half-open socket, a port taken by an unrelated service that accepts and stalls), each curl burns
its full --max-time 2. Measured against a listener that accepts and never replies:
one curl took 2054ms -> 60 iterations ≈ 138s (SessionStart timeout is 60s)
Consequence: the hook is killed at 60s, so the block at lines 109-116 never runs — the user gets
no log path, no /context-guru:status pointer, no tail of the log. And a hung port is one of the
likelier ways to reach that block, since a cleanly-absent proxy exits the loop fast either way.
Budget on wall clock rather than iteration count, and drop the per-probe timeout:
deadline=$(( $(date +%s) + ${CONTEXT_GURU_HEALTH_BUDGET:-15} ))
while [ "$(date +%s)" -lt "$deadline" ]; do
curl -fsS --max-time 1 "$HEALTH" >/dev/null 2>&1 && { ...; exit 0; }
sleep 0.25
doneThat also gives you the knob check-proxy.sh needs for the 10s-timeout finding on hooks.json.
| command -v go >/dev/null 2>&1 || return 1 | ||
| emit "fallback=go_install" | ||
| # CGO off: the binary is pure Go, and requiring a C toolchain here would reintroduce the gate. | ||
| if CGO_ENABLED=0 GOBIN="$DEST" go install "github.com/${REPO}/cmd/context-guru-proxy@${VERSION}" 2>"$TMP/go.err"; then |
There was a problem hiding this comment.
Medium — the go install fallback skips both the mkdir and the on_path report that the
tarball path does.
try_source_build returns 0 and line 126 exits 0 immediately, so everything from line 178 down is
bypassed on this path:
mkdir -p "$DEST"(line 178) never runs, yetGOBIN="$DEST"is set here. I could not test
whethergo installcreates a missingGOBIN— there is no Go toolchain on this machine — so
this needs confirming rather than taking from me; if it does not, the fallback fails on a clean
box where~/.local/bindoes not exist yet, which is the common case for the audience this
whole script targets.on_pathis never emitted (lines 193-197). So a user who lands on the fallback gets no
warning when$DESTis not onPATH— and~/.local/binfrequently is not. They then hit
start-proxy.sh:56: "routing is configured for port N but the proxy binary is not on PATH",
from a hook, in a later session, with nothing connecting it back to the install.install/SKILL.md
readson_pathand tells the user about it, so the skill is silent here too.
Both are fixed by making the fallback fall through to the shared tail instead of exiting: have
try_source_build set the path and break/skip to line 188, or hoist mkdir -p "$DEST" above
line 121 and factor the on_path case into a function both paths call.
Minor, same function: emit "fallback=go_install" is printed before the attempt, so it appears
even when the build fails. install/SKILL.md documents reading it together with the result, which
is correct — just noting the line is "attempted", not "used".
|
|
||
| If nothing answers, start it in the foreground of a background shell and read the log rather | ||
| than declaring victory: | ||
|
|
There was a problem hiding this comment.
Medium — this manual-start command reintroduces the dead dashboard link the PR lists as fixed.
It omits --dashboard and --dashboard-db, which start-proxy.sh:89-90 passes deliberately.
--dashboard defaults to false (cmd/context-guru-proxy/main.go:105), so a proxy started this
way does not serve /dashboard/ — and step 6, ~25 lines below, tells the user
Dashboard:
http://127.0.0.1:<port>/dashboard/— the four billed token tiers are where the cache
effect is visible.
which is a 404. That is the same defect as the "dead dashboard link — it advertised /dashboard/
and never passed --dashboard" item in the PR description.
It also does not self-heal, and that is the part worth fixing rather than documenting:
start-proxy.sh is idempotent on /healthz (line 51), so once this hand-started proxy is up the
hook will never replace it — and with --idle-exit 24h it holds the port for a day. The user's
dashboard is broken for that whole window with no signal as to why.
check-proxy.sh:48 has the same omission in the command it prints to the user.
Simplest fix: make both places invoke start-proxy.sh rather than restating the command line —
the script already self-gates on ANTHROPIC_BASE_URL, which step 5 correctly explains is unset in
the installing session, so it would need an override (CONTEXT_GURU_FORCE=1, or just documenting
ANTHROPIC_BASE_URL=http://127.0.0.1:$PORT/anthropic start-proxy.sh). Failing that, add the two
dashboard flags in both places.
Credit where due: step 5's explanation of why the hook does nothing in the installing session is
exactly right, and it is the kind of thing that usually gets left out.
| import glob | ||
|
|
||
| try: | ||
| found = sorted(glob.glob(f"{path}.context-guru-backup-*"), key=os.path.getmtime) |
There was a problem hiding this comment.
Low — glob.glob treats [, ? and * in the path as pattern syntax, so pruning silently does
nothing for a settings file under a directory containing them (~/projects/foo[1]/.claude/...).
Backups then accumulate forever, which is the thing KEEP_BACKUPS exists to stop, and the failure
is invisible because prune_backups is best-effort by design.
glob.glob(glob.escape(path) + ".context-guru-backup-*") fixes it.
| t.Helper() | ||
| p, err := exec.LookPath(name) | ||
| if err != nil { | ||
| t.Skipf("%s not available: %v", name, err) |
There was a problem hiding this comment.
Low, process rather than code: requireTool uses t.Skipf, so on a runner without python3 or
bash every test in this file skips and go test is green. ubuntu-latest has both today, so
this is latent — but the whole point of the package header is that these scripts get the coverage
their blast radius warrants, and a skip is indistinguishable from a pass in CI output.
For python3 and bash specifically I'd t.Fatalf instead: they are guaranteed on the CI image,
so an absence means the image changed and you want to hear about it. Keep t.Skipf for anything
genuinely optional.
| [ -n "$found" ] || die "binary_not_in_tarball: no $BIN anywhere in $TARBALL" | ||
|
|
||
| mkdir -p "$DEST" || die "cannot_create_$DEST" | ||
| install -m 755 "$found" "$DEST/$BIN" || die "install_failed_to_$DEST" |
There was a problem hiding this comment.
Low, and unverified — please confirm before acting on it, I could not. On Linux, opening a
currently-executing binary with O_TRUNC fails with ETXTBSY, and coreutils install truncates
rather than replacing the inode. If that holds here, the upgrade path (CONTEXT_GURU_UPGRADE=1)
fails with install_failed_to_$DEST in exactly the situation upgrades happen in — a proxy that is
running, which with --idle-exit 24h is most of the time. macOS permits the write, so this would
not reproduce on a dev box.
I could not test it: no Go toolchain and no Linux here.
If it does hold, install -m 755 "$found" "$DEST/$BIN.new" && mv -f "$DEST/$BIN.new" "$DEST/$BIN"
avoids it — rename swaps the directory entry and leaves the running image alone — and is also
atomic for anyone starting the proxy concurrently.
| # setsid detaches the proxy from this hook's process group so it survives the hook returning | ||
| # and is not killed with the session's process tree. --idle-exit is what eventually reaps it. | ||
| STARTER=(setsid) | ||
| command -v setsid >/dev/null 2>&1 || STARTER=(nohup) # macOS has no setsid |
There was a problem hiding this comment.
Low — the comment above claims the starter "detaches the proxy from this hook's process group so
it survives the hook returning and is not killed with the session's process tree." setsid does
that; the macOS fallback nohup does not — it only makes the child ignore SIGHUP, leaving it in
the caller's process group. So on macOS, which is presumably where most of this was developed, a
signal delivered to the session's process group still reaches the proxy.
disown at line 93 does not help either — that is bash job-table bookkeeping, not a process-group
change.
In practice nohup + disown usually survives, so I would not change the mechanism on my say-so.
But the comment states a property the fallback does not provide, and the next person to read it
will trust it. Either say "setsid where available; nohup only blocks SIGHUP", or get the real
property portably with (trap '' HUP; exec "$BIN" ... &) in a subshell.
| ## Quickstart (60 seconds) | ||
|
|
||
| Download a release binary — statically linked, **no Go and no C compiler needed** — or build | ||
| **Claude Code users — two commands, no toolchain, and no API key needed on a Pro/Max |
There was a problem hiding this comment.
Nit — "two commands" sits directly above a three-command block, and docs/how-to/install-plugin.md
spells out why it is three ("The first two are once per machine. The third is once per repo").
marketplace.json compresses it further to "in one command."
Since the split is the genuinely reassuring part of the design — the machine-wide step and the
per-repo routing decision are separate on purpose, and the routing one is the one with blast
radius — I would say so rather than round the count: "two commands to install, one per repo to
route."
…ured before and after
Thirteen findings. Two were blocking, and both were on the surfaces I had named as the highest-risk
ones in the review request, which is the part worth noting: naming them was not the same as having
tested them.
## The hook took 19s and its timeout was 10s — on the path it exists for
check-proxy.sh exists for one case: routing configured, nothing listening, so a prompt produces
NOTHING — no error, no readable timeout, the session just hangs. The hook replaces that silence with
an explanation. But the explanation is printed LAST, after an attempt to recover, and recovery
called start-proxy.sh with its default 15s health wait.
Measured on that exact path: 19s, against a UserPromptSubmit timeout of 10s. So the hook was killed
before it printed anything, and the user got the identical symptom the hook was added to remove —
now with a hook that appeared to have handled it.
The shared cause was in start-proxy.sh: the health wait was `for _ in $(seq 1 60)` with
`--max-time 2`, commented "up to ~15s". That is only true when the port is REFUSED, where curl
returns instantly. Against a socket that ACCEPTS and never answers — a hung proxy, a half-open
socket, an unrelated service on the port — each probe burns its full timeout: measured 2046ms, so
~122s against SessionStart's 60s. The hook was killed there too, so the failure report at the
bottom of that script (log path, status pointer, last lines of the log) never ran. A hung port is
one of the likeliest reasons to need that report and was the one case that never produced it.
* the wait now budgets on WALL CLOCK, honouring CONTEXT_GURU_HEALTH_BUDGET (default 15s)
* check-proxy.sh asks for 5s, since it runs from a much tighter hook
* hooks.json allows 30s, so even the expensive shapes finish with room
Measured after: 5s on the dead-proxy path, 11.1s on the worst shape (a port that accepts and
stalls, where all three probes cost their full timeout), and the hung-port start path is 17s with
the failure report actually printing.
I did NOT take the other half of the suggested fix — printing the note before attempting recovery.
The note is guaranteed to survive a kill that way, but this hook's common case is a silent
successful recovery after an --idle-exit between two prompts, and a paragraph about a dead proxy on
every one of those is noise on a path that is working. The budget is what makes the ordering safe,
and TestCheckHookFinishesInsideItsOwnTimeout reads the timeout out of hooks.json so the two cannot
drift apart again.
## `remove` with no --url deleted anybody's base URL and reported success
The conflict check read `if args.url and current != args.url and not is_ours(...)`. So omitting
--url — an invocation the module docstring advertises as supported — skipped the check entirely.
Measured against a corporate gateway with no context-guru record: `result=removed`, `restored=`
empty, exit 0. The user's gateway silently gone, reported as success, in ~/.claude/settings.json.
What made it blocking was not the branch but where the safety lived: uninstall/SKILL.md passes
--url, and its prose said that flag "is what keeps this safe" — so the property protecting the
user's configuration depended on a model remembering a flag in a prompt. This script is the
deterministic half precisely so that it does not have to be.
The check is unconditional now: with no --url, is_ours() decides alone, i.e. remove only what we
recorded installing. The escape hatch for an unrecorded value is to name it with --url, which the
skill already does. The prose no longer claims a safety property it does not provide.
## Two of the review's findings were false, and I tested rather than argued
Both were flagged as unverified — no Go toolchain, macOS only. Go lives on a Linux box here, so:
* `go install` DOES create a missing GOBIN (Linux, Go 1.26.4), so the fallback needs no mkdir
* `install -m 755` over a RUNNING binary SUCCEEDS on Linux — no ETXTBSY, because coreutils
`install` unlinks the destination first
I still install-then-rename, but for the real reason, which the false premise was sitting next to:
that unlink is a window in which $DEST/$BIN does not exist, and a SessionStart hook firing in
another project during an upgrade would report "the proxy binary is not on PATH". rename(2) swaps
the directory entry in one step.
Also declined as written: `O_EXCL` on the fixed temp name `.context-guru-tmp`. It closes the mode
window, but a leftover temp file from a crash then makes every later save fail until somebody
deletes it by hand — trading a mode window for a permanent lockout of the file this script exists
to edit. mkstemp has neither problem.
## The rest
* install/SKILL.md's manual-start command and check-proxy.sh's printed command both omitted
--dashboard/--dashboard-db, reintroducing the dead /dashboard/ 404 this PR lists as fixed — and
because start-proxy.sh is idempotent on /healthz, the hook would never replace such a proxy, so
it stayed broken for the whole 24h idle-exit window
* install.sh's go-install fallback returned before the shared tail, so `on_path` was never
emitted; ~/.local/bin frequently is not on PATH, and the user found out from a hook in a LATER
session with nothing tying it to the install. Both paths call report_path now
* `fallback=go_install` renamed to `fallback=go_install_attempted`: it is printed before the
build, so it appeared even when the build failed
* settings.py: the temp file was created 0644 and corrected only after the whole file was
written — a window where a replacement for a 0600 settings file holding ANTHROPIC_AUTH_TOKEN is
world-readable. mkstemp creates it 0600
* prune_backups used glob.glob on the PATH, so under a directory like `foo[1]` it matched nothing
and pruning silently never happened. glob.escape
* both port gates were PREFIX matches, so PORT=8787 also matched 87871 — the hook would start our
proxy under a user routed to a different local proxy there. The trailing "/" is always present,
since every URL we write ends in /anthropic
* the setsid/nohup comment credited both with detaching the process group. Only setsid does that;
nohup blocks SIGHUP and leaves the child in this process group, and `disown` is bash job-table
bookkeeping. The mechanism works, so the comment was the bug — corrected rather than replaced
* README said "two commands" above a three-command block, and marketplace.json compressed it to
"one command". The split is the reassuring part of the design — install once, route per repo —
so both now say that instead of rounding the count
## Verification
Six properties for check-proxy.sh, which was the only one of the six blocking fixes from the
previous review with no test at all — and its absence is exactly what hid the 19s defect. The hook
timeout is READ FROM hooks.json rather than retyped, and the timing assertion measures the
accept-and-stall shape rather than the cheap refused-port one: on the cheap shape the assertion
would have passed at almost any timeout, which is the opposite of what it is for.
Plus: the `remove`-without---url regression (the file must come back BYTE-IDENTICAL, since a
refusal that rewrites the file has already done the thing it refused), the fallback's on_path
report, glob-escaped pruning, and a prefix-port case in both silence tests.
requireTool now fails instead of skipping for python3 and bash. A skip and a pass are
indistinguishable in CI output, so on a runner without either, every test in this file skipped and
`go test` was green.
Nine mutations, each proven to have LANDED in the source before its result counted, each failing
its named test, each file restored byte-identical afterwards, and the full suite green again after
the sweep:
check-proxy asks for no health budget -> TestCheckHookFinishesInsideItsOwnTimeout
hooks.json timeout back to 10s -> TestCheckHookFinishesInsideItsOwnTimeout
printed command loses the dashboard flags -> TestCheckHookFinishesInsideItsOwnTimeout
remove's check conditional on --url again -> TestUninstallRefusesAForeignBaseURLEvenWithNoURLGiven
health wait back to an iteration count -> TestStartHookBudgetsOnWallClockNotIterations
check-proxy port gate back to a prefix match -> TestCheckHookIsSilentWhereRoutingIsNotConfigured
start-proxy port gate back to a prefix match -> TestHookIsSilentAndInertWhereRoutingIsNotConfigured
fallback exits without the PATH report -> TestInstallReportsPATHFromTheSourceFallbackToo
prune_backups without glob.escape -> TestBackupPruningSurvivesAGlobbyPath
One gap stated rather than papered over: the 0600 temp-file window is fixed but not tested. It is a
window between two syscalls, and a test asserting on it would be asserting on a race it cannot
observe. Verified by reading; TestSettingsPreservesFileMode still covers the resulting mode.
`go test ./...` and `gofmt -l` clean; docs pass the anchor check.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
|
All thirteen. Two of them were blocking, both on the two surfaces I asked you to go deep on — and the fact that I named those surfaces as the highest-risk ones and had still not tested them is the honest summary of this round. Pushed as The 19s hook with a 10s timeoutReproduced exactly, and it is worse than a slow hook: Your diagnosis of the shared cause was right, and I confirmed the mechanism separately: the Wall-clock budget via I took one half of your fix and not the other. The note stays last. Printing first guarantees it survives a kill, but the common case here is a silent successful recovery after an idle-exit between two prompts, and a paragraph about a dead proxy on every one of those is noise on a working path — it also lands in the model's context for that turn. The budget is what makes the ordering safe. If you still think the guarantee beats the noise, say so and I will flip it; it is a judgement call, not a disagreement about the facts.
|
amiddavid
left a comment
There was a problem hiding this comment.
Re-review of 6577320 — all thirteen verified fixed; two new findings, one of them mine to own
I re-ran the measurements rather than reading the diff. Everything I filed is genuinely fixed, and
the three places you did not do what I suggested are all better than what I suggested. Detail below,
then two new items — the first of which I should have caught in round one and did not.
The two blocking findings, measured before and after
| Path | Before | After | Bound |
|---|---|---|---|
check-proxy.sh, dead proxy |
18s | 5s | 30s timeout |
check-proxy.sh, accept-and-stall |
— | 5s | 30s timeout |
start-proxy.sh, accept-and-stall |
~138s | 16s | 60s timeout |
The diagnostic is delivered in every case now, including the stall shape. I measure the stall
shape at 5s rather than your 11.1s — probably harness differences, and in the safe direction either
way; the assertion has margin regardless.
settings.py remove verified on three shapes:
- no
--url, foreign gateway, no record →result=conflict, exit 2, file byte-identical ✓ - no
--url, our own recorded URL → stillresult=removed✓ (the fix did not overshoot into
makingremoveuseless without the flag) --urlmismatching a foreign URL → stillresult=conflict, exit 2 ✓
Your three pushbacks: all accepted, and one correction to my own record
1. My two unverified items were wrong, and you tested what I could not. go install creating a
missing GOBIN, and install not hitting ETXTBSY because coreutils unlinks first — I flagged
both as needing confirmation precisely because I had no Linux box and no toolchain, and you went and
got the answer. Thank you for testing rather than deferring. The install-then-mv you kept is
better justified than my version of it: the unlink window is real, rename(2) closes it, and the
comment in the script now records the corrected reasoning instead of my wrong premise, which is the
right thing to leave behind for the next reader.
2. Diagnostic staying last: you are right, and TestCheckHookRecoversSilently is what settles
it. My reasoning was "the kill-survival guarantee is unconditional, ordering makes it free." Yours
is that the guarantee is only worth anything on the failure path, while the common path after an
idle-exit is a silent successful recovery — and a dead-proxy paragraph on every one of those lands
in the model's context on a turn where nothing is wrong. That cost is certain and recurring; mine
was hypothetical once the budget exists. Budget first, ordering second is the correct dependency,
and the test now pins the silence so nobody "helpfully" moves the note up later. No flip needed.
3. mkstemp over O_EXCL: correct, and I missed the failure mode. A leftover
.context-guru-tmp from a crash or a full disk would have made every subsequent save fail
permanently — turning a brief mode window into a permanent lockout of the one file this script
exists to edit, recoverable only by hand. Trading a transient defect for a persistent one is
strictly worse. The except BaseException unlink is a good addition I had not asked for.
I also accept the untested-window gap as stated: a race between two syscalls is not honestly
assertable, and "verified by reading" is the right label rather than inventing a test that would
pass either way.
On the test I asked you to check
TestCheckHookFinishesInsideItsOwnTimeout is right, and the change you describe is the one that
matters: measuring the accept-and-stall shape instead of a free port is what makes it sensitive to
anything. Reading the bound from hooks.json via hookTimeout and asserting against limit/2
rather than limit is better than what I suggested — the half-margin means it fails on a real
regression instead of only on a loaded runner, and it cannot drift from the config. Asserting
--dashboard appears in the printed recovery command is a nice touch: it pins finding 5 in the same
place, so the 404 cannot come back through the text.
TestCheckHookIsSilentWhereRoutingIsNotConfigured covering 127.0.0.1:87871 as a prefix of 8787
pins the gate fix at the level it actually broke. That is the table-driven version I would have
written.
Nothing further on the other eleven — report_path shared by both install paths, glob.escape,
t.Fatalf for python3/bash, the nohup comment, and the "two commands to install, one per repo
to route" wording all check out.
Merge order
Agreed and unchanged: release.yaml via workflow_dispatch → v0.1.0 → this. I am not treating
"has never run end to end" as a finding, for the reason you gave — it is gated on a tag existing,
not on anything in this diff. Worth saying plainly in the PR when it merges, so the first person to
run /context-guru:install against a real release knows they are the first.
Two new comments below. The first is a genuine miss on my part from round one — same defect class
as #141's finding 1, which you noted is not hypothetical here. It is unchanged by this commit, so it
is not a regression; I simply did not see it. The second is a small test-hygiene item on new code.
| fi | ||
|
|
||
| if [ -n "$pid" ]; then | ||
| kill "$pid" && rm -f "$PIDFILE" |
There was a problem hiding this comment.
Medium, and my miss from round one — the ownership check is printed after the kill it is
supposed to gate.
This is not a regression from 6577320; it has been here since 0abd995 and I did not catch it. Same
defect class as #141's finding 1, which is why I want it fixed rather than left as prose ordering.
Read the skill top to bottom, which is how it gets executed:
- lines 74-78 —
if [ -n "$pid" ]; then kill "$pid" && rm -f "$PIDFILE" - line 81 — "Before killing anything, confirm the PID is ours — the port may be held by
something else entirely, and this step must not kill a stranger's process" - line 85 — the
ps -p "$pid" -o command= | grep -q context-guru-proxycheck
The instruction says "before killing anything" and sits after the block that kills. An agent working
through this file in order has already sent the signal by the time it reads the guard, and the check
then reports on a process that is already dying. The safety property is stated but structurally
unreachable.
It matters most on the path that produces an untrustworthy PID in the first place. The pidfile is
ours by construction, but the fallback at lines 66-72 is lsof/ss on "whoever holds the port" —
which is explicitly there to cover a stale pidfile and a hand-started proxy, i.e. exactly
the cases where the PID may belong to something else. A recycled PID from a stale pidfile passes
kill -0 too.
The fix is to make it one unskippable block rather than two blocks and a warning, so the ordering
cannot be got wrong by reading:
if [ -z "$pid" ]; then
echo "(nothing listening on ${PORT})"
elif ps -p "$pid" -o command= | grep -q context-guru-proxy; then
kill "$pid" && rm -f "$PIDFILE"
else
# The port is held by something that is not ours — a hand-started service, or a recycled PID
# from a stale pidfile. Report it and stop; do not kill it.
echo "NOT OURS — pid $pid holds ${PORT} and is not context-guru-proxy; left alone"
fiThen keep the liveness re-check at lines 91-93 as it is, since "did the kill work" is a separate
question from "is it ours".
One consequence worth stating in the skill while you are there: if the check says NOT OURS, step 1
has already removed the routing, so the user is safe and unblocked — the leftover is a port
conflict to report, not something to escalate on. That is the reassurance that stops the next reader
reaching for kill -9.
| if err != nil { | ||
| return | ||
| } | ||
| held = append(held, c) // hold it open, answer nothing |
There was a problem hiding this comment.
Low, new-code hygiene: held is appended here on the listener goroutine and iterated by the
t.Cleanup closure at line 812, which runs on the test goroutine. That is an unsynchronised
read/write of the same slice, and make cover runs go test -race — so if a connection is being
accepted while cleanup runs, this is a race-detector failure rather than a test failure, which is a
confusing thing to land on.
Registering t.Cleanup from inside the goroutine is also a hazard in its own right: t.Cleanup
called after the test has finished panics, and nothing here orders the goroutine's registration
before the test's end.
Cleanup order makes it narrow rather than safe — the held cleanup is registered second so it runs
first (LIFO), while the goroutine is still blocked in Accept and can still append. In practice
curl is done by then, so I would not expect this to fire often; "not often" is the bad frequency
for a race in CI.
Simplest fix is to drop held entirely — closing the listener is what ends the goroutine, and the
accepted connections die with the process at test end:
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close() // held open for the life of the goroutine, answers nothing
}
}()If you want the connections explicitly closed, register the cleanup on the test goroutine before
starting the listener goroutine and guard the slice with a mutex. Either way the property the helper
exists for — accept, never answer — is unaffected.
Worth saying: stallingPort is the right helper to have added. The shape it produces is what turned
both timeout tests from assertions that would pass at any value into ones that catch the defect.
There was a problem hiding this comment.
Correcting my own severity: this is not "low", it is the build-test failure on 6577320. It
fired on the first CI run, not rarely.
--- FAIL: TestCheckHookFinishesInsideItsOwnTimeout (11.14s)
FAIL github.com/rossoctl/context-guru/context-guru-plugin 20.047s
Three WARNING: DATA RACE reports, all the same shape — Read at ... by goroutine 65 inside
testing.go:1317 (that is tRunner running the registered cleanups) against Previous write at ... by goroutine 66, the accept loop. The frames are plugin_test.go:812, :813, :817 and :821:
the held iteration and c.Close() in the cleanup, versus the append and Accept in the
goroutine. Run: https://github.com/rossoctl/context-guru/actions/runs/33815232946/job/100845795572
I said "not often is the bad frequency for a race in CI" and then underrated it anyway — I reasoned
that curl would be finished by cleanup time, but this test holds three probes against the
stalling port, and ln.Close() runs after the held cleanup (LIFO), so the accept loop is still
live and writing while the cleanup reads. Sorry for filing it as a nit; the fix in my comment above
is unchanged and is what unblocks CI.
Two things worth taking from the failure beyond the fix.
Your 11.1s was right and my 5s was the outlier — the run measures 11.14s, so that was Linux/CI
versus my macOS box, and your figure is the one to design the margin against. That also means the
timing assertion passed: 11.14s against a limit/2 bound of 15s. The defect I filed on
hooks.json is fixed and the test proves it on the platform that matters; the only thing red here
is the helper's bookkeeping. Good news for the substance, and an argument for the half-margin you
chose over my limit — at 30s the bound is 15s and 11.14s fits, but it is not roomy, so I would
resist any future trimming of that timeout.
Second: this is test-only code. No production path touches held, so nothing about the plugin's
behaviour is implicated — worth stating in the commit message that fixes it, so the next reader of
the history does not go hunting for a proxy defect behind a red build-test.
Everything else in the run is green (DCO, build, purego, trivy), and this is the only failing
test in the only failing package.
…data race in my own test Two items from the re-review of 6577320. One is a real defect in the uninstall skill; the other is a race I introduced in TEST-ONLY code in the previous commit, which is what turned build-test red — no production path touches it, so a red build-test on 6577320 is not a proxy defect. ## uninstall sent the signal before the check that was supposed to gate it skills/uninstall/SKILL.md had `kill "$pid"` in one bash block, and "Before killing anything, confirm the PID is ours" as PROSE with its own snippet BELOW it. These skills are executed the way they read, top to bottom, so the signal was already sent by the time the guard was reached. The guard was decoration. It matters most on the lsof/ss fallback immediately above it, which exists precisely for a stale pidfile or a hand-started proxy — i.e. the cases where the PID may belong to something else entirely. `kill -0` does not help: a recycled PID satisfies it perfectly well. This is the same shape as the defect that had this skill killing the user's own Claude Code session: a destructive command whose safety condition lives somewhere the reader gets to afterwards. Now one if/elif/else, so the ordering cannot be got wrong by reading it: * no pid -> say so * pid is not ours -> print WHAT is on the port, signal nothing, and LEAVE THE PIDFILE ALONE (removing it would strand a proxy of ours still running under another pid) * pid is ours -> kill, then remove the pidfile Given an EXECUTING test rather than more careful prose, because this class has now recurred twice and reading is what missed it both times: TestUninstallDoesNotSignalAProcessThatIsNotOurs extracts the fenced block straight out of the skill and runs it against a stubbed `ps`. A stranger's process must not be signalled and its pidfile must survive; ours must be signalled. Mutation-checked: with the two-block ordering restored, the block sends SIGTERM to `/usr/bin/postgres`. ## The data race was mine, in the test helper, and -race is the only thing that sees it stallingPort registered its connection cleanup with t.Cleanup from INSIDE the listener goroutine, so the cleanup closure read `held` on the test goroutine while the accept loop appended to it on another. Three WARNING: DATA RACE, all on that slice. Fixed by ownership rather than a mutex: the goroutine that appends is the only thing that reads, and it closes the connections in its own deferred func. The test goroutine only closes the listener, which is what makes Accept return and lets that teardown run. Worth recording why I did not catch it: I ran the suite repeatedly on a box without -race, where this is invisible — a real race with no symptom until the scheduler interleaves badly. CI runs `go test -race` via `make cover`, so CI was the first thing that could see it. New concurrent test code gets -race locally from now on; the full suite is verified under it here. Also worth recording, because it is the vacuous-pass shape again: the first version of the uninstall test stubbed `kill` as a FILE ON PATH. `kill` is a bash builtin, so the stub was never consulted — the block's `kill -0` liveness probe failed for a pid that does not exist, it fell through to the socket-owner lookup, and the run exercised none of the branch under test. It only failed loudly because one of the two cases asserts a POSITIVE outcome (kill WAS invoked); the negative case alone would have passed for the wrong reason. A shell function overrides the builtin, so the preamble defines one. ## Two notes from the reviewer's own measurements, kept because they inform later changes * their 5s vs my 11.1s on the accept-and-stall shape is macOS vs Linux/CI, and the CI run measured 11.14s — so the Linux number is the one to design the margin against * that leaves 11.14s against a limit/2 bound of 15s: real margin, but not roomy. The 30s UserPromptSubmit timeout should not be trimmed later without re-measuring on Linux `go test -race ./...` and `gofmt -l` clean. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
|
Both done, pushed as The race — yours to flag, mine to have causedYou were right to lead with it, and right that it is test-only: nothing in the proxy touches I had it fixed from the same CI log before your message arrived, and the diagnosis matches: Why I did not catch it is the part worth recording. I ran that suite repeatedly on a box without On underrating it as "low hygiene": I would not spend much on that. You filed the right observation with a wrong severity, and the severity was recoverable in one CI run. Filing it at all is what made the fix a five-minute job. uninstall killing before the checkReal, and the diagnosis is exact — the guard was decoration, because these files execute the way they read. Your point that it bites hardest on the Collapsed into one Given an executing test rather than better prose, because this class has now recurred twice in this PR's lineage and reading is what missed it both times. That test has a process note of its own, and it is the same trap as the race: my first version stubbed Your measurementsTaken, and both recorded in the commit body rather than just agreed to here:
Thanks for verifying the two blocking fixes by measurement rather than by reading the diff — the three Merge order unchanged: |
amiddavid
left a comment
There was a problem hiding this comment.
Re-review of 82b8f6f — both items fixed and independently verified; no blocking findings left
The uninstall ordering fix, verified by re-running it rather than reading it
I extracted the fenced block from uninstall/SKILL.md myself and ran it against a stubbed ps, in
both shapes and then with your old two-block ordering restored:
| Block | ps reports |
signalled | pidfile survives | said NOT OURS |
|---|---|---|---|---|
| 82b8f6f | /usr/bin/postgres -D /var/lib/postgres |
no | yes | yes |
| 82b8f6f | context-guru-proxy --listen 127.0.0.1:8787 |
yes | no (removed, correctly) | no |
| mutation: old two-block order | /usr/bin/postgres … |
yes | no | yes |
So the mutation does signal postgres, and the fix does not. Your report matches exactly, and the
test is sensitive to the defect rather than to its surroundings.
The pidfile surviving on NOT OURS is a better call than what I proposed, and I had not thought it
through. I only said "do not remove it"; you gave the reason that makes it load-bearing — a proxy of
ours may still be running under a different pid, and "refuse to touch this process" plus "throw away
our own handle" combine into a proxy nobody can stop by the documented route. Deleting the handle is
the more expensive half of that pair.
skillBlock is the right shape for testing prose that gets executed, and it fails on zero or more
than one match rather than silently taking the first — which is the failure mode that would otherwise
make it test the wrong block after an edit. I confirmed exactly one of the three bash blocks in that
skill contains the needle.
The race, fixed by ownership
Correct, and better than the mutex I offered as the alternative: held is now touched only by the
listener goroutine and closed by its own defer, with t.Cleanup(ln.Close) registered on the test
goroutine as the thing that unblocks Accept. No shared state, so nothing to synchronise — and
t.Cleanup is no longer called from a non-test goroutine, which was the second hazard in the same
five lines. Noting in the commit that no production path touches stallingPort is the right thing to
leave in the history.
Agreed on the process point, and it is the more transferable lesson: a non-race run cannot see this
class at all, so -race locally before pushing is the cheap version of what CI told you.
The trap you flagged — I went looking, and there are exactly two instances left
Thank you for naming the shape; it made this a mechanical search rather than a guess. Your
kill-as-a-file version is a textbook vacuous pass, and the reason it surfaced is exactly what you
said: the positive row asserted a positive outcome, so the stub had to actually be consulted for
the test to go green. That is the property worth generalising.
By that standard the stub-based tests come out clean. TestInstallRefusesAnUnverifiedDownload is
refusal-only on its face, but it asserts checksum_unavailable — a string only reachable if the curl
stub successfully served the tarball first, since real curl would have produced download_failed
instead. So the stub is proven consulted by the assertion itself.
TestInstallReportsPATHFromTheSourceFallbackToo asserts built_from=source and covers both
on_path values, so the go stub is likewise pinned.
Two tests remain in the shape you told me to distrust, and I filed the detail on one of them below.
Neither is wrong today — both are saved by a positive control that lives in a different test — and
that is the fragility worth one line each.
CI
DCO, build, purego, trivy green on 82b8f6f; build-test was still running when I posted, and
I am watching it. Merge order unchanged and still the real gate: release.yaml via
workflow_dispatch → v0.1.0 → this.
Nothing here blocks. From my side this is ready once build-test is green.
| // more here: this hook runs on EVERY PROMPT in every project on the machine, not once per session. | ||
| // Anything it prints lands in the model's context for that turn, so a regression is noise on every | ||
| // turn the user takes, in projects that have nothing to do with context-guru. | ||
| func TestCheckHookIsSilentWhereRoutingIsNotConfigured(t *testing.T) { |
There was a problem hiding this comment.
Following up on the trap you flagged — this is one of the two tests still in that shape, and the
only reason I am filing it is that you asked me to look for it.
Every assertion here is an absence: exit 0, and empty output. Nothing in the test requires the
script to have done anything, so it cannot distinguish "the gate correctly declined" from "the
script exited before reaching the gate." Demonstrated by running its four rows against a
check-proxy.sh gutted to a bare exit 0:
all four rows pass: yes (exit 0 + silence are the only assertions)
TestHookIsSilentAndInertWhereRoutingIsNotConfigured at line 291 is the same shape — the sentinel's
absence plus silence, with no in-test evidence the sentinel mechanism works at all.
Both are saved today, but from a distance: the positive control for this one is
TestCheckHookFinishesInsideItsOwnTimeout (routed → it speaks), and for line 291 it is
TestHookStartsTheProxyAndWaitsForHealthz (routed → the sentinel appears). That is real coverage, so
neither test is wrong. The fragility is that the control is somewhere else: a future change that
skips, renames or narrows the positive test takes the meaning out of the silence test without
touching it or making it fail, and a silence test that has quietly stopped proving anything is the
exact failure you just spent a round on.
Cheapest fix is to make the control local — one more row in each table asserting the routed case
does act:
{"routed to our port", "http://127.0.0.1:8787/anthropic", wantSilent: false},with the assertion inverted for that row (non-empty output here; sentinel present at line 291). Then
the table itself proves the mechanism is live, and the silence rows mean something on their own.
Low priority and entirely your call — the coverage exists either way. It is the "would this still
fail if the thing under test were removed?" question, which is the same question your kill stub
answered the hard way.
|
|
Addendum — I ran the grep I suggested, and the blast radius is exactly one test. There are seven
The remaining five assert nothing from that block within their following lines. So the fix is genuinely one test's wait, not a pattern to unpick across the file — which makes the Worth noting for whoever picks it up: |
The last finding from the #160 review, and it is the trap I had just flagged to the reviewer, pointed back at my own tests. TestCheckHookIsSilentWhereRoutingIsNotConfigured and TestHookIsSilentAndInertWhereRoutingIsNotConfigured assert only ABSENCES: no output, no proxy started, exit 0. So neither could distinguish "the gate declined" from "the script exited before it ever reached the gate". Gut either hook to a bare `exit 0` and every row passed. A positive control did exist — TestHookStartsTheProxyAndWaitsForHealthz for the starter — but a control living in a DIFFERENT test is one these tests cannot rely on: narrow or skip that test later and the meaning drains out of these without anything failing. So each table gets one more row asserting the routed case DOES act: * the starter must launch the stand-in and report the failure when it never answers /healthz * the checker must speak about a dead proxy Both use a port of their own from freePort rather than the literal 8787, so neither probes nor starts anything against a developer's real proxy, and the routed starter row sets CONTEXT_GURU_HEALTH_BUDGET=1 so the control costs a second rather than fifteen. Verified the way the reviewer demonstrated the gap: with each script replaced by `#!/usr/bin/env bash\nexit 0`, the corresponding test now FAILS on its control row (it passed before), and both files restored byte-identical afterwards. Note on the red build-test this commit does not address: TestArriveCancelsAndReports in ./proxy is failing, and it is not this PR — #160 touches no file under proxy/, and the defect is on main (keepalive.go, 50e3966, 2026-08-22, well before this branch). waitPings polls k.pings, incremented at keepalive.go:899 immediately after k.send() returns, but the value the test then asserts — e.refreshed — is not written until line 989, after the error check, the event construction and ev.Price(). The wait therefore returns before the state under test exists, and `refreshed = 0` is precisely the pre-write value. I could not reproduce it locally (60 runs under -race, then 200 at -cpu=1, all green), so rather than assert the mechanism from reading I widened the suspected window: a 200ms sleep inserted between those two points reproduces `refreshed = 0, want 48576` exactly. keepalive.go restored byte-identical; the probe is not part of this branch. Filed separately rather than absorbed here, so a plugin PR does not carry an unrelated proxy change. Worth recording one property of it: -race cannot catch that one — both accesses are properly mutex guarded, so the bug is ordering, not unsynchronised memory. Exactly inverted from the stallingPort race in the previous commit, which -race caught immediately and no plain run could see. `go test -race ./...` and `gofmt -l` clean. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Review complete — all findings resolved, CI green at
|
The Claude Code plugin:
/plugin marketplace add rossoctl/context-guru→/plugin install→/context-guru:install.#141 has merged, so this is no longer stacked — it is two commits on
main, 17 files, and the diff is the plugin and its docs. Three skills over four scripts and two hooks.Default routing scope is
.claude/settings.local.json: one repo, gitignored,--globalan explicit opt-in. A base URL pointing atlocalhostbreaks Claude Code wherever it is routed when the proxy is down, so blast radius is the thing the default optimises for.Read this first: it cannot work yet, and that is not a defect in the diff
install.shresolves a GitHub release, and no tag has published assets. Onmaintoday the third command ends atno_release_found.So the merge order matters more than the code review: run
release.yamlviaworkflow_dispatch(snapshot, publishes nothing — it has never executed, and #141 landedwrap_in_directory: truewhose archive layout only a real goreleaser run confirms), cutv0.1.0, then merge this. Landing it first puts a README onmaintelling strangers to run three commands, the last of which fails.The plugin has therefore never run end to end in a real session. Everything below was verified by unit tests, by driving the scripts directly, and by reading — not by installing it.
What the review of the combined PR found here, and what changed
All six blocking findings were in this half:
uninstallkilled the user's own session and left the proxy runningargvvia--listen; no pattern matchinginstall.shcould not install, and its documentedgo installfallback was absentdownload_failedas a reported outcome and akey=valuecontract curl no longer breaksstatuscannot diagnose itUserPromptSubmithook — the only thing that runs without a model callcacheadvertisedcontext_guru_expandO_EXCL+ microsecond stamps; the replaced value is recorded and restoredOn finding 1 the mechanism is worth stating, because the fix follows from it: the port was passed through
LISTEN_ADDRin the environment, sopkill -f "context-guru-proxy.*$PORT"matched no proxy — and did match the shell running it, which is the session's own Bash tool. The narrow pattern the skill offered as the safe option was the one that bit. So the fix is a handle, not a better pattern.On
is_oursI did not take the review's suggestion. A URL-shape rule ((127.0.0.1|localhost|[::1]):\d+/anthropic) makes litellm's default read as ours, so uninstall would delete somebody else's routing — the existing test failed the moment I tried it.addnow records the URL it wrote and later runs read that record; anything unrecorded stays a conflict, for add and remove alike.One change since that review
install.shfinds the binary in the tarball rather than assuming the archive root. #141 setwrap_in_directory: true— deliberately, because a flat archive plus the documentedtar xzfwith no-Coverwrites theREADME.mdandLICENSEof whatever directory the user is standing in, which for an evaluator is their own project. Without this change the first install anyone attempted would have failed withbinary_not_in_tarball, reading as a broken release rather than a moved file. Found by checking #141's packaging change against this script, not by waiting for it to fail.Verification
The shell and Python helpers are tested from Go (
context-guru-plugin/plugin_test.go) sogo test ./...and CI cover them. Seven mutations, each proven to have landed in the source before its result counted, each failing its named test and passing when restored — full output in the commit body. Coverage includes: settings merge/conflict/removal/backup/mode/symlink behaviour, the hook's silence in unrouted projects, its idempotence, its non-failure when the binary is missing, and its wait for/healthz.One process note kept in the open: my first attempt at the backup mutation reverted only the timestamp granularity and left the
O_EXCLloop, so the filename was still unique and the test passed — proving nothing. Reverting half a fix is its own route to a vacuous result.Worth a reviewer's attention
SessionStarthook runs in every project the user has, because the plugin installs at user scope. It self-gates on$ANTHROPIC_BASE_URLnaming its own port — matching the port, not "localhost", so somebody routing to litellm on 4000 is not hijacked. It is synchronous (closes the race with the session's first request), idempotent (SessionStartalso fires on clear/compact/resume/fork), and exits 0 on every path: a hook that fails here is a plugin that can brick every session on the machine.settings.pyedits the user's realsettings.json. It merges exactly one key, backs the file up first (and prunes to 10), refuses to overwrite a base URL it did not write, restores what it replaced on removal, preserves mode, and follows symlinks. It refuses rather than rewrites a file it cannot parse.statuschecks the commonest one at runtime: outside a git repository there is no environment snapshot, socachesplitskips and the saving is exactly zero.statusalso no longer readsacted: 0/savings_pct: 0as a verdict — those count content removal, and this component relocates a breakpoint.🤖 Generated with Claude Code