Live-tested Codex 0.149 through the proxy and found four defects:
- proxy: custom_tool_call_output.output is an ARRAY of input_text parts on
Codex 0.149+. The Responses extractor accepted only the string form, so
every real Codex tool turn had an empty live zone and the subscription
route compressed nothing (75-116 KB requests, zero savings). The captured
real request now compresses; regression test pins the array shape.
- proxy: the /chatgpt route never recorded compression eligibility, so the
CLI told every Codex subscription user "your agent never reached the
compression layer" even when routing worked.
- cli: codexNativeConfig ran the legacy table strippers before removing its
own marker blocks; the stripper swallowed the tables end marker, so the
block enable had just written failed its own re-parse as "corrupted" on
the next wrap and doctor reported drift forever. Markers come off first.
- cli: nativeHooksDocument matched caveman hook entries by exact command
string, so a binary path change (upgrade, moved install) appended a new
hook set per event instead of replacing it. Match on managed identity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2f7Lf7G9erA7YNn5SXPEj
A proxy caveman just started binds its port before its run state is
readable, so the immediate probe could report a foreign owner on slower
runners. Poll briefly after start and restart. Test fixes for CI: pin
XDG_CONFIG_HOME for in-process Kilo assertions, import the profile scope
checker through a file URL so Windows can load it, and skip the sh-stub
gh reporter tests on Windows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-ups to #945: a listener caveman did not start this run and
cannot prove it owns (foreign process, or a proxy binary too old to
answer) now launches direct, and the direct-launch strip removes the
agent-attributed /w/<agent> route as well as the bare gateway URL, so a
leaked outer route never reaches the child. Qwen's ACP and extension
listing guards accept yargs camelCase spellings. Proxy-aware fetch no
longer sets an IP literal as TLS servername (rejected on Node 26). The
conformance-pin test fixture follows the pinned-upstream-binary job.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A foreign listener on 127.0.0.1:8787 used to get the banner but still
receive the routed launch, which would send the operator's provider
keys to a process caveman does not own (#945). Treat it like a gate
mismatch and fall back to a direct launch.
Also: Claude Code refuses Remote Control behind any non-first-party
ANTHROPIC_BASE_URL, so `caveman claude remote-control` launches direct
and the limitation is documented (#947); caveman-compress reads the
first text block instead of content[0] so a leading tool_use block no
longer crashes after a paid call (closes#932's report); hook checksum
manifest regenerated after #794.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Bare start never stamps global recovery; the banner now says which
sessions compress (those whose agent carries caveman_retrieve) and the
status mcp-missing row keys off any installed MCP marker.
Qwen: refuse extension, ACP, session-restore, control-command, and
enforced-auth surfaces that could reactivate a provider route outside
the locked session; replace modelProviders/providerProtocol wholesale
in the routed profile; keep web search and workflows off via env.
Kilo: daemon and console launch direct since they outlive the wrap.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(proxy): skip vendor trees in repo intel
BM25 was indexing .pixi/envs and other dependency
dirs, then injecting those file:line guesses every
turn. Exclude known vendor trees and drop the path
block when no local scout is configured.
Closes#899
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): detect dep trees by content, not name
Name denylist missed conda envs, undotted pixi, and
virtualenvs under any name, while dropping real
first-party build/ and vendor/ dirs.
- listFiles prefers `git ls-files --exclude-standard`,
so the project's own .gitignore defines vendored
- walk fallback identifies environments by contents
(conda-meta, pyvenv.cfg, site-packages child),
catching the interpreter stdlib they carry
- ambiguous build/dist/target/vendor need a sibling
manifest before exclusion
- symlink and junction dirs are never followed
Refs #899
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): treat the indexed repo as untrusted input
Second review found the git listing both unsafe and
incomplete.
- gitCommand pins core.fsmonitor and other exec
vectors off and strips GIT_*; fsmonitor runs during
an index read, so `ls-files` in a hostile clone ran
attacker code at session start
- git-listed paths now run the same content filter as
the walk, so an un-ignored conda prefix or committed
vendor tree no longer rides in on git's say-so
- an empty-but-successful listing means the cwd is
ignored by an enclosing repo; fall back to the walk
instead of reporting a complete map
- Direct matches the file name, not the whole path:
a term like "src" marked every file beneath it
- ListingBasis discloses which path ran, hashed in
Refs #899
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): log declined evidence, document invariants
A declined turn stays silent to the model but now
records repository_evidence_declined_no_direct_match,
so weak evidence is distinguishable from a broken or
disabled mechanism.
Adds a positive control to the suppression test and
records the gating, filtering, and git-hardening
invariants in proxy/CLAUDE.md.
Refs #899
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(installer): make prune path flavour explicit, not host-derived
pruneOrphanedManagedHooks guards against pruning a hook whose path is
absolute on the other platform. That guard is POSIX-only by construction:
win32's absolute set is a superset of posix's, so a Windows host reads
C:\... natively and prunes it. The test asserting the guard hard-coded a
Windows path and so failed on the windows CI runner.
Select the path flavour from an explicit platform arg (default
process.platform, same as rewriteLegacyManagedHookCommands) so the POSIX
side stays reachable from a Windows runner. Host behaviour unchanged.
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* test(cli): escape stub preload path in NODE_OPTIONS
Node parses NODE_OPTIONS itself and treats a backslash inside a quoted
value as an escape, so the raw Windows path reached --require with every
separator eaten:
Cannot find module 'C:UsersRUNNER~1AppDataLocalTempcave-pi-enableXbinstub-dispatch.cjs'
Every stub-backed process died in the preload, failing all six pi enable
and wrap round trips. The quotes must stay for profile names with spaces,
so double the separators instead. POSIX is unaffected — stubEnv returns
before this on non-Windows.
Never caught before because npm test failed earlier in the same job, so
test:windows has not run since these tests joined the gate.
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): harden every untrusted git call, not just repo intelligence
`repointel` hardened its own `git ls-files` and left three other call sites
raw. `nativehook.currentRepositoryState` runs `git status` on EVERY hook
event, and `status` refreshes the index, so it triggers `core.fsmonitor`
harder than `ls-files` does — verified: the sentinel hook fires. Same for
`store/detect_outcomes` and the two `execFileSync` sites in the CLI.
- `proxy/internal/gitsafe` and `packages/cli/src/git-safe.ts` now own the
`-c` override list; all five call sites route through them.
- `verify_repo.py` fails the build on a NEW raw `git -C <dir>` call site,
which is the regression class this bug actually belongs to.
- `Bundle.DirectOnly()` strips metadata-only items BEFORE the evidence
object is written. Filtering only at render time left the BM25-proximity
guesses stored behind the `ccr://` handle, one retrieval from the model,
which is what the block refuses to render.
- `gitListBudget` caps git at half the caller's remaining warm budget. 2s
of a 3s window left the fallback walk — the MORE expensive path — unable
to finish, so the map failed closed in exactly the case the fallback
exists for.
- `NormalizeTerms` floors terms at 3 characters, matching the hook that
produces them: direct evidence is a substring test on a file's own name,
so `go`/`py`/`js` marked every file of that language a direct hit.
Regression tests verified to fail without their fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(build): stop wedge generation racing itself on Windows
mcp, shrink, and browse each run gen-wedge-installer from their own
prepack/pretest, and every run writes all four targets, not just its own.
`pnpm -r test` runs those packages in parallel, so several processes
copy onto the same file at once and Windows fails the loser:
EBUSY: resource busy or locked, copyfile
packages/shared/binary-installer/installer.mjs
-> mcp/bin/binary-installer.generated.mjs
The bytes are identical every time and the outputs are committed, so the
race was over writes that change nothing. Skip when the destination
already matches, and land a real change through a temp file + rename so
no concurrent reader sees a partial file. Verified with a 12-way
concurrent storm, and against corrupted outputs to prove the write path
still restores them.
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): keep system git config readable while hardening
GIT_CONFIG_NOSYSTEM defends against nothing here — /etc/gitconfig is
root-owned, the threat is the repository's own .git/config, and the `-c`
overrides beat every config level anyway. What it does cost is org-wide
`safe.directory` allowances: without them `git status` fails "dubious
ownership" on shared and CI machines, repository state comes back empty,
and reuse silently stops. Dropped from both wrappers, with a test so it
does not come back.
Also names the fsmonitor tradeoff at the hot call site: disabling it taxes
the people who configured it because their repo is huge, and status has a
100ms budget. That degrades safely; moving the budget is a latency
decision for every user and needs a measurement this fix does not have.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(build): pin wedge generation against the concurrency it now survives
The EBUSY fix has no committed regression proof, and the failure only
appears when several packages run the script at once — which is exactly
what `pnpm -r test` does and what a single-process check never reproduces.
Eight concurrent runs must all succeed and leave the shared outputs
byte-identical; a drifted output must still be repaired, so skip-if-
identical cannot regress into skip-always.
Runs in root `npm test`, which is the gate the Windows job failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
Co-authored-by: Julius Brussee <juliusbrussee@getlockedin.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A wrap-owned proxy idle-exits ~30m after its wrap dies, but native routing
keeps pointing every later turn of a plain session at 127.0.0.1:8787 — so the
next prompt hard-failed with ConnectionRefused and nothing was left to restart
it. Only the SessionStart hook ever revived the proxy.
nativeHook() now runs the (already idempotent, port-checked) revive block on
UserPromptSubmit and PostCompact too, and the fast native hook delegates a
UserPromptSubmit to the full CLI when the loopback gateway PORT is dead. The
trigger is the port, not the runtime socket, so proxy-up/socket-down states
(record pass-through, noProxy, slow runtime) keep the zero-spawn fast path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the CLI half of learn v3: a window-cost and effective-input line on the
default report, `learn savings`, `learn experiment start|arm|report|list|stop`,
`learn export`, and `learn reconcile --usage-export <csv>`.
The rendering enforces the same rules the analyzer does structurally. Savings
rows are grouped by measurement method with no blended headline, confounders and
provenance print in the default view rather than behind a flag, and a confirmed
row without attribution is shown as unattributed rather than attributed by
default. Money is formatted down to five decimals so real sub-cent spend does not
round to $0.00 and read as nothing.
`export` and `reconcile` stay machine-readable: the digest is a file to read
before deciding to share it, and a reconciliation is a table, not a headline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Node's global fetch ignores the conventional proxy variables, so on a host
whose egress only leaves through an HTTP proxy every request the CLI makes
fails while curl and npm succeed. The first thing a new user hits is:
binary download unreachable — agents still launch; traffic is NOT
compressed or metered
fix: caveman setup --install
which points back at the command that just failed, with nothing to suggest a
proxy is involved. The same blind spot affects the npm version check, login,
and every cloud call.
Node 24 can opt in with NODE_USE_ENV_PROXY=1 and 22 has it behind an
experimental warning, but it is off by default and a process cannot enable it
for itself after start-up, so the CLI has to implement the variables.
Add packages/cli/src/proxy-fetch.ts: resolve the proxy per target
(HTTPS_PROXY/HTTP_PROXY with ALL_PROXY fallback, lower case winning, NO_PROXY
with wildcard, suffix and port rules), then dispatch over node:http and
node:https — CONNECT tunnel for https so TLS still terminates at the target,
absolute-form for http. Redirects are followed with the proxy re-resolved per
hop, so a hop into a NO_PROXY host goes direct.
main() installs the wrapper before any request. It is skipped when nothing is
configured, and when NODE_USE_ENV_PROXY is set so a runtime that already
handles proxying is not proxied twice. No new dependencies.
Verified on a proxy-only host: setup --install reported the download as
unreachable before and installs all six binaries with checksums verified
after. Tests cover variable precedence, NO_PROXY matching, tunnelling,
bypass, redirects, bodies, abort, and the install guard.
Signed-off-by: Dylan Neve <dylanneve1@users.noreply.github.com>
pi-enable and pi-wrap could not join the Windows gate: their fixtures built
PATH with `:` and wrote extensionless `#!/bin/sh` stubs, which Windows cannot
execute at all. tests/harness/stub-bin.mjs gives them two stub shapes, one
per way the CLI launches things — a .cmd Node shim for commands routed
through portableInvocation (`pi`), and, for the Go binaries the CLI
execFile's directly, a real .exe: node.exe hardlinked under the binary's
name, answering from a --require preload that runs before Node resolves
argv[1] as a script. A .cmd cannot serve there, since Node refuses to spawn
one without a shell (CVE-2024-27980). Both fixtures also set USERPROFILE
beside HOME — os.homedir() reads it on Windows, and the Pi extension
directory hangs off it — and retry cleanup for handles Windows still holds.
update.runtime.mjs stays out, and not for a harness reason: its committed
checksum manifest carries only darwin and linux artifacts, so on win32
`update` looks up caveman-proxy_win32_amd64 and fails before downloading
anything. Adding those rows needs the release signing key; the regeneration
recipe is recorded at the fixture read.
The macOS job also runs `go test ./engine/... ./proxy/... ./shared/...` —
without it the runner touched no Go at all, so a BSD-only path, spawn or
timer regression shipped green on the platform this job exists to cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmbqWDtNexzDfH5V3BMRz
In-flight work that was already in the working tree, committed alongside the
cross-platform review at the user's request. NOT reviewed as part of that
review — recorded separately so it stays attributable.
Generalizes unwrapListing into unwrapInput (engine/filewrap.go) so agent
<file> tags are stripped alongside read-tool line-number gutters before
Detect classifies content, and rewrapped afterwards. Carries matching
packages/cli changes and their runtime tests.
Verified before committing: `go test ./engine/` passes and the
packages/cli suite is 612 passed / 0 failed / 17 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez7w7BBtKrMZCiZz9ea4cb
Pi joins every host surface: NativeAgent unions, capability matrix (incl.
post_tool_rewrite; PostToolUseFailure now returns raw responses for pi and
opencode), native-hook output contracts, Go validAgent +
directOutputRewriteAgent, doctor/status/enable/disable/expectedRoute rows,
and the porcelain agent list (visible cap 7 → 8: '+1 more' rendered longer
than the id it hid).
Wrap door: buildPiWrapArgs prepends the profile-declared loader flag plus the
bundled extension (CAVEMAN_PI_EXTENSION → dist/caveman-pi-extension.mjs),
stamps CAVEMAN_PI_HOOK_CMD + CAVE_GATEWAY_URL, and skips the generic base-URL
env spray — pi routing is extension-side, behind its recovery gate. Missing
asset fails open to a direct launch with a warning; the bundler script fails
the build instead of silently shipping without the asset.
Enable door: journaled write of banner + baked hook invocation + bundle to
~/.pi/agent/extensions/caveman-native.js (Pi auto-discovers only .ts/.js;
.mjs would journal fine and never load). settings.json stays byte-identical.
Status/doctor: pi route health = on-disk artifact ends with the currently
shipped bundle (catches stale bundles after CLI upgrades; the gateway is
resolved at runtime by the extension through the same chain as gatewayURL(),
so enable-time route recording would only manufacture false drift).
hooks install/uninstall pi exit 0 and point at enable/disable — the rewrite
surface ships inside the native extension.
Conformance: pi joins the matrix — a real wrap through the real proxy build
compresses via /w/pi/openai/v1 with a CCR handle booked under agent_slug=pi,
and the stub refuses to run if wrap failed open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez7w7BBtKrMZCiZz9ea4cb
typescript hung a third time (2h+ where a completing run takes ~2min).
The telemetry-stub unref fixed one instance of the class, but ~55 runtime
files hold stub servers/processes, and ANY load-dependent failure that
skips its cleanup pins that file's event loop and node --test waits
forever. --test-force-exit (Node 22.5+) ends each test-file process once
its tests finish, leaked handles or not — a failure now fails, on every
file, permanently. Also gave the hand-rolled noisy-stdout stub the same
10s token-read budget as stubProxyStats (the remaining 400ms flake under
concurrency). Mixed batch at concurrency 4: 61 pass x3 consecutive runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez7w7BBtKrMZCiZz9ea4cb