From 8254211202dde6d18556a233e158963168bca761 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 12:50:20 -0400 Subject: [PATCH 01/19] fix: Make the laptop quickstart one command, and fix what blocked it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quickstart asked a teammate to run nine commands, two of which were a 25-line YAML heredoc and a sed to substitute $HOME into it, and it spent three paragraphs explaining that `VAR=1 curl ... | sh` sets the variable on curl rather than sh. It is now three commands and no gotchas. Most of this commit is the things that had to be true first. Trying to follow my own instructions end-to-end is what found them. **abctl was broken in release builds.** #849 added a gjson import to authlib/pipeline without updating cmd/abctl's go.mod, and abctl resolves authlib through a replace directive, so `GOWORK=off go build` — exactly what release-binaries.yaml does — failed on a missing go.sum entry. Every other build path uses the workspace, which masked it. Cutting a tag today would have produced a release with no abctl for any platform. Root cause is that ci.yaml's matrix covers authbridge-proxy and authbridge-envoy but not abctl, so a binary we publish was never built in CI; abctl is now in the matrix. **A fatal error was logged at INFO.** slog.SetDefault also routes the standard log package, at Info unless told otherwise, and all 24 startup failure paths in main.go use log.Fatalf. So a port clash printed as an INFO line and the process exited — the log looked clean and the proxy was gone. One SetLogLoggerLevel call fixes every one of them. Fatals are the only std-log users in these binaries, so nothing else gets mislabelled. **The health port was a hardcoded ":9091".** Two proxies could therefore never coexist on one host: the second died on a bind conflict, which is precisely what happens when a teammate follows the quickstart while a demo is already running. It is now `listener.health_addr`, defaulted to ":9091" by every preset so Kubernetes probes are unaffected. **The documented laptop config bound two ports on every interface.** :8082 (transparent egress) and :9091 came from preset defaults the config never overrode, so a laptop on an untrusted network exposed both. The generated config now pins all five listeners to loopback. Note that transparent_proxy_addr cannot be disabled under proxy-sidecar — the preset refills an empty value — so pinning it is the available fix, not omitting it. **The scan would write a removal list inferred from nothing.** With zero observed tool calls, "tools you have not called" is every tool it knows: a 26-name list including BashOutput and KillShell, which background tasks need. That is not an edge case, it is a new install — little history, and the moment someone is most likely to accept the default. `--write` now refuses when the scan saw no tool calls, prints the proposal anyway, and says what to do instead. The paired test covers both directions so the guard can't pass by never writing. The installer grows `--claude-code`, which writes a persistent config under ~/.cortex (not the ./cortex-ca/demo.yaml that --demo regenerates on every start, discarding edits), fills the remove: list, starts the proxy, and prints the run command. Flags replace the env-var form because `sh -s --` has no variable-placement trap; AUTHBRIDGE_INSTALL_ONLY still works. AUTHBRIDGE_SKIP_DOWNLOAD is new and is what let me test all of this without a published release. $HOME in the generated config is left literal: authbridge expands ${ENV_VAR} at load, so the file stays portable and needs no sed. Verified: the full release matrix (16 artifacts, 4 platforms) builds with GOWORK=off; the documented flow runs end to end on shifted ports, writes the same 15-tool list as a hand-built config, and comes up with all five listeners on loopback; shellcheck clean at every severity. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .github/workflows/ci.yaml | 6 + .github/workflows/release-binaries.yaml | 6 + authbridge/authlib/config/config.go | 8 + authbridge/authlib/config/presets.go | 4 + authbridge/authlib/runtimeutil/runtimeutil.go | 8 + authbridge/cmd/README.md | 10 +- authbridge/cmd/abctl/cmd_tools.go | 17 ++ authbridge/cmd/abctl/cmd_tools_test.go | 103 +++++++++++ authbridge/cmd/abctl/go.mod | 3 + authbridge/cmd/abctl/go.sum | 7 + authbridge/cmd/authbridge-cpex/main.go | 2 +- authbridge/cmd/authbridge-envoy/main.go | 2 +- authbridge/cmd/authbridge-proxy/main.go | 2 +- authbridge/docs/laptop-token-savings.md | 166 +++++++---------- authbridge/install-demo.sh | 169 +++++++++++++++++- 15 files changed, 402 insertions(+), 111 deletions(-) create mode 100644 authbridge/cmd/abctl/cmd_tools_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f9d55baed..bde1c472d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -76,6 +76,12 @@ jobs: binary: - authbridge-proxy - authbridge-envoy + # abctl is published by release-binaries.yaml, so it has to build + # under GOWORK=off here too. Omitting it let a missing go.sum entry + # for an authlib transitive dep reach main: the workspace build used + # everywhere else resolved it, and the per-module release build did + # not — surfacing only when a tag was cut. + - abctl defaults: run: working-directory: authbridge/cmd/${{ matrix.binary }} diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml index 6b3426ab6..1df94cd31 100644 --- a/.github/workflows/release-binaries.yaml +++ b/.github/workflows/release-binaries.yaml @@ -56,6 +56,12 @@ jobs: lite_tags="${lite_tags},exclude_plugin_inferenceparser" lite_tags="${lite_tags},exclude_plugin_mcpparser,exclude_plugin_opa" lite_tags="${lite_tags},exclude_plugin_sparc,exclude_plugin_tokenbroker" + # tool-prune declares RequiresAny: [inference-parser], which lite + # excludes — so leaving it compiled in only adds bytes to a variant + # whose whole purpose is to be small, and any config naming it would + # fail Build. ci.yaml's lite tag set already excludes it; keep these + # two in step. + lite_tags="${lite_tags},exclude_plugin_toolprune" declare -a proxy_variants=( ":" "lite:${lite_tags}" diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index aacbecdf5..cc3322c03 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -429,6 +429,14 @@ type ListenerConfig struct { // mode preset is ":9094". Set to empty string to disable the endpoint. SessionAPIAddr string `yaml:"session_api_addr" json:"session_api_addr"` + // HealthAddr is the bind address for the liveness/readiness server + // (/healthz, /readyz). Every mode preset defaults it to ":9091", which is + // what Kubernetes probes expect. It is configurable because the literal was + // previously hardcoded, and two proxies on one host could therefore never + // coexist: the second died on a bind conflict. Local setups can pin it to + // loopback on another port; leaving it empty keeps the preset default. + HealthAddr string `yaml:"health_addr" json:"health_addr"` + // SkipHosts lists outbound destination host patterns whose traffic // bypasses the plugin pipeline AND session recording entirely. The // listener forwards matched requests as a transparent proxy without diff --git a/authbridge/authlib/config/presets.go b/authbridge/authlib/config/presets.go index b9f117b3c..a651f8548 100644 --- a/authbridge/authlib/config/presets.go +++ b/authbridge/authlib/config/presets.go @@ -44,6 +44,10 @@ func ApplyPreset(cfg *Config) { // session.enabled: false — main.go skips the API server when the // store itself is nil. setDefault(&cfg.Listener.SessionAPIAddr, ":9094") + + // Health server is default-on for every mode; ":9091" is what the operator's + // probe config and the container images expect. + setDefault(&cfg.Listener.HealthAddr, ":9091") } func setDefault(field *string, value string) { diff --git a/authbridge/authlib/runtimeutil/runtimeutil.go b/authbridge/authlib/runtimeutil/runtimeutil.go index 17f053d03..f01b0c4ab 100644 --- a/authbridge/authlib/runtimeutil/runtimeutil.go +++ b/authbridge/authlib/runtimeutil/runtimeutil.go @@ -48,6 +48,14 @@ func InitLogging(binaryName string) { } h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}) slog.SetDefault(slog.New(h).With("binary", binaryName)) + // slog.SetDefault also routes the standard log package through this handler, + // and it does so at Info unless told otherwise. Every fatal startup error in + // the binaries goes through log.Fatalf, so without this a port clash — the + // most common local failure — prints as INFO and the process then exits, + // leaving an operator scanning an apparently clean log for a cause. Fatals + // are the only std-log users in these binaries, so raising the bridge to + // Error labels them correctly rather than mislabelling anything else. + slog.SetLogLoggerLevel(slog.LevelError) } // StartSignalToggle installs a SIGUSR1 handler that toggles the process log diff --git a/authbridge/cmd/README.md b/authbridge/cmd/README.md index 7e366c81f..329a4fd76 100644 --- a/authbridge/cmd/README.md +++ b/authbridge/cmd/README.md @@ -44,13 +44,21 @@ ConfigMap contracts are documented in | 8081 | Forward proxy (outbound; HTTP_PROXY target) | | 8082 | Transparent egress listener (enforce-redirect capture target) | | 8083 | Transparent inbound listener (`inbound_interception: transparent`) | -| 9091 | Health | +| 9091 | Health (`listener.health_addr`) | | 9093 | Stats / config inspection | | 9094 | Session Events API (consumed by `abctl`) | `8080` and `8083` are mutually exclusive: `inbound_interception` picks one inbound mechanism, and the preset fills only that one's address. +Every one of these is a `listener.*` address and can be overridden — which +matters for running two proxies on one host, since a second instance on the +default ports dies on a bind conflict. Local single-host setups typically pin +them all to `127.0.0.1`; the defaults bind every interface, which is what +Kubernetes probes and sidecar traffic need but not what a laptop wants. See +[`docs/laptop-token-savings.md`](../docs/laptop-token-savings.md) for a worked +loopback-only config. + `8082` and `8083` are the iptables REDIRECT targets installed by [`proxy-init`](../proxy-init/) and must match its `TRANSPARENT_PORT` / `INBOUND_TRANSPARENT_PORT`. A mismatch redirects traffic to a dead port. diff --git a/authbridge/cmd/abctl/cmd_tools.go b/authbridge/cmd/abctl/cmd_tools.go index 784a92a4a..614fd6360 100644 --- a/authbridge/cmd/abctl/cmd_tools.go +++ b/authbridge/cmd/abctl/cmd_tools.go @@ -73,6 +73,23 @@ func runTools(args []string, stdout, stderr io.Writer) int { return 0 } + // Refuse to write a list inferred from no evidence. With zero observed tool + // calls, "tools you have not called" degrades to "every tool I know about", + // and the proposal above is that list — including ones a session genuinely + // needs. This is not a rare edge: it is exactly a new install, where there is + // little or no transcript history yet, which is also when someone is most + // likely to accept the default. Printing the proposal is still useful; the + // refusal is only about writing it unattended. + if len(res.Called) == 0 { + fmt.Fprintln(stdout) + fmt.Fprint(stdout, res.YAMLBlock()) + fmt.Fprintf(stderr, "\nabctl: not writing %s — the scan observed no tool calls at all in the\n"+ + "last %d day(s), so it has no evidence for what you do not use. Use Claude Code\n"+ + "for a while and re-run, widen the window with --days, or paste the block above\n"+ + "yourself once you have checked it.\n", *write, *days) + return 1 + } + changed, err := toolscan.PatchConfig(*write, res.Candidates) if err != nil { fmt.Fprintf(stderr, "abctl: %v\n", err) diff --git a/authbridge/cmd/abctl/cmd_tools_test.go b/authbridge/cmd/abctl/cmd_tools_test.go new file mode 100644 index 000000000..d6d5ecdd3 --- /dev/null +++ b/authbridge/cmd/abctl/cmd_tools_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const scanTestConfig = `mode: proxy-sidecar +pipeline: + outbound: + plugins: + - name: inference-parser + - name: tool-prune + config: + remove: [] +` + +// writeTranscript writes one JSONL transcript. withCall controls whether it +// contains a tool_use block, which is the scan's only evidence. +func writeTranscript(t *testing.T, dir, name string, withCall bool) { + t.Helper() + line := `{"timestamp":"` + nowStamp() + `","message":{"content":[{"type":"text","text":"hi"}]}}` + if withCall { + line = `{"timestamp":"` + nowStamp() + `","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash"}]}}` + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(line+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func nowStamp() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") } + +// TestToolsScan_RefusesToWriteWithoutEvidence: with no observed tool calls, +// "tools you have not called" is every tool it knows, so writing that list +// unattended would propose removing tools the session needs. A new install is +// exactly this case, which is also when the default is most likely accepted. +func TestToolsScan_RefusesToWriteWithoutEvidence(t *testing.T) { + dir := t.TempDir() + tdir := filepath.Join(dir, "projects") + if err := os.MkdirAll(tdir, 0o755); err != nil { + t.Fatal(err) + } + writeTranscript(t, tdir, "a.jsonl", false) // no tool_use anywhere + + cfg := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfg, []byte(scanTestConfig), 0o600); err != nil { + t.Fatal(err) + } + + var out, errb bytes.Buffer + code := runTools([]string{"scan", "--dir", tdir, "--write", cfg}, &out, &errb) + if code == 0 { + t.Errorf("exit code = 0, want non-zero when there is no evidence") + } + if !strings.Contains(errb.String(), "no tool calls") { + t.Errorf("stderr did not explain the refusal: %q", errb.String()) + } + // The config must be untouched. + after, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + if string(after) != scanTestConfig { + t.Errorf("config was modified despite the refusal:\n%s", after) + } + // The proposal is still printed — refusing to write it is not refusing to + // show it. + if !strings.Contains(out.String(), "remove:") { + t.Errorf("stdout did not include the proposal: %q", out.String()) + } +} + +// TestToolsScan_WritesWithEvidence is the paired positive case, so the guard +// above can't pass by simply never writing. +func TestToolsScan_WritesWithEvidence(t *testing.T) { + dir := t.TempDir() + tdir := filepath.Join(dir, "projects") + if err := os.MkdirAll(tdir, 0o755); err != nil { + t.Fatal(err) + } + writeTranscript(t, tdir, "a.jsonl", true) // one real tool_use + + cfg := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfg, []byte(scanTestConfig), 0o600); err != nil { + t.Fatal(err) + } + + var out, errb bytes.Buffer + if code := runTools([]string{"scan", "--dir", tdir, "--write", cfg}, &out, &errb); code != 0 { + t.Fatalf("exit code = %d, want 0. stderr: %s", code, errb.String()) + } + after, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + if string(after) == scanTestConfig { + t.Error("config was not updated despite real evidence") + } +} diff --git a/authbridge/cmd/abctl/go.mod b/authbridge/cmd/abctl/go.mod index e05cb1f71..bd69589b2 100644 --- a/authbridge/cmd/abctl/go.mod +++ b/authbridge/cmd/abctl/go.mod @@ -52,6 +52,9 @@ require ( github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/net v0.58.0 // indirect diff --git a/authbridge/cmd/abctl/go.sum b/authbridge/cmd/abctl/go.sum index bf4dac99a..a9b9cdbc0 100644 --- a/authbridge/cmd/abctl/go.sum +++ b/authbridge/cmd/abctl/go.sum @@ -151,6 +151,13 @@ github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhg github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= diff --git a/authbridge/cmd/authbridge-cpex/main.go b/authbridge/cmd/authbridge-cpex/main.go index 050889df8..6cedac969 100644 --- a/authbridge/cmd/authbridge-cpex/main.go +++ b/authbridge/cmd/authbridge-cpex/main.go @@ -245,7 +245,7 @@ func main() { slog.Info("authbridge-cpex starting", "mode", cfg.Mode, "logLevel", runtimeutil.LogLevel().String()) - healthSrv, healthErr := runtimeutil.StartHealthServer(inboundH, outboundH, ":9091") + healthSrv, healthErr := runtimeutil.StartHealthServer(inboundH, outboundH, cfg.Listener.HealthAddr) if healthErr != nil { log.Fatalf("health server listen: %v", healthErr) } diff --git a/authbridge/cmd/authbridge-envoy/main.go b/authbridge/cmd/authbridge-envoy/main.go index 12fb43afe..01bce10e9 100644 --- a/authbridge/cmd/authbridge-envoy/main.go +++ b/authbridge/cmd/authbridge-envoy/main.go @@ -237,7 +237,7 @@ func main() { slog.Info("authbridge-envoy starting", "mode", cfg.Mode, "logLevel", runtimeutil.LogLevel().String()) - healthSrv, healthErr := runtimeutil.StartHealthServer(inboundH, outboundH, ":9091") + healthSrv, healthErr := runtimeutil.StartHealthServer(inboundH, outboundH, cfg.Listener.HealthAddr) if healthErr != nil { log.Fatalf("health server listen: %v", healthErr) } diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 3788602da..336796dd7 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -498,7 +498,7 @@ func main() { slog.Info("authbridge-proxy starting", "version", version, "mode", cfg.Mode, "logLevel", runtimeutil.LogLevel().String()) - healthSrv, healthErr := runtimeutil.StartHealthServer(inboundH, outboundH, ":9091") + healthSrv, healthErr := runtimeutil.StartHealthServer(inboundH, outboundH, cfg.Listener.HealthAddr) if healthErr != nil { log.Fatalf("health server listen: %v", healthErr) } diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index b88f59a49..7af4fe3c1 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -1,90 +1,26 @@ # Cut Claude Code token cost on your laptop -Cortex runs as a local proxy in front of Claude Code and strips tool -definitions your agent never calls out of every request. Claude Code sends the -full tool manifest on every turn — tens of thousands of tokens of JSON schema, -billed each time — and the manifest is built by the client, so the proxy is the -only place to trim it without changing every client. +Cortex runs as a local proxy in front of Claude Code and strips tool definitions +your agent never calls out of every request. Claude Code sends the full tool +manifest on every turn — tens of thousands of tokens of JSON schema, billed each +time — and the manifest is built by the client, so the proxy is the only place to +trim it without changing every client. -Four steps, about two minutes. +macOS or Linux, amd64 or arm64. No cluster, Keycloak, or SPIRE. -## 1. Install the binaries +## 1. Install and start ```sh curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install-demo.sh \ - | AUTHBRIDGE_INSTALL_ONLY=1 sh + | sh -s -- --claude-code ``` -Puts `authbridge-proxy` and `abctl` in `~/.local/bin`. `INSTALL_ONLY` skips -starting the demo — you want a config that persists, which the next step writes. +That downloads the released binaries into `~/.local/bin`, writes +`~/.cortex/config.yaml`, picks which tools to prune from your own transcripts, +starts the proxy, and prints the command for step 2. Re-running it is safe — it +never overwrites a config you already have. -The variable goes on `sh`, not on `curl`. Written the other way round -(`VAR=1 curl … | sh`) it only reaches `curl`, the script runs without it and -starts the demo — binding 47600-47602 and writing a `cortex-ca/demo.yaml`, so -step 3 then fails on the port clash. - -## 2. Write a config - -```sh -mkdir -p ~/.cortex -cat > ~/.cortex/config.yaml <<'YAML' -mode: proxy-sidecar -listener: - roles: [forward] - forward_proxy_addr: 127.0.0.1:47600 - session_api_addr: 127.0.0.1:47601 -stats: - address: 127.0.0.1:47602 -tls_bridge: - mode: enabled - ca_dir: "CA_DIR_PLACEHOLDER" - generate_ca: true -pipeline: - outbound: - plugins: - - name: inference-parser - # tool-prune must stay last: it rewrites the request body, and body - # readers have to precede it to see the original bytes. - - name: tool-prune - config: - remove: [] -YAML -sed -i.bak "s|CA_DIR_PLACEHOLDER|$HOME/.cortex/ca|" ~/.cortex/config.yaml && rm ~/.cortex/config.yaml.bak -``` - -Keep this outside any `cortex-ca/` directory. `authbridge-proxy --demo` -regenerates `cortex-ca/demo.yaml` from a built-in template on startup — before -it binds ports, so even a start that fails on a port clash discards your edits. -Running with `--config` avoids that entirely. - -## 3. Fill in the prune list and start - -```sh -authbridge-proxy --config ~/.cortex/config.yaml & -abctl tools scan --write ~/.cortex/config.yaml -``` - -`tools scan` reads your own `~/.claude/projects/*.jsonl` transcripts and proposes -the built-in tools you have not called in 30 days. It only ever proposes tools it -recognises, and never one it has seen you call. The config is hot-reloaded; no -restart. - -**What the scan cannot know is the future.** It reports what you have not used, -not what you will not need. If you start a kind of work that needs a pruned tool, -its definition is gone from the request and the model cannot call it — that is a -functional failure, not merely a smaller saving. Two things keep it cheap: - -- **Rescan occasionally** (say monthly, or after your work changes shape) so the - list tracks what you actually use. The list is only ever as current as the last - scan. -- **Reach for `on_error: observe` when in doubt.** It measures the saving and - changes nothing, so you can see what a list would be worth before trusting it. - abctl marks those figures with `~`. - -If a tool goes missing, the fix is to delete its name from `remove:` — the config -hot-reloads, so it comes back without a restart. - -## 4. Point Claude Code at it +## 2. Run Claude Code through it ```sh HTTPS_PROXY=http://localhost:47600 \ @@ -93,16 +29,21 @@ HTTPS_PROXY=http://localhost:47600 \ claude ``` -Then watch what it saved: +Step 1 prints this line with the paths already filled in. + +## 3. See what it saved ```sh abctl --endpoint http://localhost:47601 ``` -Plugin pane → `tool-prune` → `Metrics`. +Plugin pane → `tool-prune` → `Metrics`. Stop the proxy with +`kill $(cat ~/.cortex/proxy.pid)`. -What to expect, measured over 99 requests of one real session: **4–20% of the -prompt per turn, median 6%**. Two things move it, and neither is a defect: +## What to expect + +**4–20% of the prompt per turn, median 6%**, measured over 99 requests of one +real session. Two things move it, and neither is a defect: - **How much of the manifest is yours to prune.** Requests carrying the full tool set saved 15–20%; most requests in that session offered a reduced set and saved @@ -114,30 +55,31 @@ prompt per turn, median 6%**. Two things move it, and neither is a defect: A single early turn can read ~24%, which is why a figure quoted from one request is not the number to plan with. -Stop it with `pkill -f 'authbridge-proxy --config'`. - -## Seeing the saving in money +`$ saved` appears with no extra configuration, labelled `default rates`. **Read it +as a floor.** The built-in rates were measured on a shared gateway that bills +below vendor list; if your Claude Code talks straight to Anthropic — which it does +unless you have set `ANTHROPIC_BASE_URL` — you pay list, so the real saving is +several times what the column shows. To make it accurate, set your own rates: +[`tool-prune-plugin.md`](./tool-prune-plugin.md#costing-it). -`$ saved` and `$ saved / request` appear with no extra configuration, labelled -`default rates` to be clear they come from a built-in table rather than your own -account. +## Keeping the prune list honest -**Read that figure as a floor, not a measurement.** The built-in rates were -measured on a shared gateway that bills below vendor list. If your Claude Code -talks straight to Anthropic — which it does unless you have set -`ANTHROPIC_BASE_URL` — you are paying list, so the real saving is several times -what the column shows. Set your own rates to make it accurate; the number is -useful as-is only for confirming the plugin is working and comparing turns -against each other. +The scan proposes tools you have not called in 30 days. It only ever proposes +tools it recognises, never one it has seen you call, and it refuses to write a +list at all if it saw no tool calls to reason from. -Token savings are reported per prompt-cache tier, never as one blended number: -providers charge ~1.25x the input rate for a cache write and ~0.1x for a cache -read, so identical saved bytes differ by more than 12x depending on cache state. +**What it cannot know is the future.** It reports what you have not used, not what +you will not need. If you start work that needs a pruned tool, its definition is +gone from the request and the model cannot call it — a functional failure, not +merely a smaller saving. So: -If you are on a different gateway, or the rates have moved, override them per -model — see -[`tool-prune-plugin.md`](./tool-prune-plugin.md#costing-it), which also has the -method for measuring your own from the gateway's cost headers. +- **Re-run it occasionally** (monthly, or when your work changes shape): + `abctl tools scan --write ~/.cortex/config.yaml`. The proxy hot-reloads. +- **If a tool goes missing, delete its name from `remove:`** in + `~/.cortex/config.yaml`. It comes back without a restart. +- **To try a list without committing to it**, set `on_error: observe` on the + `tool-prune` plugin. It measures the saving and changes nothing; abctl marks + those figures with `~`. ## What this does and does not change @@ -149,6 +91,22 @@ the pruning happens downstream. So this saves money, not context window; auto-compact still triggers at the same point. Recovering headroom needs client-side settings (`--allowedTools`, disabling unused MCP servers). -If the Metrics pane stays empty and every event shows `tunnel`, Claude Code is not -trusting the bridge CA — check `NODE_EXTRA_CA_CERTS` points at the absolute path -above. The proxy also warns about this in its log after a few requests. +## If it isn't working + +- **Metrics pane empty, every event shows `tunnel`** — Claude Code is not trusting + the bridge CA. Check `NODE_EXTRA_CA_CERTS` is the absolute path from step 2. The + proxy also warns about this in `~/.cortex/proxy.log` after a few requests. +- **The proxy won't start** — read `~/.cortex/proxy.log`; a port conflict is + logged at `ERROR`. The config pins every listener to loopback on 47600–47604, so + a clash usually means Cortex is already running. +- **`abctl: command not found`** — `~/.local/bin` is not on your `PATH`: + `export PATH="$HOME/.local/bin:$PATH"`. + +## Other ways in + +- **Just the binaries**, no setup: `... | sh -s -- --install-only`. +- **A throwaway demo** in the current directory instead of a persistent config: + `... | sh` with no arguments. +- **Pin a version**: `AUTHBRIDGE_VERSION=vX.Y.Z`. +- **Re-run setup offline**, using the binaries you already have: + `AUTHBRIDGE_SKIP_DOWNLOAD=1`. diff --git a/authbridge/install-demo.sh b/authbridge/install-demo.sh index 3c87862b5..ac002ebf7 100755 --- a/authbridge/install-demo.sh +++ b/authbridge/install-demo.sh @@ -9,9 +9,28 @@ # commands to watch traffic and point an agent at it, plus how to stop it. # macOS + Linux, amd64 + arm64. No cluster, Keycloak, or SPIRE needed. # +# Modes (pass through the pipe with `sh -s --`, e.g. +# curl -fsSL ...install-demo.sh | sh -s -- --claude-code): +# +# (default) install, then start the throwaway demo in ./cortex-ca +# --claude-code install, then set up a PERSISTENT config in ~/.cortex for +# cutting Claude Code token cost: writes the config, fills the +# tool-prune remove: list from your own transcripts, starts the +# proxy, and prints the exact command to run Claude Code +# through it. Safe to re-run; never overwrites an existing +# config. +# --install-only install the binaries and stop +# +# Flags exist because the env-var form has a trap: written +# `VAR=1 curl ... | sh` the variable reaches curl, not sh, so the script runs +# without it. `sh -s -- --flag` has no such failure mode. The env vars below +# still work for backward compatibility. +# # Environment: # AUTHBRIDGE_VERSION=vX.Y.Z install a specific release tag (default: newest) -# AUTHBRIDGE_INSTALL_ONLY=1 install the binaries but do not start the demo +# AUTHBRIDGE_INSTALL_ONLY=1 same as --install-only +# AUTHBRIDGE_SKIP_DOWNLOAD=1 use the already-installed binaries in ~/.local/bin +# instead of downloading (re-run setup offline) set -eu REPO="rossoctl/cortex" @@ -21,6 +40,25 @@ info() { printf '%s\n' "$*"; } warn() { printf 'warning: %s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } +# --- mode selection --- +MODE=demo +for arg in "$@"; do + case "$arg" in + --claude-code) MODE=claude-code ;; + --install-only) MODE=install-only ;; + --demo) MODE=demo ;; + -h | --help) + sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) die "unknown option: $arg (try --claude-code, --install-only, or no argument)" ;; + esac +done +# Env form kept working; the flag wins if both are given. +if [ "${AUTHBRIDGE_INSTALL_ONLY:-}" = "1" ] && [ "$MODE" = "demo" ]; then + MODE=install-only +fi + command -v curl >/dev/null 2>&1 || die "curl is required" command -v tar >/dev/null 2>&1 || die "tar is required" @@ -73,7 +111,7 @@ case "$arch" in esac # --- preflight: fail early (before downloading) if a demo port is taken --- -if [ "${AUTHBRIDGE_INSTALL_ONLY:-}" != "1" ]; then +if [ "$MODE" = "demo" ] || [ "$MODE" = "claude-code" ]; then for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do if port_in_use "$p"; then die "port ${p} is already in use. Is the demo already running (see ./cortex-ca/demo.pid)? Otherwise free the port, or change the ports in ./cortex-ca/demo.yaml, then re-run." @@ -81,6 +119,15 @@ if [ "${AUTHBRIDGE_INSTALL_ONLY:-}" != "1" ]; then done fi +# --- skip the download entirely when asked (offline re-run) --- +if [ "${AUTHBRIDGE_SKIP_DOWNLOAD:-}" = "1" ]; then + for b in abctl authbridge-proxy; do + [ -x "${BIN_DIR}/${b}" ] || die "AUTHBRIDGE_SKIP_DOWNLOAD=1 but ${BIN_DIR}/${b} is missing" + done + version="(already installed)" + info "Using the binaries already in ${BIN_DIR}" +else + # --- resolve the release tag --- # `releases/latest` excludes prereleases, and the project ships prereleases, so # list releases (newest first) and take the first tag_name instead. @@ -132,6 +179,7 @@ fi rm -rf "$tmp" trap - EXIT +fi # end of download block # --- report --- proxy="${BIN_DIR}/authbridge-proxy" @@ -151,9 +199,124 @@ case ":${PATH}:" in ;; esac -if [ "${AUTHBRIDGE_INSTALL_ONLY:-}" = "1" ]; then +if [ "$MODE" = "install-only" ]; then info "" info "Install-only mode. Start the demo with: ${proxy_cmd} --demo" + info "Or set up persistent Claude Code cost-cutting: re-run with --claude-code" + exit 0 +fi + +# --- claude-code mode: persistent setup under ~/.cortex, then start --- +# +# Separate from --demo because --demo regenerates ./cortex-ca/demo.yaml from a +# built-in template on every start, so any edit (like the remove: list this mode +# fills in) would be discarded on the next run. A config under ~/.cortex is +# outside that path and survives. +if [ "$MODE" = "claude-code" ]; then + cfg_dir="${HOME}/.cortex" + cfg="${cfg_dir}/config.yaml" + cc_ca_dir="${cfg_dir}/ca" + mkdir -p "$cfg_dir" + + if [ -f "$cfg" ]; then + info "" + info "Keeping your existing config: ${cfg}" + else + info "" + info "Writing ${cfg}" + # ${HOME} is left literal on purpose: authbridge expands ${ENV_VAR} when it + # loads the file, so the config stays portable and needs no path rewriting + # after the fact. + cat >"$cfg" <<'YAML' +# Cortex — cut Claude Code token cost by pruning unused tool definitions. +# Written by install-demo.sh --claude-code. Safe to edit; the proxy hot-reloads. +mode: proxy-sidecar +listener: + roles: [forward] + forward_proxy_addr: 127.0.0.1:47600 + session_api_addr: 127.0.0.1:47601 + # The proxy-sidecar preset turns this listener on and refills it if emptied, so + # pin it to loopback on an uncommon port. Left at its ":8082" default it binds + # every interface — on a laptop that means the LAN — and collides with anything + # else already using 8082. + transparent_proxy_addr: 127.0.0.1:47603 + # Same reasoning: the default ":9091" is every-interface and collides with any + # other authbridge on the host. + health_addr: 127.0.0.1:47604 +stats: + address: 127.0.0.1:47602 +tls_bridge: + mode: enabled + ca_dir: "${HOME}/.cortex/ca" + generate_ca: true +pipeline: + outbound: + plugins: + - name: inference-parser + # tool-prune must stay last: it rewrites the request body, and body + # readers have to precede it to see the original bytes. + - name: tool-prune + config: + remove: [] +YAML + fi + + # Fill the remove: list before starting, so no hot-reload round-trip is + # needed. This edits the config the user just asked us to set up, which is + # the point of the mode — but say so, and say how to undo it, because it is + # derived from their transcripts rather than chosen by them. + info "" + info "Choosing tools to prune from your own ~/.claude/projects transcripts..." + if "${BIN_DIR}/abctl" tools scan --write "$cfg"; then + info "" + info "Wrote the remove: list to ${cfg}" + info "To keep a tool, delete its name from that list — the proxy hot-reloads." + else + warn "the scan did not complete; the remove: list is empty, so tool-prune" + warn "will do nothing until you run: ${abctl_cmd} tools scan --write ${cfg}" + fi + + info "" + info "Starting the proxy in the background..." + cc_log="${cfg_dir}/proxy.log" + cc_pidfile="${cfg_dir}/proxy.pid" + nohup "$proxy" --config "$cfg" "$cc_log" 2>&1 & + cc_pid=$! + echo "$cc_pid" >"$cc_pidfile" + + ready=0 + i=0 + while [ "$i" -lt 50 ]; do + if ! kill -0 "$cc_pid" 2>/dev/null; then + warn "the proxy exited during startup — last log lines:" + tail -n 15 "$cc_log" >&2 || true + die "proxy failed to start (full log: ${cc_log})" + fi + if port_in_use "$DEMO_FORWARD_PORT"; then + ready=1 + break + fi + sleep 0.2 + i=$((i + 1)) + done + + info "" + if [ "$ready" -eq 1 ]; then + info "Cortex is running (pid ${cc_pid}). Logs: ${cc_log}" + else + info "Cortex started (pid ${cc_pid}); couldn't confirm it's listening (install lsof or nc to verify). Logs: ${cc_log}" + fi + info "" + info "Run Claude Code through it:" + info "" + info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" + info " NODE_EXTRA_CA_CERTS=${cc_ca_dir}/ca.crt \\" + info " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude" + info "" + info " See what it saved: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" + info " (Plugin pane -> tool-prune -> Metrics)" + info " Stop it: kill \$(cat ${cc_pidfile})" + info "" exit 0 fi From 46ffe2e0a4f52f9473ec06fcb17b7f6565207f29 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 12:58:31 -0400 Subject: [PATCH 02/19] fix: Keep every Cortex artifact under ~/.cortex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent setup already lived in ~/.cortex, but --demo did not: it defaulted to ./cortex-ca, relative to whatever directory it was started from. So the CA and its private key landed wherever you happened to be — including inside a checkout of this repo, which is exactly what happened here during testing, and .gitignore had no rule that would have caught it. --demo now defaults to ~/.cortex/demo. One directory per user holds config, CA, keys, logs and pidfiles; nothing is written to the working directory at all, which the test confirms by asserting the cwd stays empty. The old default was justified in a comment as avoiding an absolute path baked into the binary. Resolving $HOME at runtime satisfies that just as well, and the cwd default had a cost the comment did not weigh: a private key dropped into arbitrary directories, and a CA path that changed depending on where you launched from — which is precisely the thing the session-budget demo doc had to spend a paragraph explaining how to recover with `ls "$(pwd)/cortex-ca/ca.crt"`. That paragraph is gone. --demo keeps its own subdirectory rather than sharing config.yaml, because it regenerates its config; sharing would mean it could clobber a config someone maintains by hand. Because these directories now hold a private key in a predictable place, they are created 0700 (previously 0755). Migration: the default moving is a silent failure for anyone whose client already trusts a CA in ./cortex-ca — requests just tunnel through opaquely and no plugin sees a body. So --demo warns when it finds a ./cortex-ca it is no longer using, naming both paths and the two ways forward (update the client, or pass --ca-dir ./cortex-ca). --ca-dir still overrides, verified. Also adds a cortex-ca/ gitignore rule, which covers both the no-resolvable- $HOME fallback and an explicit --ca-dir, so a private key cannot be committed from a demo run inside a checkout. Docs updated to the single location: the quickstart now shows the ~/.cortex tree and how to uninstall it, and the root README, tool-prune reference, session-budget demo, and abctl's missing-config hint all name the new path. Verified: --demo from a scratch directory writes only under ~/.cortex/demo (0700, key 0600) and leaves the cwd empty; the migration warning fires on a stale ./cortex-ca; --ca-dir still redirects; --claude-code produces exactly the tree the doc documents; four modules build/vet/test clean; shellcheck clean. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .gitignore | 5 +++ README.md | 2 +- authbridge/cmd/abctl/toolscan/patch.go | 4 +- authbridge/cmd/authbridge-proxy/demo.go | 37 ++++++++++++++++--- authbridge/cmd/authbridge-proxy/main.go | 14 ++++++- .../session-budget/hitl-with-claude-code.md | 24 ++++++------ authbridge/docs/laptop-token-savings.md | 29 +++++++++++++-- authbridge/docs/tool-prune-plugin.md | 2 +- authbridge/install-demo.sh | 18 ++++++--- 9 files changed, 100 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 94dcbebec..38e13af24 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,8 @@ authbridge/authbridge-praxis # Local experiment state, not project source. mlflow.db mlruns/ + +# Cortex writes its CA + keys under ~/.cortex. This covers the fallback location +# used when $HOME is unresolvable, and any explicit `--ca-dir ./cortex-ca`, so a +# private key can never be committed from a demo run inside a checkout. +cortex-ca/ diff --git a/README.md b/README.md index 3c2093f66..ae38a900c 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — de ```sh HTTPS_PROXY=http://localhost:47600 \ - NODE_EXTRA_CA_CERTS="$PWD/cortex-ca/ca.crt" \ + NODE_EXTRA_CA_CERTS="$HOME/.cortex/demo/ca.crt" \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude ``` diff --git a/authbridge/cmd/abctl/toolscan/patch.go b/authbridge/cmd/abctl/toolscan/patch.go index 7aca6e019..eb99d21e6 100644 --- a/authbridge/cmd/abctl/toolscan/patch.go +++ b/authbridge/cmd/abctl/toolscan/patch.go @@ -27,7 +27,7 @@ func PatchConfig(path string, candidates []string) (changed bool, err error) { orig, err := os.ReadFile(path) //nolint:gosec // operator-supplied config path if err != nil { if errors.Is(err, os.ErrNotExist) { - // The bare os error ("open ./cortex-ca/demo.yaml: no such file or + // The bare os error ("open demo.yaml: no such file or // directory") is technically complete and practically useless: the // demo anchors its config to the directory it was launched from, so // a relative path resolves against the wrong place more often than @@ -37,7 +37,7 @@ func PatchConfig(path string, candidates []string) (changed bool, err error) { abs = path } return false, fmt.Errorf("no config at %s\n"+ - " authbridge-proxy --demo writes cortex-ca/demo.yaml into the directory it is started from,\n"+ + " authbridge-proxy --demo writes demo.yaml under ~/.cortex/demo,\n"+ " so run this from there or pass an absolute path. To find it:\n"+ " curl -s localhost:47602/config | grep ca_dir", abs) } diff --git a/authbridge/cmd/authbridge-proxy/demo.go b/authbridge/cmd/authbridge-proxy/demo.go index 3a89d1afd..a89779fb1 100644 --- a/authbridge/cmd/authbridge-proxy/demo.go +++ b/authbridge/cmd/authbridge-proxy/demo.go @@ -7,11 +7,35 @@ import ( "path/filepath" ) -// demoCADirDefault is the default CA directory for --demo, relative to the -// current working directory — no absolute path is baked into the binary. -// Override with --ca-dir. The built-in config is written into this same -// directory (demo.yaml), next to the generated CA. -const demoCADirDefault = "cortex-ca" +// Everything Cortex writes for a user lives under ~/.cortex, so a laptop ends up +// with exactly one directory holding config, CA and keys rather than a CA +// scattered into whichever directory each command happened to run from. +const ( + cortexDirName = ".cortex" + // demoDirName keeps --demo's throwaway state out of the persistent config's + // way. --demo regenerates its config, so it must not share a directory with + // the config a user maintains by hand. + demoDirName = "demo" + // demoCADirFallback is used only when the home directory cannot be + // determined, which is the historical cwd-relative behaviour. + demoCADirFallback = "cortex-ca" +) + +// defaultDemoCADir returns the directory --demo works in: ~/.cortex/demo, or +// ./cortex-ca if there is no resolvable home directory. +// +// This used to be cwd-relative unconditionally, on the reasoning that no +// absolute path should be baked into the binary. Resolving $HOME at runtime +// satisfies that while keeping the private key in one predictable place — and +// the cwd default had a real cost: it dropped a CA and private key into +// whatever directory the demo was started from, including checkouts. +func defaultDemoCADir() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return demoCADirFallback + } + return filepath.Join(home, cortexDirName, demoDirName) +} // demoConfigYAML returns the built-in --demo config with caDir interpolated: a // forward-only proxy with the TLS bridge on (auto-generated CA in caDir) and @@ -83,7 +107,8 @@ pipeline: // that even a --demo start which then failed on a port clash silently destroyed // those edits. Delete the file to regenerate the preset. func writeDemoConfig(caDir string) (string, error) { - if err := os.MkdirAll(caDir, 0o755); err != nil { + // 0700: this directory holds the demo CA's private key. + if err := os.MkdirAll(caDir, 0o700); err != nil { return "", err } path := filepath.Join(caDir, "demo.yaml") diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 336796dd7..956cd31f8 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -125,7 +125,7 @@ func main() { demo := flag.Bool("demo", false, "run a built-in local demo (forward-only TLS bridge + protocol parsers) that decrypts and parses an agent's egress; no --config, cluster, Keycloak, or SPIRE needed") caDir := flag.String("ca-dir", "", - "CA directory for --demo (auto-generated); defaults to ./"+demoCADirDefault) + "CA directory for --demo (auto-generated); defaults to ~/"+cortexDirName+"/"+demoDirName) flag.Parse() if *showVersion { @@ -143,7 +143,17 @@ func main() { } dir := *caDir if dir == "" { - dir = demoCADirDefault // relative to cwd — no absolute path baked in + dir = defaultDemoCADir() + // The default moved here from ./cortex-ca. Someone who still has that + // directory almost certainly has a client trusting the CA inside it, + // and pointing at a stale CA fails silently — every request tunnels + // through opaquely and no plugin sees a body. Name both paths. + if st, serr := os.Stat(demoCADirFallback); serr == nil && st.IsDir() { + slog.Warn("demo mode — the default CA directory is now under $HOME; the ./"+demoCADirFallback+" here is no longer used", + "now_using", dir, + "ignored", demoCADirFallback, + "hint", "update the client's CA path (e.g. NODE_EXTRA_CA_CERTS), or pass --ca-dir ./"+demoCADirFallback+" to keep the old location") + } } abs, aerr := filepath.Abs(dir) if aerr != nil { diff --git a/authbridge/demos/session-budget/hitl-with-claude-code.md b/authbridge/demos/session-budget/hitl-with-claude-code.md index 4013ac1bf..3efc43675 100644 --- a/authbridge/demos/session-budget/hitl-with-claude-code.md +++ b/authbridge/demos/session-budget/hitl-with-claude-code.md @@ -62,16 +62,14 @@ there even in `roles: [forward]`, and a stale `authbridge-proxy` from a prior run will fail boot with `address already in use`. If that happens: `pkill -f authbridge-proxy` and relaunch. -The proxy generates `cortex-ca/ca.crt` (relative to the directory it -runs from) on first launch — that's the trust anchor Claude Code -needs. From the repo root that resolves to -`/cortex-ca/ca.crt`; grab the absolute path with -`ls "$(pwd)/cortex-ca/ca.crt"` from the same terminal. Look for these +The proxy generates `~/.cortex/demo/ca.crt` on first launch — that's the +trust anchor Claude Code needs. The path is the same wherever you start +the proxy from, so there is no `$PWD` to keep track of. Look for these lines in the log: ```text -level=WARN msg="tls-bridge: generated self-signed CA ..." ca_dir=cortex-ca ... -level=INFO msg="tls-bridge enabled" ca_dir=cortex-ca +level=WARN msg="tls-bridge: generated self-signed CA ..." ca_dir=/Users/you/.cortex/demo ... +level=INFO msg="tls-bridge enabled" ca_dir=/Users/you/.cortex/demo level=INFO msg="HTTP server listening" name=forward-proxy addr=127.0.0.1:47600 level=INFO msg="authbridge-proxy starting" mode=proxy-sidecar ``` @@ -87,7 +85,7 @@ settings and out of `~/.claude/settings.json`): { "env": { "HTTPS_PROXY": "http://127.0.0.1:47600", - "NODE_EXTRA_CA_CERTS": "/absolute/path/to/cortex-ca/ca.crt", + "NODE_EXTRA_CA_CERTS": "/Users/you/.cortex/demo/ca.crt", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" } } @@ -170,7 +168,7 @@ accumulating. Skip the approver terminal entirely. See [`hitl-local.md`](hitl-local.md) — [§ Reset between runs](hitl-local.md#reset-between-runs), [§ Auto modes for CI](hitl-local.md#auto-modes-for-ci), -[§ Cleanup](hitl-local.md#cleanup). Add `rm -rf cortex-ca` in the +[§ Cleanup](hitl-local.md#cleanup). Add `rm -rf ~/.cortex/demo` in the directory the proxy ran from if you want to regenerate the CA on the next run — the new `ca.crt` has a fresh serial, so re-point `NODE_EXTRA_CA_CERTS` in `.claude/settings.local.json` at it (same @@ -183,7 +181,7 @@ will fail the TLS handshake against the proxy. bucket. Fine for a single-workload laptop demo; in multi-tenant deployments one caller exhausting the budget denies all others. Leave it off in production and rely on the inbound A2A session ID. -- **`--demo` overwrites `cortex-ca/demo.yaml`.** If you also run - `authbridge-proxy --demo` in the same directory, it will clobber the - config from step 2. Use a separate working directory or a config - path outside `cortex-ca/`. +- **`--demo` has its own config at `~/.cortex/demo/demo.yaml`.** It no + longer depends on which directory you start from, so it cannot clobber + a config kept elsewhere — but two `--demo` runs share that one file. + Point one of them at `--ca-dir` if you need two independent demos. diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index 7af4fe3c1..352851d18 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -20,6 +20,25 @@ That downloads the released binaries into `~/.local/bin`, writes starts the proxy, and prints the command for step 2. Re-running it is safe — it never overwrites a config you already have. +Everything Cortex writes lives under `~/.cortex`, so there is one directory to +inspect, back up, or delete — and no CA or private key left in whichever +directory you happened to run a command from: + +```text +~/.cortex/ +├── config.yaml your config — edit freely, the proxy hot-reloads +├── ca/ the bridge CA Claude Code has to trust +│ ├── ca.crt <- NODE_EXTRA_CA_CERTS points here +│ └── tls.key private key, never leaves this machine +├── proxy.log +├── proxy.pid +└── demo/ only if you run the throwaway demo (see below) +``` + +The directory is created `0700`, because the CA private key is under it. To +uninstall completely: stop the proxy, then `rm -rf ~/.cortex` and +`rm ~/.local/bin/{abctl,authbridge-proxy}`. + ## 2. Run Claude Code through it ```sh @@ -94,8 +113,9 @@ client-side settings (`--allowedTools`, disabling unused MCP servers). ## If it isn't working - **Metrics pane empty, every event shows `tunnel`** — Claude Code is not trusting - the bridge CA. Check `NODE_EXTRA_CA_CERTS` is the absolute path from step 2. The - proxy also warns about this in `~/.cortex/proxy.log` after a few requests. + the bridge CA. `NODE_EXTRA_CA_CERTS` must point at `~/.cortex/ca/ca.crt`, + expanded to an absolute path. The proxy also warns about this in + `~/.cortex/proxy.log` after a few requests, naming the path it expects. - **The proxy won't start** — read `~/.cortex/proxy.log`; a port conflict is logged at `ERROR`. The config pins every listener to loopback on 47600–47604, so a clash usually means Cortex is already running. @@ -105,8 +125,9 @@ client-side settings (`--allowedTools`, disabling unused MCP servers). ## Other ways in - **Just the binaries**, no setup: `... | sh -s -- --install-only`. -- **A throwaway demo** in the current directory instead of a persistent config: - `... | sh` with no arguments. +- **A throwaway demo** instead of a persistent config: `... | sh` with no + arguments. It keeps its own regenerated config and CA in `~/.cortex/demo`, so + it never touches the `config.yaml` above. - **Pin a version**: `AUTHBRIDGE_VERSION=vX.Y.Z`. - **Re-run setup offline**, using the binaries you already have: `AUTHBRIDGE_SKIP_DOWNLOAD=1`. diff --git a/authbridge/docs/tool-prune-plugin.md b/authbridge/docs/tool-prune-plugin.md index 72f0fab2c..8af147c25 100644 --- a/authbridge/docs/tool-prune-plugin.md +++ b/authbridge/docs/tool-prune-plugin.md @@ -47,7 +47,7 @@ nothing, whatever the policy, so filling the list is the single act that enables it: ```sh -abctl tools scan --write ./cortex-ca/demo.yaml +abctl tools scan --write ~/.cortex/demo/demo.yaml ``` The config is hot-reloaded, so no restart. A reload does rebuild the plugin and diff --git a/authbridge/install-demo.sh b/authbridge/install-demo.sh index ac002ebf7..25fd47c28 100755 --- a/authbridge/install-demo.sh +++ b/authbridge/install-demo.sh @@ -12,7 +12,7 @@ # Modes (pass through the pipe with `sh -s --`, e.g. # curl -fsSL ...install-demo.sh | sh -s -- --claude-code): # -# (default) install, then start the throwaway demo in ./cortex-ca +# (default) install, then start the throwaway demo in ~/.cortex/demo # --claude-code install, then set up a PERSISTENT config in ~/.cortex for # cutting Claude Code token cost: writes the config, fills the # tool-prune remove: list from your own transcripts, starts the @@ -35,6 +35,9 @@ set -eu REPO="rossoctl/cortex" BIN_DIR="${HOME}/.local/bin" +# Every file Cortex writes for this user lives here: config, CA, keys, logs, +# pidfiles. One directory to inspect, back up, or delete. +CORTEX_DIR="${HOME}/.cortex" info() { printf '%s\n' "$*"; } warn() { printf 'warning: %s\n' "$*" >&2; } @@ -114,7 +117,7 @@ esac if [ "$MODE" = "demo" ] || [ "$MODE" = "claude-code" ]; then for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do if port_in_use "$p"; then - die "port ${p} is already in use. Is the demo already running (see ./cortex-ca/demo.pid)? Otherwise free the port, or change the ports in ./cortex-ca/demo.yaml, then re-run." + die "port ${p} is already in use. Is Cortex already running (see ${CORTEX_DIR}/demo/demo.pid or ${CORTEX_DIR}/proxy.pid)? Otherwise free the port, or change the ports in the config, then re-run." fi done fi @@ -183,7 +186,7 @@ fi # end of download block # --- report --- proxy="${BIN_DIR}/authbridge-proxy" -ca_dir="$(pwd)/cortex-ca" # matches demoCADirDefault in demo.go +ca_dir="${CORTEX_DIR}/demo" # matches defaultDemoCADir() in demo.go case ":${PATH}:" in *":${BIN_DIR}:"*) abctl_cmd="abctl" proxy_cmd="authbridge-proxy" ;; *) abctl_cmd="${BIN_DIR}/abctl" proxy_cmd="$proxy" ;; @@ -208,15 +211,16 @@ fi # --- claude-code mode: persistent setup under ~/.cortex, then start --- # -# Separate from --demo because --demo regenerates ./cortex-ca/demo.yaml from a +# Separate from --demo because --demo regenerates its own demo.yaml from a # built-in template on every start, so any edit (like the remove: list this mode # fills in) would be discarded on the next run. A config under ~/.cortex is # outside that path and survives. if [ "$MODE" = "claude-code" ]; then - cfg_dir="${HOME}/.cortex" + cfg_dir="$CORTEX_DIR" cfg="${cfg_dir}/config.yaml" cc_ca_dir="${cfg_dir}/ca" - mkdir -p "$cfg_dir" + # 0700: the generated CA's private key lives under here. + mkdir -p "$cfg_dir" && chmod 700 "$cfg_dir" if [ -f "$cfg" ]; then info "" @@ -323,6 +327,8 @@ fi # --- start in the background, then wait until it's actually listening --- info "" info "Starting the demo in the background..." +# 0700 on the Cortex directory: a CA private key is written beneath it. +mkdir -p "$CORTEX_DIR" && chmod 700 "$CORTEX_DIR" mkdir -p "$ca_dir" log="${ca_dir}/demo.log" pidfile="${ca_dir}/demo.pid" From df79bab5111cbac951bade770b9fcd93c73c2b8c Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 13:47:30 -0400 Subject: [PATCH 03/19] refactor: Drop "demo" from the local install path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing Cortex on a machine is the supported way to run it locally, not a demonstration, so the naming no longer says otherwise. install-demo.sh -> install.sh authbridge-proxy --demo -> --local ~/.cortex/demo/ -> ~/.cortex/local/ ~/.cortex/demo/demo.yaml -> ~/.cortex/local/config.yaml demo.log / demo.pid -> proxy.log / proxy.pid The config filename now matches the persistent one, so there is a single name to know regardless of which mode wrote it. Internals follow: demo.go -> local.go, demoConfigYAML -> builtinConfigYAML, writeDemoConfig -> writeBuiltinConfig, demoMode -> localMode, defaultDemoCADir -> defaultLocalDir. Nothing existing breaks. `--demo` stays a working alias, listed in --help as deprecated rather than hidden — an empty usage string still prints the flag, just with a blank description that reads like a bug — and it warns pointing at --local. install-demo.sh stays as a shim: the old name is in published release notes, so a command already in someone's history would otherwise 404. The shim downloads to a file before running it rather than piping into sh, so a truncated fetch cannot execute as a partial script. Two things fell out of doing this: **authbridge-praxis did not build under GOWORK=off** — the same missing gjson go.sum entry that broke abctl, from the same #849 import. It was in NO workflow: not ci.yaml, not build.yaml, not release-binaries.yaml. That is why it drifted. Tidied, and added to the CI matrix. cpex stays out deliberately: it needs CGO and libcpex_ffi.a from a pinned release, so build.yaml's image build is the right place for it. **Two comments were quietly wrong.** caTrustPath explained itself with "--demo anchors the CA to its launch directory", which stopped being true when the default moved under $HOME; it now gives the reason that still holds (a client is configured with this path and a mismatch fails silently). And authbridge-praxis told users to "use --demo for the local demo" — a flag praxis has never had. I also introduced two of my own here and caught them by reading the diff rather than trusting the rename: a blanket --demo -> --local replace turned the deprecation notice into "--local has been renamed to --local" and clobbered the comment above it. Both fixed and verified in the log output. Verified: --local writes ~/.cortex/local/config.yaml and leaves the cwd empty; --demo still works and warns; --help lists both; installer's --claude-code, default, and --install-only modes all run end to end; five modules build/vet/test clean under GOWORK=off; authlib passes -race; shellcheck clean on both scripts. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .github/workflows/ci.yaml | 7 + .gitignore | 4 + README.md | 6 +- authbridge/cmd/abctl/toolscan/patch.go | 2 +- authbridge/cmd/abctl/toolscan/patch_test.go | 4 +- authbridge/cmd/authbridge-praxis/go.mod | 3 + authbridge/cmd/authbridge-praxis/go.sum | 7 + authbridge/cmd/authbridge-praxis/main.go | 2 +- .../authbridge-proxy/{demo.go => local.go} | 47 ++- .../{demo_test.go => local_test.go} | 16 +- authbridge/cmd/authbridge-proxy/main.go | 66 +-- .../session-budget/hitl-with-claude-code.md | 19 +- authbridge/docs/laptop-token-savings.md | 12 +- authbridge/docs/tool-prune-plugin.md | 2 +- authbridge/install-demo.sh | 397 +----------------- authbridge/install.sh | 389 +++++++++++++++++ 16 files changed, 524 insertions(+), 459 deletions(-) rename authbridge/cmd/authbridge-proxy/{demo.go => local.go} (73%) rename authbridge/cmd/authbridge-proxy/{demo_test.go => local_test.go} (90%) create mode 100755 authbridge/install.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bde1c472d..a3bf29fe7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -82,6 +82,13 @@ jobs: # everywhere else resolved it, and the per-module release build did # not — surfacing only when a tag was cut. - abctl + # authbridge-praxis was in no workflow at all, which is how it ended + # up broken under GOWORK=off by the same missing go.sum entry. Any + # cmd/* module absent from every workflow will drift this way — the + # workspace hides exactly this class of breakage. (authbridge-cpex is + # deliberately not here: it needs CGO and libcpex_ffi.a from a pinned + # release, so build.yaml covers it via its image build instead.) + - authbridge-praxis defaults: run: working-directory: authbridge/cmd/${{ matrix.binary }} diff --git a/.gitignore b/.gitignore index 38e13af24..dbd2fa68e 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,7 @@ mlruns/ # used when $HOME is unresolvable, and any explicit `--ca-dir ./cortex-ca`, so a # private key can never be committed from a demo run inside a checkout. cortex-ca/ + +# Local git worktrees (git worktree add .worktrees/). Committing these +# gitlinks pins another branch's checkout into this tree, which is never wanted. +.worktrees/ diff --git a/README.md b/README.md index ae38a900c..bef1972a8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — de 1. **Install and start the demo** (macOS/Linux). Downloads two small binaries and starts the proxy in the background: ```sh - curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install-demo.sh | sh + curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh | sh ``` 2. **Open the live viewer** in another terminal: @@ -26,11 +26,11 @@ Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — de abctl --endpoint http://localhost:47601 ``` -3. **Send an agent's traffic through it** — e.g. Claude Code, from the directory where you started the demo: +3. **Send an agent's traffic through it** — e.g. Claude Code, from anywhere (the CA path is fixed, not relative to where you started): ```sh HTTPS_PROXY=http://localhost:47600 \ - NODE_EXTRA_CA_CERTS="$HOME/.cortex/demo/ca.crt" \ + NODE_EXTRA_CA_CERTS="$HOME/.cortex/local/ca.crt" \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude ``` diff --git a/authbridge/cmd/abctl/toolscan/patch.go b/authbridge/cmd/abctl/toolscan/patch.go index eb99d21e6..2b16238d2 100644 --- a/authbridge/cmd/abctl/toolscan/patch.go +++ b/authbridge/cmd/abctl/toolscan/patch.go @@ -37,7 +37,7 @@ func PatchConfig(path string, candidates []string) (changed bool, err error) { abs = path } return false, fmt.Errorf("no config at %s\n"+ - " authbridge-proxy --demo writes demo.yaml under ~/.cortex/demo,\n"+ + " authbridge-proxy --local writes config.yaml under ~/.cortex/local,\n"+ " so run this from there or pass an absolute path. To find it:\n"+ " curl -s localhost:47602/config | grep ca_dir", abs) } diff --git a/authbridge/cmd/abctl/toolscan/patch_test.go b/authbridge/cmd/abctl/toolscan/patch_test.go index 7389ea5a0..8344ac13f 100644 --- a/authbridge/cmd/abctl/toolscan/patch_test.go +++ b/authbridge/cmd/abctl/toolscan/patch_test.go @@ -79,7 +79,7 @@ func TestPatchConfig_TouchesOnlyTheRemoveLine(t *testing.T) { } } -// TestPatchConfig_Idempotent: install-demo.sh may run the scan on every +// TestPatchConfig_Idempotent: install.sh may run the scan on every // invocation, so re-writing the same candidates must not report a change or // rewrite the file. func TestPatchConfig_Idempotent(t *testing.T) { @@ -184,7 +184,7 @@ func TestPatchConfig_MissingFileExplainsWhere(t *testing.T) { t.Fatal("expected an error") } msg := err.Error() - for _, want := range []string{"no config at", "/definitely-not-here/demo.yaml", "--demo", "absolute path", "ca_dir"} { + for _, want := range []string{"no config at", "/definitely-not-here/demo.yaml", "--local", "absolute path", "ca_dir"} { if !strings.Contains(msg, want) { t.Errorf("error should mention %q:\n%s", want, msg) } diff --git a/authbridge/cmd/authbridge-praxis/go.mod b/authbridge/cmd/authbridge-praxis/go.mod index 5f0b00650..188cb34e2 100644 --- a/authbridge/cmd/authbridge-praxis/go.mod +++ b/authbridge/cmd/authbridge-praxis/go.mod @@ -19,6 +19,9 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/authbridge/cmd/authbridge-praxis/go.sum b/authbridge/cmd/authbridge-praxis/go.sum index 7add9931a..9c3e7876e 100644 --- a/authbridge/cmd/authbridge-praxis/go.sum +++ b/authbridge/cmd/authbridge-praxis/go.sum @@ -51,6 +51,13 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= diff --git a/authbridge/cmd/authbridge-praxis/main.go b/authbridge/cmd/authbridge-praxis/main.go index cb3ea2a91..7c2e272b1 100644 --- a/authbridge/cmd/authbridge-praxis/main.go +++ b/authbridge/cmd/authbridge-praxis/main.go @@ -116,7 +116,7 @@ func main() { runtimeutil.StartSignalToggle() if *configPath == "" { - log.Fatal("--config is required (or use --demo for the local demo)") + log.Fatal("--config is required") } // Build the SPIFFE Provider when the spiffe block is configured. The diff --git a/authbridge/cmd/authbridge-proxy/demo.go b/authbridge/cmd/authbridge-proxy/local.go similarity index 73% rename from authbridge/cmd/authbridge-proxy/demo.go rename to authbridge/cmd/authbridge-proxy/local.go index a89779fb1..588201b57 100644 --- a/authbridge/cmd/authbridge-proxy/demo.go +++ b/authbridge/cmd/authbridge-proxy/local.go @@ -12,16 +12,19 @@ import ( // scattered into whichever directory each command happened to run from. const ( cortexDirName = ".cortex" - // demoDirName keeps --demo's throwaway state out of the persistent config's - // way. --demo regenerates its config, so it must not share a directory with - // the config a user maintains by hand. - demoDirName = "demo" - // demoCADirFallback is used only when the home directory cannot be + // localDirName keeps --local's regenerated state out of the persistent + // config's way. --local rewrites its config from the built-in preset, so it + // must not share a directory with a config a user maintains by hand. + localDirName = "local" + // localConfigName matches the persistent config's filename, so there is one + // name to know regardless of which mode wrote it. + localConfigName = "config.yaml" + // localDirFallback is used only when the home directory cannot be // determined, which is the historical cwd-relative behaviour. - demoCADirFallback = "cortex-ca" + localDirFallback = "cortex-ca" ) -// defaultDemoCADir returns the directory --demo works in: ~/.cortex/demo, or +// defaultLocalDir returns the directory --local works in: ~/.cortex/local, or // ./cortex-ca if there is no resolvable home directory. // // This used to be cwd-relative unconditionally, on the reasoning that no @@ -29,15 +32,15 @@ const ( // satisfies that while keeping the private key in one predictable place — and // the cwd default had a real cost: it dropped a CA and private key into // whatever directory the demo was started from, including checkouts. -func defaultDemoCADir() string { +func defaultLocalDir() string { home, err := os.UserHomeDir() if err != nil || home == "" { - return demoCADirFallback + return localDirFallback } - return filepath.Join(home, cortexDirName, demoDirName) + return filepath.Join(home, cortexDirName, localDirName) } -// demoConfigYAML returns the built-in --demo config with caDir interpolated: a +// builtinConfigYAML returns the built-in --local config with caDir interpolated: a // forward-only proxy with the TLS bridge on (auto-generated CA in caDir) and // the LLM / MCP / A2A parsers, so an agent's egress is decrypted and parsed. // Kept in sync with the root README. @@ -48,14 +51,14 @@ func defaultDemoCADir() string { // decrypted bodies and any injected tokens) to the LAN, and (b) the usual // 8081/909x ports collide with common dev tools. The preset only fills empty // addresses, so these explicit values win — keep them in sync with the ports -// the installer probes and prints (authbridge/install-demo.sh). The +// the installer probes and prints (authbridge/install.sh). The // enforce-redirect transparent listener isn't used here (no iptables) and -// main.go skips starting it under --demo. +// main.go skips starting it under --local. // // The YAML body is flush-left on purpose — a raw string literal preserves // leading whitespace, so indenting these lines in source would corrupt the YAML. -func demoConfigYAML(caDir string) string { - return `# Built-in config for: authbridge-proxy --demo +func builtinConfigYAML(caDir string) string { + return `# Built-in config for: authbridge-proxy --local # Forward-only proxy + TLS bridge (auto-generated CA) + LLM/MCP/A2A parsers. # The running proxy watches this file — edit it to hot-reload. mode: proxy-sidecar @@ -96,22 +99,22 @@ pipeline: ` } -// writeDemoConfig ensures the built-in --demo config exists next to the CA (in -// caDir) and returns its path, so --demo reuses the normal file-based load + +// writeBuiltinConfig ensures the built-in --local config exists next to the CA (in +// caDir) and returns its path, so --local reuses the normal file-based load + // hot-reload path. caDir is caller-resolved (cwd-relative by default, or // --ca-dir); no absolute path is baked into the binary. // // An existing file is KEPT, not overwritten. The config's own header invites // editing it, and `abctl tools scan --write` writes a prune list into it — and // this function runs before any port is bound, so an unconditional write meant -// that even a --demo start which then failed on a port clash silently destroyed +// that even a --local start which then failed on a port clash silently destroyed // those edits. Delete the file to regenerate the preset. -func writeDemoConfig(caDir string) (string, error) { - // 0700: this directory holds the demo CA's private key. +func writeBuiltinConfig(caDir string) (string, error) { + // 0700: this directory holds the local CA's private key. if err := os.MkdirAll(caDir, 0o700); err != nil { return "", err } - path := filepath.Join(caDir, "demo.yaml") + path := filepath.Join(caDir, localConfigName) if _, err := os.Stat(path); err == nil { slog.Info("demo mode — keeping the existing config (edits and any prune list are preserved)", "path", path, "hint", "delete it to regenerate the built-in preset") @@ -119,7 +122,7 @@ func writeDemoConfig(caDir string) (string, error) { } else if !errors.Is(err, os.ErrNotExist) { return "", err } - if err := os.WriteFile(path, []byte(demoConfigYAML(caDir)), 0o644); err != nil { + if err := os.WriteFile(path, []byte(builtinConfigYAML(caDir)), 0o644); err != nil { return "", err } return path, nil diff --git a/authbridge/cmd/authbridge-proxy/demo_test.go b/authbridge/cmd/authbridge-proxy/local_test.go similarity index 90% rename from authbridge/cmd/authbridge-proxy/demo_test.go rename to authbridge/cmd/authbridge-proxy/local_test.go index 31eae9df0..81149a173 100644 --- a/authbridge/cmd/authbridge-proxy/demo_test.go +++ b/authbridge/cmd/authbridge-proxy/local_test.go @@ -10,16 +10,16 @@ import ( "github.com/rossoctl/cortex/authbridge/authlib/config" ) -// writeDemoConfig must produce a config file inside caDir that loads, presets, +// writeBuiltinConfig must produce a config file inside caDir that loads, presets, // and validates cleanly and describes a forward-only TLS-bridge observe -// pipeline pointed at that dir — otherwise --demo would fail at boot instead of +// pipeline pointed at that dir — otherwise --local would fail at boot instead of // giving users a working, hot-reloadable local demo. func TestDemoConfig_WriteLoadsAndValidates(t *testing.T) { caDir := t.TempDir() - p, err := writeDemoConfig(caDir) + p, err := writeBuiltinConfig(caDir) if err != nil { - t.Fatalf("writeDemoConfig: %v", err) + t.Fatalf("writeBuiltinConfig: %v", err) } if filepath.Dir(p) != caDir { t.Errorf("config written to %q, want inside %q", p, caDir) @@ -47,7 +47,7 @@ func TestDemoConfig_WriteLoadsAndValidates(t *testing.T) { // installer probes/prints, never a wildcard that would expose an open forward // proxy, the stats endpoint, or the unauthenticated session API (decrypted // bodies + injected tokens) to the LAN. The transparent listener isn't started - // under --demo (main.go gates it), so it's not asserted here. + // under --local (main.go gates it), so it's not asserted here. if got := cfg.Listener.ForwardProxyAddr; got != "127.0.0.1:47600" { t.Errorf("ForwardProxyAddr = %q, want loopback 127.0.0.1:47600", got) } @@ -105,11 +105,11 @@ func TestDemoConfig_WriteLoadsAndValidates(t *testing.T) { // TestWriteDemoConfig_PreservesAnExistingFile: the config's own header invites // editing it, and `abctl tools scan --write` writes a prune list into it. This // function also runs before any port is bound, so an unconditional overwrite -// meant a --demo start that then failed on a port clash silently destroyed those +// meant a --local start that then failed on a port clash silently destroyed those // edits — which is exactly how a populated remove list was lost in practice. func TestWriteDemoConfig_PreservesAnExistingFile(t *testing.T) { caDir := t.TempDir() - p, err := writeDemoConfig(caDir) + p, err := writeBuiltinConfig(caDir) if err != nil { t.Fatal(err) } @@ -118,7 +118,7 @@ func TestWriteDemoConfig_PreservesAnExistingFile(t *testing.T) { t.Fatal(err) } // A second call — a restart — must not clobber it. - p2, err := writeDemoConfig(caDir) + p2, err := writeBuiltinConfig(caDir) if err != nil { t.Fatal(err) } diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 956cd31f8..ee899da15 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -59,13 +59,13 @@ import ( // via -ldflags "-X main.version=". Defaults to "dev" for local builds. var version = "dev" -// demoMode is set by --demo. It suppresses listeners that only make sense with +// localMode is set by --local. It suppresses listeners that only make sense with // iptables enforce-redirect: the demo uses cooperative HTTPS_PROXY, so nothing // is ever REDIRECTed to the transparent listener and opening it would just be // an idle port. The forward-role preset defaults transparent_proxy_addr to // :8082, and config can't unset it (the preset refills an empty value), so this // gate is the only way to keep the demo to the listeners it actually uses. -var demoMode bool +var localMode bool // spiffeProviderNeeded reports whether any configured feature actually consumes // the SPIFFE Provider: top-level mTLS (needs the X509Source on both listeners) @@ -122,10 +122,15 @@ func pluginUsesSPIFFEIdentity(p config.PluginEntry) bool { func main() { configPath := flag.String("config", "", "path to config YAML file") showVersion := flag.Bool("version", false, "print version and exit") - demo := flag.Bool("demo", false, - "run a built-in local demo (forward-only TLS bridge + protocol parsers) that decrypts and parses an agent's egress; no --config, cluster, Keycloak, or SPIRE needed") + local := flag.Bool("local", false, + "run with a built-in local config (forward-only TLS bridge + protocol parsers) that decrypts and parses an agent's egress; no --config, cluster, Keycloak, or SPIRE needed") + // --demo is what this flag used to be called. Kept working so a command + // already in someone's shell history or notes does not start failing, and + // listed as a deprecated alias rather than hidden: an empty usage string + // still prints the flag, just with a blank description that reads like a bug. + demoDeprecated := flag.Bool("demo", false, "deprecated alias for -local") caDir := flag.String("ca-dir", "", - "CA directory for --demo (auto-generated); defaults to ~/"+cortexDirName+"/"+demoDirName) + "CA directory for --local (auto-generated); defaults to ~/"+cortexDirName+"/"+localDirName) flag.Parse() if *showVersion { @@ -136,44 +141,48 @@ func main() { runtimeutil.InitLogging("authbridge-proxy") runtimeutil.StartSignalToggle() - if *demo { - demoMode = true + if *demoDeprecated && !*local { + slog.Warn("--demo has been renamed to --local; it still works but will be removed", + "use", "--local") + } + if *local || *demoDeprecated { + localMode = true if *configPath != "" { - log.Fatal("--demo and --config are mutually exclusive") + log.Fatal("--local and --config are mutually exclusive") } dir := *caDir if dir == "" { - dir = defaultDemoCADir() + dir = defaultLocalDir() // The default moved here from ./cortex-ca. Someone who still has that // directory almost certainly has a client trusting the CA inside it, // and pointing at a stale CA fails silently — every request tunnels // through opaquely and no plugin sees a body. Name both paths. - if st, serr := os.Stat(demoCADirFallback); serr == nil && st.IsDir() { - slog.Warn("demo mode — the default CA directory is now under $HOME; the ./"+demoCADirFallback+" here is no longer used", + if st, serr := os.Stat(localDirFallback); serr == nil && st.IsDir() { + slog.Warn("local mode — the default CA directory is now under $HOME; the ./"+localDirFallback+" here is no longer used", "now_using", dir, - "ignored", demoCADirFallback, - "hint", "update the client's CA path (e.g. NODE_EXTRA_CA_CERTS), or pass --ca-dir ./"+demoCADirFallback+" to keep the old location") + "ignored", localDirFallback, + "hint", "update the client's CA path (e.g. NODE_EXTRA_CA_CERTS), or pass --ca-dir ./"+localDirFallback+" to keep the old location") } } abs, aerr := filepath.Abs(dir) if aerr != nil { - log.Fatalf("--demo: resolving --ca-dir %q: %v", dir, aerr) + log.Fatalf("--local: resolving --ca-dir %q: %v", dir, aerr) } // Write the built-in config next to the CA and drive the normal // file-based load + hot-reload path — so editing the file reloads live. - p, werr := writeDemoConfig(abs) + p, werr := writeBuiltinConfig(abs) if werr != nil { - log.Fatalf("--demo: %v", werr) + log.Fatalf("--local: %v", werr) } *configPath = p - slog.Info("demo mode — wrote built-in config next to the CA; edit it to hot-reload", + slog.Info("local mode — wrote built-in config next to the CA; edit it to hot-reload", "config", p, "ca_dir", abs) } else if *caDir != "" { - log.Fatal("--ca-dir only applies with --demo") + log.Fatal("--ca-dir only applies with --local") } if *configPath == "" { - log.Fatal("--config is required (or use --demo for the local demo)") + log.Fatal("--config is required (or use --local for a built-in local config)") } // Build the SPIFFE Provider when the spiffe block is configured. The @@ -412,9 +421,9 @@ func main() { log.Fatalf("creating transparent inbound proxy: %v", rerr) } rpSrv.Shared = sharedStore - // Skipped in --demo: there is no iptables there, so nothing would ever + // Skipped in --local: there is no iptables there, so nothing would ever // be REDIRECTed to the listener and every request would fail closed. - if demoMode { + if localMode { slog.Warn("demo mode: transparent inbound listener not started (no iptables to REDIRECT to it)") } else { rpHTTP, rerr := runtimeutil.StartTransparentInboundServer("transparent-inbound", rpSrv, cfg.Listener.TransparentInboundAddr) @@ -466,8 +475,8 @@ func main() { // forward proxy's outbound pipeline via HandleTransparentConn, so explicit // HTTP_PROXY egress and iptables-REDIRECTed bypass egress are gated and // tunnelled identically. Closed explicitly on shutdown (not an *http.Server). - // Skipped in --demo: no iptables there, so nothing is ever REDIRECTed to it. - if !demoMode { + // Skipped in --local: no iptables there, so nothing is ever REDIRECTed to it. + if !localMode { transparentLn = startTransparentProxy(fpSrv, cfg.Listener.TransparentProxyAddr) } } @@ -569,10 +578,13 @@ func startTransparentProxy(fp *forwardproxy.Server, addr string) *net.TCPListene return ln } -// caTrustPath returns the absolute path of the CA clients must trust. Absolute -// because --demo anchors the CA to its launch directory, so a relative path in -// a log line is only correct for someone standing in that same directory — -// which is exactly how the trust anchor gets mismatched. +// caTrustPath returns the absolute path of the CA clients must trust. +// +// Absolute because a client is configured with this path (NODE_EXTRA_CA_CERTS +// and friends) and a mismatched trust anchor fails silently — every request +// tunnels through opaquely and no plugin sees a body. --local now resolves under +// $HOME rather than the launch directory, which removes most of the ways that +// happened, but --ca-dir still accepts a relative path. func caTrustPath(caDir string) string { p := filepath.Join(caDir, "ca.crt") if abs, err := filepath.Abs(p); err == nil { diff --git a/authbridge/demos/session-budget/hitl-with-claude-code.md b/authbridge/demos/session-budget/hitl-with-claude-code.md index 3efc43675..e2f5e9f76 100644 --- a/authbridge/demos/session-budget/hitl-with-claude-code.md +++ b/authbridge/demos/session-budget/hitl-with-claude-code.md @@ -7,7 +7,7 @@ pause-mode contract — the new pieces are a TLS bridge (so HTTPS from the client. If you don't need an Anthropic account in the loop, use [`hitl-local.md`](hitl-local.md) instead — same demo, less setup. -The [README quickstart][qs] `install-demo.sh` binary isn't compiled +The [README quickstart][qs] `install.sh` binary isn't compiled with `include_plugin_sessionbudget`, so this doc builds `authbridge-proxy` from source with the tag on. @@ -62,14 +62,14 @@ there even in `roles: [forward]`, and a stale `authbridge-proxy` from a prior run will fail boot with `address already in use`. If that happens: `pkill -f authbridge-proxy` and relaunch. -The proxy generates `~/.cortex/demo/ca.crt` on first launch — that's the +The proxy generates `~/.cortex/local/ca.crt` on first launch — that's the trust anchor Claude Code needs. The path is the same wherever you start the proxy from, so there is no `$PWD` to keep track of. Look for these lines in the log: ```text -level=WARN msg="tls-bridge: generated self-signed CA ..." ca_dir=/Users/you/.cortex/demo ... -level=INFO msg="tls-bridge enabled" ca_dir=/Users/you/.cortex/demo +level=WARN msg="tls-bridge: generated self-signed CA ..." ca_dir=/Users/you/.cortex/local ... +level=INFO msg="tls-bridge enabled" ca_dir=/Users/you/.cortex/local level=INFO msg="HTTP server listening" name=forward-proxy addr=127.0.0.1:47600 level=INFO msg="authbridge-proxy starting" mode=proxy-sidecar ``` @@ -85,7 +85,7 @@ settings and out of `~/.claude/settings.json`): { "env": { "HTTPS_PROXY": "http://127.0.0.1:47600", - "NODE_EXTRA_CA_CERTS": "/Users/you/.cortex/demo/ca.crt", + "NODE_EXTRA_CA_CERTS": "/Users/you/.cortex/local/ca.crt", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" } } @@ -168,7 +168,7 @@ accumulating. Skip the approver terminal entirely. See [`hitl-local.md`](hitl-local.md) — [§ Reset between runs](hitl-local.md#reset-between-runs), [§ Auto modes for CI](hitl-local.md#auto-modes-for-ci), -[§ Cleanup](hitl-local.md#cleanup). Add `rm -rf ~/.cortex/demo` in the +[§ Cleanup](hitl-local.md#cleanup). Add `rm -rf ~/.cortex/local` in the directory the proxy ran from if you want to regenerate the CA on the next run — the new `ca.crt` has a fresh serial, so re-point `NODE_EXTRA_CA_CERTS` in `.claude/settings.local.json` at it (same @@ -181,7 +181,8 @@ will fail the TLS handshake against the proxy. bucket. Fine for a single-workload laptop demo; in multi-tenant deployments one caller exhausting the budget denies all others. Leave it off in production and rely on the inbound A2A session ID. -- **`--demo` has its own config at `~/.cortex/demo/demo.yaml`.** It no +- **`--local` has its own config at `~/.cortex/local/config.yaml`.** It no longer depends on which directory you start from, so it cannot clobber - a config kept elsewhere — but two `--demo` runs share that one file. - Point one of them at `--ca-dir` if you need two independent demos. + a config kept elsewhere — but two `--local` runs share that one file. + Point one of them at `--ca-dir` if you need two independent runs. + (`--demo` is the old name for this flag and still works.) diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index 352851d18..b9b1628d4 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -11,7 +11,7 @@ macOS or Linux, amd64 or arm64. No cluster, Keycloak, or SPIRE. ## 1. Install and start ```sh -curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install-demo.sh \ +curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh \ | sh -s -- --claude-code ``` @@ -32,7 +32,7 @@ directory you happened to run a command from: │ └── tls.key private key, never leaves this machine ├── proxy.log ├── proxy.pid -└── demo/ only if you run the throwaway demo (see below) +└── local/ only if you use the built-in config (see below) ``` The directory is created `0700`, because the CA private key is under it. To @@ -125,9 +125,11 @@ client-side settings (`--allowedTools`, disabling unused MCP servers). ## Other ways in - **Just the binaries**, no setup: `... | sh -s -- --install-only`. -- **A throwaway demo** instead of a persistent config: `... | sh` with no - arguments. It keeps its own regenerated config and CA in `~/.cortex/demo`, so - it never touches the `config.yaml` above. +- **A built-in config** instead of a tailored one: `... | sh` with no arguments, + or `authbridge-proxy --local`. It regenerates its own config and CA in + `~/.cortex/local`, so it never touches the `config.yaml` above. Use it to look + at decrypted agent traffic through the protocol parsers; use `--claude-code` + for cutting token cost. - **Pin a version**: `AUTHBRIDGE_VERSION=vX.Y.Z`. - **Re-run setup offline**, using the binaries you already have: `AUTHBRIDGE_SKIP_DOWNLOAD=1`. diff --git a/authbridge/docs/tool-prune-plugin.md b/authbridge/docs/tool-prune-plugin.md index 8af147c25..99a52235b 100644 --- a/authbridge/docs/tool-prune-plugin.md +++ b/authbridge/docs/tool-prune-plugin.md @@ -47,7 +47,7 @@ nothing, whatever the policy, so filling the list is the single act that enables it: ```sh -abctl tools scan --write ~/.cortex/demo/demo.yaml +abctl tools scan --write ~/.cortex/local/config.yaml ``` The config is hot-reloaded, so no restart. A reload does rebuild the plugin and diff --git a/authbridge/install-demo.sh b/authbridge/install-demo.sh index 25fd47c28..228532fed 100755 --- a/authbridge/install-demo.sh +++ b/authbridge/install-demo.sh @@ -1,388 +1,25 @@ #!/bin/sh -# install-demo.sh — one-line installer + launcher for the Cortex local demo. +# install-demo.sh — renamed to install.sh. # -# curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install-demo.sh | sh +# This shim exists because the old name was published in release notes and docs, +# so a command already in someone's shell history or notes would otherwise 404. +# It forwards every argument to the current script and will be removed once the +# old URL stops being fetched. # -# Detects your OS/arch, downloads the prebuilt `abctl` and `authbridge-proxy` -# binaries for the newest release, verifies their SHA-256 checksums, installs -# them to ~/.local/bin, and starts the demo in the background — then prints the -# commands to watch traffic and point an agent at it, plus how to stop it. -# macOS + Linux, amd64 + arm64. No cluster, Keycloak, or SPIRE needed. -# -# Modes (pass through the pipe with `sh -s --`, e.g. -# curl -fsSL ...install-demo.sh | sh -s -- --claude-code): -# -# (default) install, then start the throwaway demo in ~/.cortex/demo -# --claude-code install, then set up a PERSISTENT config in ~/.cortex for -# cutting Claude Code token cost: writes the config, fills the -# tool-prune remove: list from your own transcripts, starts the -# proxy, and prints the exact command to run Claude Code -# through it. Safe to re-run; never overwrites an existing -# config. -# --install-only install the binaries and stop -# -# Flags exist because the env-var form has a trap: written -# `VAR=1 curl ... | sh` the variable reaches curl, not sh, so the script runs -# without it. `sh -s -- --flag` has no such failure mode. The env vars below -# still work for backward compatibility. -# -# Environment: -# AUTHBRIDGE_VERSION=vX.Y.Z install a specific release tag (default: newest) -# AUTHBRIDGE_INSTALL_ONLY=1 same as --install-only -# AUTHBRIDGE_SKIP_DOWNLOAD=1 use the already-installed binaries in ~/.local/bin -# instead of downloading (re-run setup offline) +# Local installs are no longer framed as a "demo" — they are the supported way to +# run Cortex on a machine — hence the rename. set -eu -REPO="rossoctl/cortex" -BIN_DIR="${HOME}/.local/bin" -# Every file Cortex writes for this user lives here: config, CA, keys, logs, -# pidfiles. One directory to inspect, back up, or delete. -CORTEX_DIR="${HOME}/.cortex" - -info() { printf '%s\n' "$*"; } -warn() { printf 'warning: %s\n' "$*" >&2; } -die() { printf 'error: %s\n' "$*" >&2; exit 1; } - -# --- mode selection --- -MODE=demo -for arg in "$@"; do - case "$arg" in - --claude-code) MODE=claude-code ;; - --install-only) MODE=install-only ;; - --demo) MODE=demo ;; - -h | --help) - sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' - exit 0 - ;; - *) die "unknown option: $arg (try --claude-code, --install-only, or no argument)" ;; - esac -done -# Env form kept working; the flag wins if both are given. -if [ "${AUTHBRIDGE_INSTALL_ONLY:-}" = "1" ] && [ "$MODE" = "demo" ]; then - MODE=install-only -fi - -command -v curl >/dev/null 2>&1 || die "curl is required" -command -v tar >/dev/null 2>&1 || die "tar is required" - -# Verify the checklist file passed as $1 (run from the directory holding the -# files). shasum is preferred: it's always present on macOS and its -c reads the -# GNU-style checksums.txt reliably, whereas some non-GNU sha256sum builds reject -# -c. Linux without shasum falls back to sha256sum (GNU coreutils). -sha_check() { - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 -c "$1" - elif command -v sha256sum >/dev/null 2>&1; then - sha256sum -c "$1" - else - die "need shasum or sha256sum to verify downloads" - fi -} - -# Demo listener ports — loopback, and deliberately uncommon to avoid colliding -# with common dev tools. Keep in sync with the demo config in -# authbridge/cmd/authbridge-proxy/demo.go. -DEMO_FORWARD_PORT=47600 -DEMO_SESSION_PORT=47601 -DEMO_STATS_PORT=47602 - -# port_in_use exits 0 if something is already listening on the given loopback -# port. Best-effort: uses lsof, then nc; if neither exists, it assumes free. -port_in_use() { - if command -v lsof >/dev/null 2>&1; then - lsof -nP -iTCP@127.0.0.1:"$1" -sTCP:LISTEN >/dev/null 2>&1 - elif command -v nc >/dev/null 2>&1; then - nc -z 127.0.0.1 "$1" >/dev/null 2>&1 - else - return 1 - fi -} - -# --- detect platform --- -os=$(uname -s) -case "$os" in - Darwin) os=darwin ;; - Linux) os=linux ;; - *) die "unsupported OS: $os (the demo installer supports macOS and Linux)" ;; -esac - -arch=$(uname -m) -case "$arch" in - x86_64 | amd64) arch=amd64 ;; - arm64 | aarch64) arch=arm64 ;; - *) die "unsupported architecture: $arch (supported: amd64, arm64)" ;; -esac - -# --- preflight: fail early (before downloading) if a demo port is taken --- -if [ "$MODE" = "demo" ] || [ "$MODE" = "claude-code" ]; then - for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do - if port_in_use "$p"; then - die "port ${p} is already in use. Is Cortex already running (see ${CORTEX_DIR}/demo/demo.pid or ${CORTEX_DIR}/proxy.pid)? Otherwise free the port, or change the ports in the config, then re-run." - fi - done -fi - -# --- skip the download entirely when asked (offline re-run) --- -if [ "${AUTHBRIDGE_SKIP_DOWNLOAD:-}" = "1" ]; then - for b in abctl authbridge-proxy; do - [ -x "${BIN_DIR}/${b}" ] || die "AUTHBRIDGE_SKIP_DOWNLOAD=1 but ${BIN_DIR}/${b} is missing" - done - version="(already installed)" - info "Using the binaries already in ${BIN_DIR}" -else - -# --- resolve the release tag --- -# `releases/latest` excludes prereleases, and the project ships prereleases, so -# list releases (newest first) and take the first tag_name instead. -version="${AUTHBRIDGE_VERSION:-}" -if [ -z "$version" ]; then - info "Resolving newest release..." - version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" \ - | grep -m1 '"tag_name"' | sed -e 's/.*"tag_name": *"//' -e 's/".*//') - [ -n "$version" ] || die "could not resolve the newest release (set AUTHBRIDGE_VERSION=vX.Y.Z)" -fi -info "Release: $version" - -# --- download + verify --- -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT - -base="https://github.com/${REPO}/releases/download/${version}" -abctl_tgz="abctl_${version}_${os}_${arch}.tar.gz" -proxy_tgz="authbridge-proxy_${version}_${os}_${arch}.tar.gz" - -info "Downloading binaries for ${os}/${arch}..." -curl -fsSL "${base}/${abctl_tgz}" -o "${tmp}/${abctl_tgz}" || die "download failed: ${abctl_tgz}" -curl -fsSL "${base}/${proxy_tgz}" -o "${tmp}/${proxy_tgz}" || die "download failed: ${proxy_tgz}" -curl -fsSL "${base}/checksums.txt" -o "${tmp}/checksums.txt" || die "download failed: checksums.txt" - -info "Verifying checksums..." -# Match exactly the two archives we downloaded (anchored to the end of the line), -# not every entry for this platform — so an unrelated future artifact in -# checksums.txt can't make verification fail on a file we never fetched. -grep -E "(${abctl_tgz}|${proxy_tgz})\$" "${tmp}/checksums.txt" > "${tmp}/checksums.filtered" \ - || die "no checksum entries for ${abctl_tgz} / ${proxy_tgz} in checksums.txt" -( cd "$tmp" && sha_check checksums.filtered ) || die "checksum verification failed" - -# --- extract + install --- -info "Installing to ${BIN_DIR}..." -mkdir -p "$BIN_DIR" -tar -xzf "${tmp}/${abctl_tgz}" -C "$tmp" -tar -xzf "${tmp}/${proxy_tgz}" -C "$tmp" -for b in abctl authbridge-proxy; do - [ -f "${tmp}/${b}" ] || die "archive did not contain expected binary: ${b}" - chmod +x "${tmp}/${b}" - mv -f "${tmp}/${b}" "${BIN_DIR}/${b}" -done - -# macOS: clear the quarantine flag so Gatekeeper doesn't block the unsigned binaries. -if [ "$os" = "darwin" ] && command -v xattr >/dev/null 2>&1; then - xattr -dr com.apple.quarantine "${BIN_DIR}/abctl" "${BIN_DIR}/authbridge-proxy" 2>/dev/null || true -fi - -rm -rf "$tmp" -trap - EXIT -fi # end of download block - -# --- report --- -proxy="${BIN_DIR}/authbridge-proxy" -ca_dir="${CORTEX_DIR}/demo" # matches defaultDemoCADir() in demo.go -case ":${PATH}:" in - *":${BIN_DIR}:"*) abctl_cmd="abctl" proxy_cmd="authbridge-proxy" ;; - *) abctl_cmd="${BIN_DIR}/abctl" proxy_cmd="$proxy" ;; -esac - -info "" -info "Installed abctl and authbridge-proxy (${version}) to ${BIN_DIR}" -case ":${PATH}:" in - *":${BIN_DIR}:"*) ;; - *) - warn "${BIN_DIR} is not on your PATH." - warn "Add it for future sessions: export PATH=\"${BIN_DIR}:\$PATH\"" - ;; -esac - -if [ "$MODE" = "install-only" ]; then - info "" - info "Install-only mode. Start the demo with: ${proxy_cmd} --demo" - info "Or set up persistent Claude Code cost-cutting: re-run with --claude-code" - exit 0 -fi - -# --- claude-code mode: persistent setup under ~/.cortex, then start --- -# -# Separate from --demo because --demo regenerates its own demo.yaml from a -# built-in template on every start, so any edit (like the remove: list this mode -# fills in) would be discarded on the next run. A config under ~/.cortex is -# outside that path and survives. -if [ "$MODE" = "claude-code" ]; then - cfg_dir="$CORTEX_DIR" - cfg="${cfg_dir}/config.yaml" - cc_ca_dir="${cfg_dir}/ca" - # 0700: the generated CA's private key lives under here. - mkdir -p "$cfg_dir" && chmod 700 "$cfg_dir" - - if [ -f "$cfg" ]; then - info "" - info "Keeping your existing config: ${cfg}" - else - info "" - info "Writing ${cfg}" - # ${HOME} is left literal on purpose: authbridge expands ${ENV_VAR} when it - # loads the file, so the config stays portable and needs no path rewriting - # after the fact. - cat >"$cfg" <<'YAML' -# Cortex — cut Claude Code token cost by pruning unused tool definitions. -# Written by install-demo.sh --claude-code. Safe to edit; the proxy hot-reloads. -mode: proxy-sidecar -listener: - roles: [forward] - forward_proxy_addr: 127.0.0.1:47600 - session_api_addr: 127.0.0.1:47601 - # The proxy-sidecar preset turns this listener on and refills it if emptied, so - # pin it to loopback on an uncommon port. Left at its ":8082" default it binds - # every interface — on a laptop that means the LAN — and collides with anything - # else already using 8082. - transparent_proxy_addr: 127.0.0.1:47603 - # Same reasoning: the default ":9091" is every-interface and collides with any - # other authbridge on the host. - health_addr: 127.0.0.1:47604 -stats: - address: 127.0.0.1:47602 -tls_bridge: - mode: enabled - ca_dir: "${HOME}/.cortex/ca" - generate_ca: true -pipeline: - outbound: - plugins: - - name: inference-parser - # tool-prune must stay last: it rewrites the request body, and body - # readers have to precede it to see the original bytes. - - name: tool-prune - config: - remove: [] -YAML - fi - - # Fill the remove: list before starting, so no hot-reload round-trip is - # needed. This edits the config the user just asked us to set up, which is - # the point of the mode — but say so, and say how to undo it, because it is - # derived from their transcripts rather than chosen by them. - info "" - info "Choosing tools to prune from your own ~/.claude/projects transcripts..." - if "${BIN_DIR}/abctl" tools scan --write "$cfg"; then - info "" - info "Wrote the remove: list to ${cfg}" - info "To keep a tool, delete its name from that list — the proxy hot-reloads." - else - warn "the scan did not complete; the remove: list is empty, so tool-prune" - warn "will do nothing until you run: ${abctl_cmd} tools scan --write ${cfg}" - fi - - info "" - info "Starting the proxy in the background..." - cc_log="${cfg_dir}/proxy.log" - cc_pidfile="${cfg_dir}/proxy.pid" - nohup "$proxy" --config "$cfg" "$cc_log" 2>&1 & - cc_pid=$! - echo "$cc_pid" >"$cc_pidfile" - - ready=0 - i=0 - while [ "$i" -lt 50 ]; do - if ! kill -0 "$cc_pid" 2>/dev/null; then - warn "the proxy exited during startup — last log lines:" - tail -n 15 "$cc_log" >&2 || true - die "proxy failed to start (full log: ${cc_log})" - fi - if port_in_use "$DEMO_FORWARD_PORT"; then - ready=1 - break - fi - sleep 0.2 - i=$((i + 1)) - done - - info "" - if [ "$ready" -eq 1 ]; then - info "Cortex is running (pid ${cc_pid}). Logs: ${cc_log}" - else - info "Cortex started (pid ${cc_pid}); couldn't confirm it's listening (install lsof or nc to verify). Logs: ${cc_log}" - fi - info "" - info "Run Claude Code through it:" - info "" - info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" - info " NODE_EXTRA_CA_CERTS=${cc_ca_dir}/ca.crt \\" - info " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude" - info "" - info " See what it saved: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" - info " (Plugin pane -> tool-prune -> Metrics)" - info " Stop it: kill \$(cat ${cc_pidfile})" - info "" - exit 0 -fi - -# --- start in the background, then wait until it's actually listening --- -info "" -info "Starting the demo in the background..." -# 0700 on the Cortex directory: a CA private key is written beneath it. -mkdir -p "$CORTEX_DIR" && chmod 700 "$CORTEX_DIR" -mkdir -p "$ca_dir" -log="${ca_dir}/demo.log" -pidfile="${ca_dir}/demo.pid" -nohup "$proxy" --demo "$log" 2>&1 & -demo_pid=$! -echo "$demo_pid" >"$pidfile" +URL="https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh" -# Confirm readiness from real signals, not the "listening" log line — that line is -# emitted just *before* the socket is bound, so a bind failure could look ready. -# A bind failure exits within ms (the proxy Fatalf's), so watch for early exit; -# and probe the forward port for a true post-bind signal. -ready=0 -i=0 -while [ "$i" -lt 50 ]; do - if ! kill -0 "$demo_pid" 2>/dev/null; then - warn "the demo exited during startup — last log lines:" - tail -n 15 "$log" >&2 || true - die "demo failed to start (full log: ${log})" - fi - if port_in_use "$DEMO_FORWARD_PORT"; then - ready=1 - break - fi - sleep 0.2 - i=$((i + 1)) -done +printf '%s\n' "note: install-demo.sh is now install.sh; forwarding to it." >&2 +printf '%s\n' " update your command to: curl -fsSL ${URL} | sh" >&2 -info "" -if [ "$ready" -eq 1 ]; then - info "Cortex demo is running (pid ${demo_pid}). Logs: ${log}" -else - # It didn't exit during the startup window (a bind failure would have killed - # it), but no probe tool confirmed the port — most likely up. Say so honestly. - info "Cortex demo started (pid ${demo_pid}); couldn't confirm it's listening (install lsof or nc to verify). Logs: ${log}" -fi -info "" +command -v curl >/dev/null 2>&1 || { printf 'error: curl is required\n' >&2; exit 1; } -# tool-prune ships inert: the remove list is empty, so it does nothing until a -# name is added. That empty list is the guard — on_error is enforce, because the -# list is what gates the plugin. Offer the scan that fills it in. Only patch a config -# that already exists, so a first run never rewrites a file it just created -# behind the user's back -- print the command instead and let them look first. -demo_cfg="${ca_dir}/demo.yaml" -if [ -f "${demo_cfg}" ]; then - info " Cut tool-manifest waste (fills the remove: list; hot-reloaded, no restart):" - info " ${abctl_cmd} tools scan --write ${demo_cfg}" - info " Then watch the Metrics section of tool-prune's pane in abctl." - info "" -fi -info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" -info " Send traffic through it (e.g. Claude Code):" -info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" -info " NODE_EXTRA_CA_CERTS=${ca_dir}/ca.crt \\" -info " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude" -info "" -info " Stop the demo: kill ${demo_pid} (or: kill \$(cat ${pidfile}))" -info "" +# Download to a file first rather than piping into sh, so a truncated or failed +# fetch cannot execute as a partial script. +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +curl -fsSL "$URL" -o "$tmp" || { printf 'error: could not fetch %s\n' "$URL" >&2; exit 1; } +sh "$tmp" "$@" diff --git a/authbridge/install.sh b/authbridge/install.sh new file mode 100755 index 000000000..1f2499d42 --- /dev/null +++ b/authbridge/install.sh @@ -0,0 +1,389 @@ +#!/bin/sh +# install.sh — one-line installer for Cortex on a local machine. +# +# curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh | sh +# +# Detects your OS/arch, downloads the prebuilt `abctl` and `authbridge-proxy` +# binaries for the newest release, verifies their SHA-256 checksums, installs +# them to ~/.local/bin, and starts Cortex in the background — then prints the +# commands to watch traffic and point an agent at it, plus how to stop it. +# macOS + Linux, amd64 + arm64. No cluster, Keycloak, or SPIRE needed. +# +# Modes (pass through the pipe with `sh -s --`, e.g. +# curl -fsSL ...install.sh | sh -s -- --claude-code): +# +# (default) install, then start with a built-in config in ~/.cortex/local +# --claude-code install, then set up a PERSISTENT config in ~/.cortex for +# cutting Claude Code token cost: writes the config, fills the +# tool-prune remove: list from your own transcripts, starts the +# proxy, and prints the exact command to run Claude Code +# through it. Safe to re-run; never overwrites an existing +# config. +# --install-only install the binaries and stop +# +# Flags exist because the env-var form has a trap: written +# `VAR=1 curl ... | sh` the variable reaches curl, not sh, so the script runs +# without it. `sh -s -- --flag` has no such failure mode. The env vars below +# still work for backward compatibility. +# +# Environment: +# AUTHBRIDGE_VERSION=vX.Y.Z install a specific release tag (default: newest) +# AUTHBRIDGE_INSTALL_ONLY=1 same as --install-only +# AUTHBRIDGE_SKIP_DOWNLOAD=1 use the already-installed binaries in ~/.local/bin +# instead of downloading (re-run setup offline) +set -eu + +REPO="rossoctl/cortex" +BIN_DIR="${HOME}/.local/bin" +# Every file Cortex writes for this user lives here: config, CA, keys, logs, +# pidfiles. One directory to inspect, back up, or delete. +CORTEX_DIR="${HOME}/.cortex" + +info() { printf '%s\n' "$*"; } +warn() { printf 'warning: %s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +# --- mode selection --- +MODE=local +for arg in "$@"; do + case "$arg" in + --claude-code) MODE=claude-code ;; + --install-only) MODE=install-only ;; + # --demo is the old name for the built-in-config run; still accepted. + --local | --demo) MODE=local ;; + -h | --help) + sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) die "unknown option: $arg (try --claude-code, --install-only, or no argument)" ;; + esac +done +# Env form kept working; the flag wins if both are given. +if [ "${AUTHBRIDGE_INSTALL_ONLY:-}" = "1" ] && [ "$MODE" = "local" ]; then + MODE=install-only +fi + +command -v curl >/dev/null 2>&1 || die "curl is required" +command -v tar >/dev/null 2>&1 || die "tar is required" + +# Verify the checklist file passed as $1 (run from the directory holding the +# files). shasum is preferred: it's always present on macOS and its -c reads the +# GNU-style checksums.txt reliably, whereas some non-GNU sha256sum builds reject +# -c. Linux without shasum falls back to sha256sum (GNU coreutils). +sha_check() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 -c "$1" + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "$1" + else + die "need shasum or sha256sum to verify downloads" + fi +} + +# Demo listener ports — loopback, and deliberately uncommon to avoid colliding +# with common dev tools. Keep in sync with the built-in config in +# authbridge/cmd/authbridge-proxy/local.go. +DEMO_FORWARD_PORT=47600 +DEMO_SESSION_PORT=47601 +DEMO_STATS_PORT=47602 + +# port_in_use exits 0 if something is already listening on the given loopback +# port. Best-effort: uses lsof, then nc; if neither exists, it assumes free. +port_in_use() { + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP@127.0.0.1:"$1" -sTCP:LISTEN >/dev/null 2>&1 + elif command -v nc >/dev/null 2>&1; then + nc -z 127.0.0.1 "$1" >/dev/null 2>&1 + else + return 1 + fi +} + +# --- detect platform --- +os=$(uname -s) +case "$os" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) die "unsupported OS: $os (the installer supports macOS and Linux)" ;; +esac + +arch=$(uname -m) +case "$arch" in + x86_64 | amd64) arch=amd64 ;; + arm64 | aarch64) arch=arm64 ;; + *) die "unsupported architecture: $arch (supported: amd64, arm64)" ;; +esac + +# --- preflight: fail early (before downloading) if a listener port is taken --- +if [ "$MODE" = "local" ] || [ "$MODE" = "claude-code" ]; then + for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do + if port_in_use "$p"; then + die "port ${p} is already in use. Is Cortex already running (see ${CORTEX_DIR}/local/proxy.pid or ${CORTEX_DIR}/proxy.pid)? Otherwise free the port, or change the ports in the config, then re-run." + fi + done +fi + +# --- skip the download entirely when asked (offline re-run) --- +if [ "${AUTHBRIDGE_SKIP_DOWNLOAD:-}" = "1" ]; then + for b in abctl authbridge-proxy; do + [ -x "${BIN_DIR}/${b}" ] || die "AUTHBRIDGE_SKIP_DOWNLOAD=1 but ${BIN_DIR}/${b} is missing" + done + version="(already installed)" + info "Using the binaries already in ${BIN_DIR}" +else + +# --- resolve the release tag --- +# `releases/latest` excludes prereleases, and the project ships prereleases, so +# list releases (newest first) and take the first tag_name instead. +version="${AUTHBRIDGE_VERSION:-}" +if [ -z "$version" ]; then + info "Resolving newest release..." + version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" \ + | grep -m1 '"tag_name"' | sed -e 's/.*"tag_name": *"//' -e 's/".*//') + [ -n "$version" ] || die "could not resolve the newest release (set AUTHBRIDGE_VERSION=vX.Y.Z)" +fi +info "Release: $version" + +# --- download + verify --- +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +base="https://github.com/${REPO}/releases/download/${version}" +abctl_tgz="abctl_${version}_${os}_${arch}.tar.gz" +proxy_tgz="authbridge-proxy_${version}_${os}_${arch}.tar.gz" + +info "Downloading binaries for ${os}/${arch}..." +curl -fsSL "${base}/${abctl_tgz}" -o "${tmp}/${abctl_tgz}" || die "download failed: ${abctl_tgz}" +curl -fsSL "${base}/${proxy_tgz}" -o "${tmp}/${proxy_tgz}" || die "download failed: ${proxy_tgz}" +curl -fsSL "${base}/checksums.txt" -o "${tmp}/checksums.txt" || die "download failed: checksums.txt" + +info "Verifying checksums..." +# Match exactly the two archives we downloaded (anchored to the end of the line), +# not every entry for this platform — so an unrelated future artifact in +# checksums.txt can't make verification fail on a file we never fetched. +grep -E "(${abctl_tgz}|${proxy_tgz})\$" "${tmp}/checksums.txt" > "${tmp}/checksums.filtered" \ + || die "no checksum entries for ${abctl_tgz} / ${proxy_tgz} in checksums.txt" +( cd "$tmp" && sha_check checksums.filtered ) || die "checksum verification failed" + +# --- extract + install --- +info "Installing to ${BIN_DIR}..." +mkdir -p "$BIN_DIR" +tar -xzf "${tmp}/${abctl_tgz}" -C "$tmp" +tar -xzf "${tmp}/${proxy_tgz}" -C "$tmp" +for b in abctl authbridge-proxy; do + [ -f "${tmp}/${b}" ] || die "archive did not contain expected binary: ${b}" + chmod +x "${tmp}/${b}" + mv -f "${tmp}/${b}" "${BIN_DIR}/${b}" +done + +# macOS: clear the quarantine flag so Gatekeeper doesn't block the unsigned binaries. +if [ "$os" = "darwin" ] && command -v xattr >/dev/null 2>&1; then + xattr -dr com.apple.quarantine "${BIN_DIR}/abctl" "${BIN_DIR}/authbridge-proxy" 2>/dev/null || true +fi + +rm -rf "$tmp" +trap - EXIT +fi # end of download block + +# --- report --- +proxy="${BIN_DIR}/authbridge-proxy" +ca_dir="${CORTEX_DIR}/local" # matches defaultLocalDir() in local.go +case ":${PATH}:" in + *":${BIN_DIR}:"*) abctl_cmd="abctl" proxy_cmd="authbridge-proxy" ;; + *) abctl_cmd="${BIN_DIR}/abctl" proxy_cmd="$proxy" ;; +esac + +info "" +info "Installed abctl and authbridge-proxy (${version}) to ${BIN_DIR}" +case ":${PATH}:" in + *":${BIN_DIR}:"*) ;; + *) + warn "${BIN_DIR} is not on your PATH." + warn "Add it for future sessions: export PATH=\"${BIN_DIR}:\$PATH\"" + ;; +esac + +if [ "$MODE" = "install-only" ]; then + info "" + info "Install-only mode. Start it with: ${proxy_cmd} --local" + info "Or set up persistent Claude Code cost-cutting: re-run with --claude-code" + exit 0 +fi + +# --- claude-code mode: persistent setup under ~/.cortex, then start --- +# +# Separate from --local because --local regenerates its own config.yaml from a +# built-in template on every start, so any edit (like the remove: list this mode +# fills in) would be discarded on the next run. A config under ~/.cortex is +# outside that path and survives. +if [ "$MODE" = "claude-code" ]; then + cfg_dir="$CORTEX_DIR" + cfg="${cfg_dir}/config.yaml" + cc_ca_dir="${cfg_dir}/ca" + # 0700: the generated CA's private key lives under here. + mkdir -p "$cfg_dir" && chmod 700 "$cfg_dir" + + if [ -f "$cfg" ]; then + info "" + info "Keeping your existing config: ${cfg}" + else + info "" + info "Writing ${cfg}" + # ${HOME} is left literal on purpose: authbridge expands ${ENV_VAR} when it + # loads the file, so the config stays portable and needs no path rewriting + # after the fact. + cat >"$cfg" <<'YAML' +# Cortex — cut Claude Code token cost by pruning unused tool definitions. +# Written by install.sh --claude-code. Safe to edit; the proxy hot-reloads. +mode: proxy-sidecar +listener: + roles: [forward] + forward_proxy_addr: 127.0.0.1:47600 + session_api_addr: 127.0.0.1:47601 + # The proxy-sidecar preset turns this listener on and refills it if emptied, so + # pin it to loopback on an uncommon port. Left at its ":8082" default it binds + # every interface — on a laptop that means the LAN — and collides with anything + # else already using 8082. + transparent_proxy_addr: 127.0.0.1:47603 + # Same reasoning: the default ":9091" is every-interface and collides with any + # other authbridge on the host. + health_addr: 127.0.0.1:47604 +stats: + address: 127.0.0.1:47602 +tls_bridge: + mode: enabled + ca_dir: "${HOME}/.cortex/ca" + generate_ca: true +pipeline: + outbound: + plugins: + - name: inference-parser + # tool-prune must stay last: it rewrites the request body, and body + # readers have to precede it to see the original bytes. + - name: tool-prune + config: + remove: [] +YAML + fi + + # Fill the remove: list before starting, so no hot-reload round-trip is + # needed. This edits the config the user just asked us to set up, which is + # the point of the mode — but say so, and say how to undo it, because it is + # derived from their transcripts rather than chosen by them. + info "" + info "Choosing tools to prune from your own ~/.claude/projects transcripts..." + if "${BIN_DIR}/abctl" tools scan --write "$cfg"; then + info "" + info "Wrote the remove: list to ${cfg}" + info "To keep a tool, delete its name from that list — the proxy hot-reloads." + else + warn "the scan did not complete; the remove: list is empty, so tool-prune" + warn "will do nothing until you run: ${abctl_cmd} tools scan --write ${cfg}" + fi + + info "" + info "Starting the proxy in the background..." + cc_log="${cfg_dir}/proxy.log" + cc_pidfile="${cfg_dir}/proxy.pid" + nohup "$proxy" --config "$cfg" "$cc_log" 2>&1 & + cc_pid=$! + echo "$cc_pid" >"$cc_pidfile" + + ready=0 + i=0 + while [ "$i" -lt 50 ]; do + if ! kill -0 "$cc_pid" 2>/dev/null; then + warn "the proxy exited during startup — last log lines:" + tail -n 15 "$cc_log" >&2 || true + die "proxy failed to start (full log: ${cc_log})" + fi + if port_in_use "$DEMO_FORWARD_PORT"; then + ready=1 + break + fi + sleep 0.2 + i=$((i + 1)) + done + + info "" + if [ "$ready" -eq 1 ]; then + info "Cortex is running (pid ${cc_pid}). Logs: ${cc_log}" + else + info "Cortex started (pid ${cc_pid}); couldn't confirm it's listening (install lsof or nc to verify). Logs: ${cc_log}" + fi + info "" + info "Run Claude Code through it:" + info "" + info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" + info " NODE_EXTRA_CA_CERTS=${cc_ca_dir}/ca.crt \\" + info " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude" + info "" + info " See what it saved: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" + info " (Plugin pane -> tool-prune -> Metrics)" + info " Stop it: kill \$(cat ${cc_pidfile})" + info "" + exit 0 +fi + +# --- start in the background, then wait until it's actually listening --- +info "" +info "Starting Cortex in the background..." +# 0700 on the Cortex directory: a CA private key is written beneath it. +mkdir -p "$CORTEX_DIR" && chmod 700 "$CORTEX_DIR" +mkdir -p "$ca_dir" +log="${ca_dir}/proxy.log" +pidfile="${ca_dir}/proxy.pid" +nohup "$proxy" --local "$log" 2>&1 & +proxy_pid=$! +echo "$proxy_pid" >"$pidfile" + +# Confirm readiness from real signals, not the "listening" log line — that line is +# emitted just *before* the socket is bound, so a bind failure could look ready. +# A bind failure exits within ms (the proxy Fatalf's), so watch for early exit; +# and probe the forward port for a true post-bind signal. +ready=0 +i=0 +while [ "$i" -lt 50 ]; do + if ! kill -0 "$proxy_pid" 2>/dev/null; then + warn "Cortex exited during startup — last log lines:" + tail -n 15 "$log" >&2 || true + die "Cortex failed to start (full log: ${log})" + fi + if port_in_use "$DEMO_FORWARD_PORT"; then + ready=1 + break + fi + sleep 0.2 + i=$((i + 1)) +done + +info "" +if [ "$ready" -eq 1 ]; then + info "Cortex is running (pid ${proxy_pid}). Logs: ${log}" +else + # It didn't exit during the startup window (a bind failure would have killed + # it), but no probe tool confirmed the port — most likely up. Say so honestly. + info "Cortex started (pid ${proxy_pid}); couldn't confirm it's listening (install lsof or nc to verify). Logs: ${log}" +fi +info "" + +# tool-prune ships inert: the remove list is empty, so it does nothing until a +# name is added. That empty list is the guard — on_error is enforce, because the +# list is what gates the plugin. Offer the scan that fills it in. Only patch a config +# that already exists, so a first run never rewrites a file it just created +# behind the user's back -- print the command instead and let them look first. +local_cfg="${ca_dir}/config.yaml" +if [ -f "${local_cfg}" ]; then + info " Cut tool-manifest waste (fills the remove: list; hot-reloaded, no restart):" + info " ${abctl_cmd} tools scan --write ${local_cfg}" + info " Then watch the Metrics section of tool-prune's pane in abctl." + info "" +fi +info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" +info " Send traffic through it (e.g. Claude Code):" +info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" +info " NODE_EXTRA_CA_CERTS=${ca_dir}/ca.crt \\" +info " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude" +info "" +info " Stop it: kill ${proxy_pid} (or: kill \$(cat ${pidfile}))" +info "" From 4b7c81c6e3b6ee9fefd7554e8ac9aecdc3c26ccb Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 13:58:10 -0400 Subject: [PATCH 04/19] fix: Make the quickstart hand off to the cost guide without breaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walking the path a new user actually walks — README quick start, then click through to the token-cost guide — the guide failed at its first command. Both setups bind the same loopback ports, so the second invocation hit the port preflight and died: "port 47600 is already in use". The guide is reached by a link from the README, so that is the common order, not an edge case. The installer now stops a Cortex a previous run of it started, and says so. Deliberately narrow: it only kills a pid from OUR pidfile whose process name is still authbridge-proxy, because a pidfile outlives its process and the number can be recycled onto something unrelated. Verified with a foreign listener holding the port AND our pidfile pointing at it — left alone, and the preflight still reports the conflict. **Switching setups changes the CA, and a stale trust anchor fails silently** — traffic still flows, every request tunnels through opaquely, nothing is pruned, and nothing looks broken. So when the replaced instance was the built-in local one, the installer now names both CA paths and says what happens if the old one is reused. That is the failure this whole feature is most likely to die of in someone else's hands. Also fixes a real exposure the rename walked past: the built-in --local config never pinned health_addr, so the preset filled it with ":9091" — every interface, on a port common enough to collide. The comment above that config claimed "every listener the demo uses is pinned to loopback on an uncommon port", which was simply not true of health. Pinned to 127.0.0.1:47604, matching the --claude-code config, and the comment now lists health among what a wildcard bind would expose. Doc wording: the README said "start the demo" and "four steps" (it is three now), and neither page acknowledged the other. Each now says the guide takes over from the quick start rather than conflicting with it, so the "Stopping the Cortex started earlier" line is expected rather than alarming. Verified end to end: README step 1 starts, the cost guide's step 1 replaces it and prints the CA note, and both print a working command. Five modules build/vet/test clean under GOWORK=off; shellcheck clean. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 9 ++-- authbridge/cmd/authbridge-proxy/local.go | 19 +++++---- authbridge/docs/laptop-token-savings.md | 4 ++ authbridge/install.sh | 54 ++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bef1972a8..cbf32ffce 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ It ships as a single binary; the identity and access layer is **AuthBridge**, an Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — decrypted and parsed live on your laptop. -1. **Install and start the demo** (macOS/Linux). Downloads two small binaries and starts the proxy in the background: +1. **Install and start Cortex** (macOS/Linux). Downloads two small binaries and starts the proxy in the background: ```sh curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh | sh @@ -43,8 +43,11 @@ Already using Claude Code? Cortex can strip the tool definitions your agent neve calls out of every request. Measured over 99 requests in one session: **4–20% of the prompt billed per turn, median 6%**. The share is highest early — the removed bytes are a fixed size, so as the conversation grows they shrink as a fraction of -it — and depends on how many of the tools you actually use. Four steps, about two minutes: -**[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**. +it — and depends on how many of the tools you actually use. Three steps, about two +minutes: **[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**. + +If you already ran the quick start above, that guide takes over from it — its +first command replaces the running proxy rather than colliding with it. ## Running on Kubernetes diff --git a/authbridge/cmd/authbridge-proxy/local.go b/authbridge/cmd/authbridge-proxy/local.go index 588201b57..49fd3c9a1 100644 --- a/authbridge/cmd/authbridge-proxy/local.go +++ b/authbridge/cmd/authbridge-proxy/local.go @@ -45,15 +45,15 @@ func defaultLocalDir() string { // the LLM / MCP / A2A parsers, so an agent's egress is decrypted and parsed. // Kept in sync with the root README. // -// Every listener the demo uses is pinned to loopback on an uncommon port. This -// runs on a laptop, so (a) a wildcard bind would expose an open forward proxy, -// the stats endpoint, and the unauthenticated session API (which carries -// decrypted bodies and any injected tokens) to the LAN, and (b) the usual -// 8081/909x ports collide with common dev tools. The preset only fills empty -// addresses, so these explicit values win — keep them in sync with the ports -// the installer probes and prints (authbridge/install.sh). The +// Every listener is pinned to loopback on an uncommon port. This runs on a +// laptop, so (a) a wildcard bind would expose an open forward proxy, the stats +// endpoint, the health endpoint, and the unauthenticated session API (which +// carries decrypted bodies and any injected tokens) to the LAN, and (b) the +// usual 8081/909x ports collide with common dev tools. The preset only fills +// empty addresses, so these explicit values win — keep them in sync with the +// ports the installer probes and prints (authbridge/install.sh). The // enforce-redirect transparent listener isn't used here (no iptables) and -// main.go skips starting it under --local. +// main.go skips starting it under --local, so it needs no address. // // The YAML body is flush-left on purpose — a raw string literal preserves // leading whitespace, so indenting these lines in source would corrupt the YAML. @@ -66,6 +66,9 @@ listener: roles: [forward] forward_proxy_addr: 127.0.0.1:47600 session_api_addr: 127.0.0.1:47601 + # Without this the preset defaults health to ":9091" — every interface, and a + # port common enough to collide with an unrelated service. + health_addr: 127.0.0.1:47604 stats: address: 127.0.0.1:47602 tls_bridge: diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index b9b1628d4..b7907ef76 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -8,6 +8,10 @@ trim it without changing every client. macOS or Linux, amd64 or arm64. No cluster, Keycloak, or SPIRE. +Coming from the [README quick start](../../README.md#quick-start-local-no-kubernetes)? +Step 1 replaces the proxy it started — same ports, a config tuned for cost instead +of observation — so there is nothing to stop first. + ## 1. Install and start ```sh diff --git a/authbridge/install.sh b/authbridge/install.sh index 1f2499d42..74dc29180 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -114,8 +114,54 @@ case "$arch" in *) die "unsupported architecture: $arch (supported: amd64, arm64)" ;; esac +# stop_previous_cortex stops a Cortex a previous run of this script started, if +# one is still holding the ports the next one needs. +# +# This is the path a new user actually walks: the README quickstart starts the +# built-in local config, then the token-cost guide starts the --claude-code one. +# Both use the same loopback ports, so without this the second command dies on a +# bind conflict and the guide stops working at step 1. +# +# Deliberately narrow. It only kills a pid from OUR pidfile whose process name is +# still authbridge-proxy — a pidfile can outlive its process and the number can +# be recycled onto something unrelated. Anything else holding the port is left +# alone and reported by the preflight below. +stop_previous_cortex() { + STOPPED_LOCAL="" + for pidfile in "${CORTEX_DIR}/proxy.pid" "${CORTEX_DIR}/local/proxy.pid"; do + [ -f "$pidfile" ] || continue + pid=$(cat "$pidfile" 2>/dev/null) || continue + case "$pid" in + '' | *[!0-9]*) continue ;; + esac + kill -0 "$pid" 2>/dev/null || { rm -f "$pidfile"; continue; } + name=$(ps -p "$pid" -o comm= 2>/dev/null || true) + case "$name" in + *authbridge-proxy*) ;; + *) continue ;; # pid recycled onto something else — never touch it + esac + info "Stopping the Cortex started earlier (pid ${pid}); the new one replaces it." + [ "$pidfile" = "${CORTEX_DIR}/local/proxy.pid" ] && STOPPED_LOCAL=1 + kill "$pid" 2>/dev/null || true + i=0 + while [ "$i" -lt 25 ] && kill -0 "$pid" 2>/dev/null; do + sleep 0.2 + i=$((i + 1)) + done + rm -f "$pidfile" + done +} + # --- preflight: fail early (before downloading) if a listener port is taken --- if [ "$MODE" = "local" ] || [ "$MODE" = "claude-code" ]; then + # Clear our own previous instance first, so switching between the two setups + # is one command rather than a bind error and a manual kill. + for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do + if port_in_use "$p"; then + stop_previous_cortex + break + fi + done for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do if port_in_use "$p"; then die "port ${p} is already in use. Is Cortex already running (see ${CORTEX_DIR}/local/proxy.pid or ${CORTEX_DIR}/proxy.pid)? Otherwise free the port, or change the ports in the config, then re-run." @@ -312,6 +358,14 @@ YAML info "Cortex started (pid ${cc_pid}); couldn't confirm it's listening (install lsof or nc to verify). Logs: ${cc_log}" fi info "" + if [ -n "${STOPPED_LOCAL:-}" ]; then + info "Note: this setup uses a different CA than the one you were running" + info " (${cc_ca_dir}/ca.crt, not ${CORTEX_DIR}/local/ca.crt)." + info " Restart Claude Code with the command below — a stale" + info " NODE_EXTRA_CA_CERTS fails silently: traffic still flows, but" + info " every request tunnels through opaquely and nothing is pruned." + info "" + fi info "Run Claude Code through it:" info "" info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" From 1d1c79ca2c5945197710952b63c0bdc03b8ab98f Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 14:17:51 -0400 Subject: [PATCH 05/19] refactor: One local config, so the two guides stop being the same page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick start and the token-cost guide read as the same three commands with a different flag, because underneath they were the same thing. The built-in config already contained inference-parser AND tool-prune, and writeBuiltinConfig already preserved edits — I fixed that earlier in this same series. So `abctl tools scan --write` against the quick start's own config produced exactly what the second "persistent" config produced, plus the MCP and A2A parsers. The only real difference was that one of them ran the scan for you. I added the second config beside the first instead of noticing the first already did the work, and everything downstream had to compensate: a second CA, a second config location, logic to stop one proxy when the other started, and a warning that the CA path had changed between them. All of that is now deleted rather than maintained. There is one config at ~/.cortex/config.yaml and one CA at ~/.cortex/ca. The binary owns the single config template — the installer no longer carries a second copy of the YAML, which was its own source-of-truth problem. --ca-dir now moves only the CA, so relocating a client's trust anchor can't strand the config somewhere a later command won't look. The installer fills the prune list by default. That is the point of installing it, and it is safe to do unattended because `tools scan` refuses to write when it saw no tool calls to reason from — a brand-new install with no history gets an empty list and an explanation, not a guess. --no-prune opts out. --claude-code stays accepted as a no-op alias. The two pages now divide by content rather than repeating each other: the README covers install, point an agent at it, watch. The cost page covers only what it adds — what the saving comes to, how to read the dollar figure, how to keep the list honest, what does and doesn't change — and links back for setup. It got shorter (154 lines to 107) by losing duplication, and gained the per-million pricing example that was only in the plugin reference. Also fixes a leftover from the rename: writeBuiltinConfig still logged "demo mode — keeping the existing config". Verified: the one command installs, writes one config, prunes 15 tools from real transcripts, and starts with all four plugins live on /v1/pipeline; re-running replaces the previous instance and reports the config already up to date; --no-prune leaves remove: empty; --install-only and --claude-code both behave. Five modules build/vet/test clean under GOWORK=off; shellcheck clean. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 35 +-- authbridge/cmd/authbridge-proxy/local.go | 41 +-- authbridge/cmd/authbridge-proxy/local_test.go | 22 +- authbridge/cmd/authbridge-proxy/main.go | 44 +-- authbridge/docs/laptop-token-savings.md | 152 +++++------ authbridge/install.sh | 251 +++++------------- 6 files changed, 214 insertions(+), 331 deletions(-) diff --git a/README.md b/README.md index cbf32ffce..d10082a5b 100644 --- a/README.md +++ b/README.md @@ -12,42 +12,45 @@ It ships as a single binary; the identity and access layer is **AuthBridge**, an ## Quick start (local, no Kubernetes) -Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — decrypted and parsed live on your laptop. +Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — +decrypted and parsed live on your laptop, and cut what it spends on unused tool +definitions. -1. **Install and start Cortex** (macOS/Linux). Downloads two small binaries and starts the proxy in the background: +1. **Install and start Cortex** (macOS/Linux). Downloads two small binaries, + writes one config under `~/.cortex`, and starts the proxy in the background: ```sh curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh | sh ``` + It also fills in the list of tool definitions to strip from outbound requests, + read from your own Claude Code transcripts — that is the cost saving, and it + prints exactly what it chose. Add `--no-prune` to skip it, or + `--install-only` for just the binaries. + 2. **Open the live viewer** in another terminal: ```sh abctl --endpoint http://localhost:47601 ``` -3. **Send an agent's traffic through it** — e.g. Claude Code, from anywhere (the CA path is fixed, not relative to where you started): +3. **Send an agent's traffic through it** — e.g. Claude Code, from any directory: ```sh HTTPS_PROXY=http://localhost:47600 \ - NODE_EXTRA_CA_CERTS="$HOME/.cortex/local/ca.crt" \ + NODE_EXTRA_CA_CERTS="$HOME/.cortex/ca/ca.crt" \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude ``` - Its calls stream into `abctl`, decrypted and parsed. - -## Cut Claude Code token cost on your laptop - -Already using Claude Code? Cortex can strip the tool definitions your agent never -calls out of every request. Measured over 99 requests in one session: **4–20% of -the prompt billed per turn, median 6%**. The share is highest early — the removed -bytes are a fixed size, so as the conversation grows they shrink as a fraction of -it — and depends on how many of the tools you actually use. Three steps, about two -minutes: **[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**. + Its calls stream into `abctl`, decrypted and parsed. Step 1 prints this line + with the paths already filled in. Stop the proxy with + `kill $(cat ~/.cortex/proxy.pid)`. -If you already ran the quick start above, that guide takes over from it — its -first command replaces the running proxy rather than colliding with it. +**What the token saving comes to, and how to tune it:** +**[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**. +Measured over 99 requests in one session: **4–20% of the prompt billed per turn, +median 6%**. ## Running on Kubernetes diff --git a/authbridge/cmd/authbridge-proxy/local.go b/authbridge/cmd/authbridge-proxy/local.go index 49fd3c9a1..b8ad6d43f 100644 --- a/authbridge/cmd/authbridge-proxy/local.go +++ b/authbridge/cmd/authbridge-proxy/local.go @@ -12,32 +12,35 @@ import ( // scattered into whichever directory each command happened to run from. const ( cortexDirName = ".cortex" - // localDirName keeps --local's regenerated state out of the persistent - // config's way. --local rewrites its config from the built-in preset, so it - // must not share a directory with a config a user maintains by hand. - localDirName = "local" - // localConfigName matches the persistent config's filename, so there is one - // name to know regardless of which mode wrote it. + // localConfigName is the one config a local install has. There is deliberately + // no second "cost-optimised" config: this one already carries both the parsers + // and tool-prune, and writeBuiltinConfig preserves edits, so filling in + // tool-prune's remove list is all the difference ever amounted to. Two configs + // meant two CAs, two sets of paths, and two pages of instructions that read + // identically. localConfigName = "config.yaml" + // caDirName holds the bridge CA. Separate from the config so one directory + // listing distinguishes "your settings" from "generated key material". + caDirName = "ca" // localDirFallback is used only when the home directory cannot be // determined, which is the historical cwd-relative behaviour. localDirFallback = "cortex-ca" ) -// defaultLocalDir returns the directory --local works in: ~/.cortex/local, or -// ./cortex-ca if there is no resolvable home directory. +// defaultCortexDir returns ~/.cortex, or ./cortex-ca if there is no resolvable +// home directory. // // This used to be cwd-relative unconditionally, on the reasoning that no // absolute path should be baked into the binary. Resolving $HOME at runtime // satisfies that while keeping the private key in one predictable place — and // the cwd default had a real cost: it dropped a CA and private key into -// whatever directory the demo was started from, including checkouts. -func defaultLocalDir() string { +// whatever directory the proxy was started from, including checkouts. +func defaultCortexDir() string { home, err := os.UserHomeDir() if err != nil || home == "" { return localDirFallback } - return filepath.Join(home, cortexDirName, localDirName) + return filepath.Join(home, cortexDirName) } // builtinConfigYAML returns the built-in --local config with caDir interpolated: a @@ -112,14 +115,20 @@ pipeline: // this function runs before any port is bound, so an unconditional write meant // that even a --local start which then failed on a port clash silently destroyed // those edits. Delete the file to regenerate the preset. -func writeBuiltinConfig(caDir string) (string, error) { - // 0700: this directory holds the local CA's private key. - if err := os.MkdirAll(caDir, 0o700); err != nil { +// writeBuiltinConfig ensures cortexDir/config.yaml exists, pointing at caDir for +// the CA, and returns its path. +// +// An existing file is never rewritten. That is what makes this the only config a +// local install needs: the prune list, the on_error policy and any hand edit all +// survive a restart, so there is nothing for a second "persistent" config to do. +func writeBuiltinConfig(cortexDir, caDir string) (string, error) { + // 0700: caDir under here holds the CA's private key. + if err := os.MkdirAll(cortexDir, 0o700); err != nil { return "", err } - path := filepath.Join(caDir, localConfigName) + path := filepath.Join(cortexDir, localConfigName) if _, err := os.Stat(path); err == nil { - slog.Info("demo mode — keeping the existing config (edits and any prune list are preserved)", + slog.Info("local mode — keeping the existing config (edits and any prune list are preserved)", "path", path, "hint", "delete it to regenerate the built-in preset") return path, nil } else if !errors.Is(err, os.ErrNotExist) { diff --git a/authbridge/cmd/authbridge-proxy/local_test.go b/authbridge/cmd/authbridge-proxy/local_test.go index 81149a173..17d1192eb 100644 --- a/authbridge/cmd/authbridge-proxy/local_test.go +++ b/authbridge/cmd/authbridge-proxy/local_test.go @@ -10,19 +10,20 @@ import ( "github.com/rossoctl/cortex/authbridge/authlib/config" ) -// writeBuiltinConfig must produce a config file inside caDir that loads, presets, +// writeBuiltinConfig must produce a config file in cortexDir that loads, presets, // and validates cleanly and describes a forward-only TLS-bridge observe -// pipeline pointed at that dir — otherwise --local would fail at boot instead of -// giving users a working, hot-reloadable local demo. +// pipeline pointed at caDir — otherwise --local would fail at boot instead of +// giving users a working, hot-reloadable local setup. func TestDemoConfig_WriteLoadsAndValidates(t *testing.T) { - caDir := t.TempDir() + cortexDir := t.TempDir() + caDir := filepath.Join(cortexDir, "ca") - p, err := writeBuiltinConfig(caDir) + p, err := writeBuiltinConfig(cortexDir, caDir) if err != nil { t.Fatalf("writeBuiltinConfig: %v", err) } - if filepath.Dir(p) != caDir { - t.Errorf("config written to %q, want inside %q", p, caDir) + if filepath.Dir(p) != cortexDir { + t.Errorf("config written to %q, want inside %q", p, cortexDir) } cfg, err := config.Load(p) @@ -108,8 +109,9 @@ func TestDemoConfig_WriteLoadsAndValidates(t *testing.T) { // meant a --local start that then failed on a port clash silently destroyed those // edits — which is exactly how a populated remove list was lost in practice. func TestWriteDemoConfig_PreservesAnExistingFile(t *testing.T) { - caDir := t.TempDir() - p, err := writeBuiltinConfig(caDir) + cortexDir := t.TempDir() + caDir := filepath.Join(cortexDir, "ca") + p, err := writeBuiltinConfig(cortexDir, caDir) if err != nil { t.Fatal(err) } @@ -118,7 +120,7 @@ func TestWriteDemoConfig_PreservesAnExistingFile(t *testing.T) { t.Fatal(err) } // A second call — a restart — must not clobber it. - p2, err := writeBuiltinConfig(caDir) + p2, err := writeBuiltinConfig(cortexDir, caDir) if err != nil { t.Fatal(err) } diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index ee899da15..bfe44cc72 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -130,7 +130,7 @@ func main() { // still prints the flag, just with a blank description that reads like a bug. demoDeprecated := flag.Bool("demo", false, "deprecated alias for -local") caDir := flag.String("ca-dir", "", - "CA directory for --local (auto-generated); defaults to ~/"+cortexDirName+"/"+localDirName) + "CA directory for --local (auto-generated); defaults to ~/"+cortexDirName+"/"+caDirName) flag.Parse() if *showVersion { @@ -150,33 +150,41 @@ func main() { if *configPath != "" { log.Fatal("--local and --config are mutually exclusive") } + cortexDir := defaultCortexDir() + // The default moved here from ./cortex-ca. Someone who still has that + // directory almost certainly has a client trusting the CA inside it, + // and pointing at a stale CA fails silently — every request tunnels + // through opaquely and no plugin sees a body. Name both paths. + if st, serr := os.Stat(localDirFallback); serr == nil && st.IsDir() && *caDir == "" { + slog.Warn("local mode — the CA now lives under $HOME; the ./"+localDirFallback+" here is no longer used", + "now_using", filepath.Join(cortexDir, caDirName), + "ignored", localDirFallback, + "hint", "update the client's CA path (e.g. NODE_EXTRA_CA_CERTS), or pass --ca-dir ./"+localDirFallback+" to keep the old location") + } + // --ca-dir moves only the CA. The config stays at one known path, so a + // client's trust anchor can be relocated without the config going + // somewhere a later command can't find. dir := *caDir if dir == "" { - dir = defaultLocalDir() - // The default moved here from ./cortex-ca. Someone who still has that - // directory almost certainly has a client trusting the CA inside it, - // and pointing at a stale CA fails silently — every request tunnels - // through opaquely and no plugin sees a body. Name both paths. - if st, serr := os.Stat(localDirFallback); serr == nil && st.IsDir() { - slog.Warn("local mode — the default CA directory is now under $HOME; the ./"+localDirFallback+" here is no longer used", - "now_using", dir, - "ignored", localDirFallback, - "hint", "update the client's CA path (e.g. NODE_EXTRA_CA_CERTS), or pass --ca-dir ./"+localDirFallback+" to keep the old location") - } + dir = filepath.Join(cortexDir, caDirName) } - abs, aerr := filepath.Abs(dir) + absCA, aerr := filepath.Abs(dir) if aerr != nil { log.Fatalf("--local: resolving --ca-dir %q: %v", dir, aerr) } - // Write the built-in config next to the CA and drive the normal - // file-based load + hot-reload path — so editing the file reloads live. - p, werr := writeBuiltinConfig(abs) + absCortex, cerr := filepath.Abs(cortexDir) + if cerr != nil { + log.Fatalf("--local: resolving %q: %v", cortexDir, cerr) + } + // Drive the normal file-based load + hot-reload path, so editing the + // config reloads live. + p, werr := writeBuiltinConfig(absCortex, absCA) if werr != nil { log.Fatalf("--local: %v", werr) } *configPath = p - slog.Info("local mode — wrote built-in config next to the CA; edit it to hot-reload", - "config", p, "ca_dir", abs) + slog.Info("local mode — using the built-in config; edit it to hot-reload", + "config", p, "ca_dir", absCA) } else if *caDir != "" { log.Fatal("--ca-dir only applies with --local") } diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index b7907ef76..9e5b01228 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -1,72 +1,18 @@ # Cut Claude Code token cost on your laptop -Cortex runs as a local proxy in front of Claude Code and strips tool definitions -your agent never calls out of every request. Claude Code sends the full tool -manifest on every turn — tens of thousands of tokens of JSON schema, billed each -time — and the manifest is built by the client, so the proxy is the only place to -trim it without changing every client. +Claude Code sends the full tool manifest on every turn — tens of thousands of +tokens of JSON schema, billed each time — and the manifest is built by the client, +so a proxy is the only place to trim it without changing every client. Cortex +strips the definitions your agent never calls. -macOS or Linux, amd64 or arm64. No cluster, Keycloak, or SPIRE. - -Coming from the [README quick start](../../README.md#quick-start-local-no-kubernetes)? -Step 1 replaces the proxy it started — same ports, a config tuned for cost instead -of observation — so there is nothing to stop first. - -## 1. Install and start - -```sh -curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh \ - | sh -s -- --claude-code -``` - -That downloads the released binaries into `~/.local/bin`, writes -`~/.cortex/config.yaml`, picks which tools to prune from your own transcripts, -starts the proxy, and prints the command for step 2. Re-running it is safe — it -never overwrites a config you already have. - -Everything Cortex writes lives under `~/.cortex`, so there is one directory to -inspect, back up, or delete — and no CA or private key left in whichever -directory you happened to run a command from: - -```text -~/.cortex/ -├── config.yaml your config — edit freely, the proxy hot-reloads -├── ca/ the bridge CA Claude Code has to trust -│ ├── ca.crt <- NODE_EXTRA_CA_CERTS points here -│ └── tls.key private key, never leaves this machine -├── proxy.log -├── proxy.pid -└── local/ only if you use the built-in config (see below) -``` - -The directory is created `0700`, because the CA private key is under it. To -uninstall completely: stop the proxy, then `rm -rf ~/.cortex` and -`rm ~/.local/bin/{abctl,authbridge-proxy}`. - -## 2. Run Claude Code through it - -```sh -HTTPS_PROXY=http://localhost:47600 \ - NODE_EXTRA_CA_CERTS="$HOME/.cortex/ca/ca.crt" \ - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ - claude -``` - -Step 1 prints this line with the paths already filled in. - -## 3. See what it saved - -```sh -abctl --endpoint http://localhost:47601 -``` - -Plugin pane → `tool-prune` → `Metrics`. Stop the proxy with -`kill $(cat ~/.cortex/proxy.pid)`. +**Setup is the [README quick start](../../README.md#quick-start-local-no-kubernetes)** — +one command, and it fills the prune list for you. This page is what that gets you +and how to tune it; there is nothing extra to install. ## What to expect -**4–20% of the prompt per turn, median 6%**, measured over 99 requests of one -real session. Two things move it, and neither is a defect: +**4–20% of the prompt per turn, median 6%**, measured over 99 requests of one real +session. Two things move it, and neither is a defect: - **How much of the manifest is yours to prune.** Requests carrying the full tool set saved 15–20%; most requests in that session offered a reduced set and saved @@ -75,21 +21,46 @@ real session. Two things move it, and neither is a defect: so their share of a growing prompt falls — 13% early in that session, 4% by the end. -A single early turn can read ~24%, which is why a figure quoted from one request -is not the number to plan with. +A single early turn can read ~24%, which is why a figure quoted from one request is +not the number to plan with. + +Watch it live in `abctl` (`--endpoint http://localhost:47601`): the plugin pane's +`tool-prune` → `Metrics`, and the per-request saving in the events timeline's +`TOKENS / SAVED` column. + +## Reading the dollar figure + +`$ saved` appears with no configuration, labelled `default rates`. **Read it as a +floor.** The built-in rates were measured on a shared gateway that bills below +vendor list; if your Claude Code talks straight to Anthropic — which it does unless +you have set `ANTHROPIC_BASE_URL` — you pay list, so the real saving is several +times what the column shows. + +Savings are reported per prompt-cache tier, never as one blended number: providers +charge ~1.25x the input rate for a cache write and ~0.1x for a cache read, so +identical saved bytes differ by more than 12x depending on cache state. + +To make the figure accurate, set your own rates — per million tokens, the unit +price lists use, and keyed by model family so a version bump needs no edit: -`$ saved` appears with no extra configuration, labelled `default rates`. **Read it -as a floor.** The built-in rates were measured on a shared gateway that bills -below vendor list; if your Claude Code talks straight to Anthropic — which it does -unless you have set `ANTHROPIC_BASE_URL` — you pay list, so the real saving is -several times what the column shows. To make it accurate, set your own rates: +```yaml +# ~/.cortex/config.yaml, under the tool-prune plugin +config: + pricing: + "*claude-opus-*": + input_cost_per_million: 3.80 + cache_write_cost_per_million: 4.75 + cache_read_cost_per_million: 0.38 +``` + +Full reference, including how to measure your own from a gateway's cost headers: [`tool-prune-plugin.md`](./tool-prune-plugin.md#costing-it). ## Keeping the prune list honest -The scan proposes tools you have not called in 30 days. It only ever proposes -tools it recognises, never one it has seen you call, and it refuses to write a -list at all if it saw no tool calls to reason from. +The scan proposes tools you have not called in 30 days. It only ever proposes tools +it recognises, never one it has seen you call, and it refuses to write a list at +all if it saw no tool calls to reason from. **What it cannot know is the future.** It reports what you have not used, not what you will not need. If you start work that needs a pruned tool, its definition is @@ -97,12 +68,20 @@ gone from the request and the model cannot call it — a functional failure, not merely a smaller saving. So: - **Re-run it occasionally** (monthly, or when your work changes shape): - `abctl tools scan --write ~/.cortex/config.yaml`. The proxy hot-reloads. + + ```sh + abctl tools scan --write ~/.cortex/config.yaml + ``` + + The proxy hot-reloads; no restart. Use `--days N` to widen the window and + `--keep Name,Name` to protect specific tools. + - **If a tool goes missing, delete its name from `remove:`** in `~/.cortex/config.yaml`. It comes back without a restart. + - **To try a list without committing to it**, set `on_error: observe` on the `tool-prune` plugin. It measures the saving and changes nothing; abctl marks - those figures with `~`. + those figures with `~` instead of `−`. ## What this does and does not change @@ -120,20 +99,9 @@ client-side settings (`--allowedTools`, disabling unused MCP servers). the bridge CA. `NODE_EXTRA_CA_CERTS` must point at `~/.cortex/ca/ca.crt`, expanded to an absolute path. The proxy also warns about this in `~/.cortex/proxy.log` after a few requests, naming the path it expects. -- **The proxy won't start** — read `~/.cortex/proxy.log`; a port conflict is - logged at `ERROR`. The config pins every listener to loopback on 47600–47604, so - a clash usually means Cortex is already running. -- **`abctl: command not found`** — `~/.local/bin` is not on your `PATH`: - `export PATH="$HOME/.local/bin:$PATH"`. - -## Other ways in - -- **Just the binaries**, no setup: `... | sh -s -- --install-only`. -- **A built-in config** instead of a tailored one: `... | sh` with no arguments, - or `authbridge-proxy --local`. It regenerates its own config and CA in - `~/.cortex/local`, so it never touches the `config.yaml` above. Use it to look - at decrypted agent traffic through the protocol parsers; use `--claude-code` - for cutting token cost. -- **Pin a version**: `AUTHBRIDGE_VERSION=vX.Y.Z`. -- **Re-run setup offline**, using the binaries you already have: - `AUTHBRIDGE_SKIP_DOWNLOAD=1`. +- **`tool-prune` shows `skip`, never `modify`** — the remove list is empty. Run the + scan above; if it refuses, you have no transcript history for it to reason from + yet. +- **The proxy won't start** — read `~/.cortex/proxy.log`; a port conflict is logged + at `ERROR`. Every listener is pinned to loopback on 47600–47604, so a clash + usually means Cortex is already running (`kill $(cat ~/.cortex/proxy.pid)`). diff --git a/authbridge/install.sh b/authbridge/install.sh index 74dc29180..b34f8d9f1 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -9,22 +9,25 @@ # commands to watch traffic and point an agent at it, plus how to stop it. # macOS + Linux, amd64 + arm64. No cluster, Keycloak, or SPIRE needed. # -# Modes (pass through the pipe with `sh -s --`, e.g. -# curl -fsSL ...install.sh | sh -s -- --claude-code): +# By default it installs, starts Cortex with its built-in config in ~/.cortex, +# fills in tool-prune's remove list from your own transcripts, and prints the +# command to send an agent through it. +# +# Options (pass through the pipe with `sh -s --`, e.g. +# curl -fsSL ...install.sh | sh -s -- --install-only): # -# (default) install, then start with a built-in config in ~/.cortex/local -# --claude-code install, then set up a PERSISTENT config in ~/.cortex for -# cutting Claude Code token cost: writes the config, fills the -# tool-prune remove: list from your own transcripts, starts the -# proxy, and prints the exact command to run Claude Code -# through it. Safe to re-run; never overwrites an existing -# config. # --install-only install the binaries and stop +# --no-prune set up and start, but leave tool-prune's list empty +# +# There is deliberately only one config. It carries the parsers AND tool-prune, +# and the proxy preserves edits to it, so a second "cost-optimised" config had +# nothing to do that filling in one list did not already do — while costing a +# second CA, a second set of paths, and a second page of instructions that read +# identically to the first. `--claude-code` is kept as an accepted no-op alias. # -# Flags exist because the env-var form has a trap: written -# `VAR=1 curl ... | sh` the variable reaches curl, not sh, so the script runs -# without it. `sh -s -- --flag` has no such failure mode. The env vars below -# still work for backward compatibility. +# Flags rather than env vars: written `VAR=1 curl ... | sh` the variable reaches +# curl, not sh, so the script runs without it. `sh -s -- --flag` has no such +# failure mode. The env vars below still work. # # Environment: # AUTHBRIDGE_VERSION=vX.Y.Z install a specific release tag (default: newest) @@ -45,17 +48,19 @@ die() { printf 'error: %s\n' "$*" >&2; exit 1; } # --- mode selection --- MODE=local +PRUNE=1 for arg in "$@"; do case "$arg" in - --claude-code) MODE=claude-code ;; --install-only) MODE=install-only ;; - # --demo is the old name for the built-in-config run; still accepted. - --local | --demo) MODE=local ;; + --no-prune) PRUNE="" ;; + # --claude-code and --demo were separate modes; both now describe the one + # setup this script performs, so they are accepted and change nothing. + --local | --demo | --claude-code) MODE=local ;; -h | --help) sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' exit 0 ;; - *) die "unknown option: $arg (try --claude-code, --install-only, or no argument)" ;; + *) die "unknown option: $arg (try --install-only, --no-prune, or no argument)" ;; esac done # Env form kept working; the flag wins if both are given. @@ -117,43 +122,42 @@ esac # stop_previous_cortex stops a Cortex a previous run of this script started, if # one is still holding the ports the next one needs. # -# This is the path a new user actually walks: the README quickstart starts the -# built-in local config, then the token-cost guide starts the --claude-code one. -# Both use the same loopback ports, so without this the second command dies on a -# bind conflict and the guide stops working at step 1. +# Re-running the installer while Cortex is already up is ordinary — following the +# README and then the token-cost guide does exactly that. Both use the same +# loopback ports, so without this the second command dies on a bind conflict. # # Deliberately narrow. It only kills a pid from OUR pidfile whose process name is # still authbridge-proxy — a pidfile can outlive its process and the number can # be recycled onto something unrelated. Anything else holding the port is left # alone and reported by the preflight below. stop_previous_cortex() { - STOPPED_LOCAL="" - for pidfile in "${CORTEX_DIR}/proxy.pid" "${CORTEX_DIR}/local/proxy.pid"; do - [ -f "$pidfile" ] || continue - pid=$(cat "$pidfile" 2>/dev/null) || continue - case "$pid" in - '' | *[!0-9]*) continue ;; - esac - kill -0 "$pid" 2>/dev/null || { rm -f "$pidfile"; continue; } - name=$(ps -p "$pid" -o comm= 2>/dev/null || true) - case "$name" in - *authbridge-proxy*) ;; - *) continue ;; # pid recycled onto something else — never touch it - esac - info "Stopping the Cortex started earlier (pid ${pid}); the new one replaces it." - [ "$pidfile" = "${CORTEX_DIR}/local/proxy.pid" ] && STOPPED_LOCAL=1 - kill "$pid" 2>/dev/null || true - i=0 - while [ "$i" -lt 25 ] && kill -0 "$pid" 2>/dev/null; do - sleep 0.2 - i=$((i + 1)) - done + pidfile="${CORTEX_DIR}/proxy.pid" + [ -f "$pidfile" ] || return 0 + pid=$(cat "$pidfile" 2>/dev/null) || return 0 + case "$pid" in + '' | *[!0-9]*) return 0 ;; + esac + if ! kill -0 "$pid" 2>/dev/null; then rm -f "$pidfile" + return 0 + fi + name=$(ps -p "$pid" -o comm= 2>/dev/null || true) + case "$name" in + *authbridge-proxy*) ;; + *) return 0 ;; # pid recycled onto something else — never touch it + esac + info "Stopping the Cortex started earlier (pid ${pid}); the new one replaces it." + kill "$pid" 2>/dev/null || true + i=0 + while [ "$i" -lt 25 ] && kill -0 "$pid" 2>/dev/null; do + sleep 0.2 + i=$((i + 1)) done + rm -f "$pidfile" } # --- preflight: fail early (before downloading) if a listener port is taken --- -if [ "$MODE" = "local" ] || [ "$MODE" = "claude-code" ]; then +if [ "$MODE" = "local" ]; then # Clear our own previous instance first, so switching between the two setups # is one command rather than a bind error and a manual kill. for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do @@ -164,7 +168,7 @@ if [ "$MODE" = "local" ] || [ "$MODE" = "claude-code" ]; then done for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do if port_in_use "$p"; then - die "port ${p} is already in use. Is Cortex already running (see ${CORTEX_DIR}/local/proxy.pid or ${CORTEX_DIR}/proxy.pid)? Otherwise free the port, or change the ports in the config, then re-run." + die "port ${p} is already in use. Is Cortex already running (see ${CORTEX_DIR}/proxy.pid)? Otherwise free the port, or change the ports in the config, then re-run." fi done fi @@ -233,7 +237,7 @@ fi # end of download block # --- report --- proxy="${BIN_DIR}/authbridge-proxy" -ca_dir="${CORTEX_DIR}/local" # matches defaultLocalDir() in local.go +ca_dir="${CORTEX_DIR}/ca" # matches defaultCortexDir()+caDirName in local.go case ":${PATH}:" in *":${BIN_DIR}:"*) abctl_cmd="abctl" proxy_cmd="authbridge-proxy" ;; *) abctl_cmd="${BIN_DIR}/abctl" proxy_cmd="$proxy" ;; @@ -252,130 +256,6 @@ esac if [ "$MODE" = "install-only" ]; then info "" info "Install-only mode. Start it with: ${proxy_cmd} --local" - info "Or set up persistent Claude Code cost-cutting: re-run with --claude-code" - exit 0 -fi - -# --- claude-code mode: persistent setup under ~/.cortex, then start --- -# -# Separate from --local because --local regenerates its own config.yaml from a -# built-in template on every start, so any edit (like the remove: list this mode -# fills in) would be discarded on the next run. A config under ~/.cortex is -# outside that path and survives. -if [ "$MODE" = "claude-code" ]; then - cfg_dir="$CORTEX_DIR" - cfg="${cfg_dir}/config.yaml" - cc_ca_dir="${cfg_dir}/ca" - # 0700: the generated CA's private key lives under here. - mkdir -p "$cfg_dir" && chmod 700 "$cfg_dir" - - if [ -f "$cfg" ]; then - info "" - info "Keeping your existing config: ${cfg}" - else - info "" - info "Writing ${cfg}" - # ${HOME} is left literal on purpose: authbridge expands ${ENV_VAR} when it - # loads the file, so the config stays portable and needs no path rewriting - # after the fact. - cat >"$cfg" <<'YAML' -# Cortex — cut Claude Code token cost by pruning unused tool definitions. -# Written by install.sh --claude-code. Safe to edit; the proxy hot-reloads. -mode: proxy-sidecar -listener: - roles: [forward] - forward_proxy_addr: 127.0.0.1:47600 - session_api_addr: 127.0.0.1:47601 - # The proxy-sidecar preset turns this listener on and refills it if emptied, so - # pin it to loopback on an uncommon port. Left at its ":8082" default it binds - # every interface — on a laptop that means the LAN — and collides with anything - # else already using 8082. - transparent_proxy_addr: 127.0.0.1:47603 - # Same reasoning: the default ":9091" is every-interface and collides with any - # other authbridge on the host. - health_addr: 127.0.0.1:47604 -stats: - address: 127.0.0.1:47602 -tls_bridge: - mode: enabled - ca_dir: "${HOME}/.cortex/ca" - generate_ca: true -pipeline: - outbound: - plugins: - - name: inference-parser - # tool-prune must stay last: it rewrites the request body, and body - # readers have to precede it to see the original bytes. - - name: tool-prune - config: - remove: [] -YAML - fi - - # Fill the remove: list before starting, so no hot-reload round-trip is - # needed. This edits the config the user just asked us to set up, which is - # the point of the mode — but say so, and say how to undo it, because it is - # derived from their transcripts rather than chosen by them. - info "" - info "Choosing tools to prune from your own ~/.claude/projects transcripts..." - if "${BIN_DIR}/abctl" tools scan --write "$cfg"; then - info "" - info "Wrote the remove: list to ${cfg}" - info "To keep a tool, delete its name from that list — the proxy hot-reloads." - else - warn "the scan did not complete; the remove: list is empty, so tool-prune" - warn "will do nothing until you run: ${abctl_cmd} tools scan --write ${cfg}" - fi - - info "" - info "Starting the proxy in the background..." - cc_log="${cfg_dir}/proxy.log" - cc_pidfile="${cfg_dir}/proxy.pid" - nohup "$proxy" --config "$cfg" "$cc_log" 2>&1 & - cc_pid=$! - echo "$cc_pid" >"$cc_pidfile" - - ready=0 - i=0 - while [ "$i" -lt 50 ]; do - if ! kill -0 "$cc_pid" 2>/dev/null; then - warn "the proxy exited during startup — last log lines:" - tail -n 15 "$cc_log" >&2 || true - die "proxy failed to start (full log: ${cc_log})" - fi - if port_in_use "$DEMO_FORWARD_PORT"; then - ready=1 - break - fi - sleep 0.2 - i=$((i + 1)) - done - - info "" - if [ "$ready" -eq 1 ]; then - info "Cortex is running (pid ${cc_pid}). Logs: ${cc_log}" - else - info "Cortex started (pid ${cc_pid}); couldn't confirm it's listening (install lsof or nc to verify). Logs: ${cc_log}" - fi - info "" - if [ -n "${STOPPED_LOCAL:-}" ]; then - info "Note: this setup uses a different CA than the one you were running" - info " (${cc_ca_dir}/ca.crt, not ${CORTEX_DIR}/local/ca.crt)." - info " Restart Claude Code with the command below — a stale" - info " NODE_EXTRA_CA_CERTS fails silently: traffic still flows, but" - info " every request tunnels through opaquely and nothing is pruned." - info "" - fi - info "Run Claude Code through it:" - info "" - info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" - info " NODE_EXTRA_CA_CERTS=${cc_ca_dir}/ca.crt \\" - info " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude" - info "" - info " See what it saved: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" - info " (Plugin pane -> tool-prune -> Metrics)" - info " Stop it: kill \$(cat ${cc_pidfile})" - info "" exit 0 fi @@ -385,8 +265,8 @@ info "Starting Cortex in the background..." # 0700 on the Cortex directory: a CA private key is written beneath it. mkdir -p "$CORTEX_DIR" && chmod 700 "$CORTEX_DIR" mkdir -p "$ca_dir" -log="${ca_dir}/proxy.log" -pidfile="${ca_dir}/proxy.pid" +log="${CORTEX_DIR}/proxy.log" +pidfile="${CORTEX_DIR}/proxy.pid" nohup "$proxy" --local "$log" 2>&1 & proxy_pid=$! echo "$proxy_pid" >"$pidfile" @@ -422,15 +302,28 @@ fi info "" # tool-prune ships inert: the remove list is empty, so it does nothing until a -# name is added. That empty list is the guard — on_error is enforce, because the -# list is what gates the plugin. Offer the scan that fills it in. Only patch a config -# that already exists, so a first run never rewrites a file it just created -# behind the user's back -- print the command instead and let them look first. -local_cfg="${ca_dir}/config.yaml" -if [ -f "${local_cfg}" ]; then - info " Cut tool-manifest waste (fills the remove: list; hot-reloaded, no restart):" +# name is added. Fill it now — that is the whole point of installing this — and +# say so plainly, because it is derived from the user's transcripts rather than +# chosen by them. The config is hot-reloaded, so this needs no restart. +# +# `abctl tools scan` refuses to write anything when it saw no tool calls to reason +# from, which is what makes doing this unattended safe: a brand-new install with +# no history gets an empty list and a message, not a guess. +local_cfg="${CORTEX_DIR}/config.yaml" +if [ -n "${PRUNE:-}" ] && [ -f "${local_cfg}" ]; then + info "Choosing unused tools to prune from your own ~/.claude/projects transcripts..." + if "${BIN_DIR}/abctl" tools scan --write "${local_cfg}"; then + info "" + info "To keep a tool, delete its name from the remove: list in ${local_cfg}." + else + info "" + info " Nothing pruned yet. Once you have used Claude Code for a while:" + info " ${abctl_cmd} tools scan --write ${local_cfg}" + fi + info "" +elif [ -f "${local_cfg}" ]; then + info " Fill the prune list when you are ready (hot-reloaded, no restart):" info " ${abctl_cmd} tools scan --write ${local_cfg}" - info " Then watch the Metrics section of tool-prune's pane in abctl." info "" fi info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" From 148fdc5c062c5685d86560bab53b879f99e9de3a Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 14:31:27 -0400 Subject: [PATCH 06/19] refactor: Drop two compatibility aliases that were compatible with nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --claude-code appears in no instruction on any page — the previous commit removed the last one — and it survived only as an accepted no-op in install.sh. I kept it "for compatibility", which does not hold up: - it has never existed on main (git log -S finds it only on this branch) - the newest release, v0.7.0-alpha.3 from 2026-08-19, predates the commit that added it So it protected nobody, and keeping it meant shipping an undocumented flag on day one — strictly worse than not having it. The installer's --demo alias fails the same test, and more plainly: install-demo.sh on main parsed no command-line flags at all, only env vars. There is no earlier spelling for anyone to be using. The proxy's --demo -> --local alias stays, because that one is real: the flag is at main.go:125 on main and appears in the published README and release notes. That is the distinction worth drawing — an alias earns its keep by having shipped, not by sounding cautious. install.sh now accepts --install-only, --no-prune, and --local (the default, spelled out, mirroring the proxy flag). An unknown flag names the three. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/install.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/authbridge/install.sh b/authbridge/install.sh index b34f8d9f1..96391cce5 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -23,7 +23,11 @@ # and the proxy preserves edits to it, so a second "cost-optimised" config had # nothing to do that filling in one list did not already do — while costing a # second CA, a second set of paths, and a second page of instructions that read -# identically to the first. `--claude-code` is kept as an accepted no-op alias. +# identically to the first. +# +# No compatibility aliases here: this script accepted no flags at all until now, +# so there is no earlier spelling for anyone to still be using. (The proxy's +# --demo -> --local alias is different: that flag really did ship.) # # Flags rather than env vars: written `VAR=1 curl ... | sh` the variable reaches # curl, not sh, so the script runs without it. `sh -s -- --flag` has no such @@ -53,9 +57,9 @@ for arg in "$@"; do case "$arg" in --install-only) MODE=install-only ;; --no-prune) PRUNE="" ;; - # --claude-code and --demo were separate modes; both now describe the one - # setup this script performs, so they are accepted and change nothing. - --local | --demo | --claude-code) MODE=local ;; + # --local is the default; accepted so writing it out explicitly works, and + # so it mirrors the proxy flag of the same name. + --local) MODE=local ;; -h | --help) sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' exit 0 From dc60ed47381c518e9c029421a9a97c314fde9185 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 14:36:08 -0400 Subject: [PATCH 07/19] fix: Don't enable pruning on a fresh install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made the installer fill tool-prune's remove list by default. That was wrong twice over. The quick start's job is to *observe* an agent's traffic — that is what the page says it does. Filling the prune list makes it start *rewriting* that traffic instead, on the first command a new user ever runs, before they have seen anything the tool does. And the whole mechanism is Claude-Code-specific: the scan reads ~/.claude/projects, so for anyone driving a different agent it is a mutation of their requests with no upside at all. Cortex is not a Claude Code tool; its quick start should not behave like one. I had reasoned that filling it was safe because `tools scan` refuses to write without evidence. That defends against a *bad* list, not against applying a list nobody asked for. So a fresh install is inert again: tool-prune is in the config with an empty remove list, no request is modified, and the installer prints the one opt-in command instead of running it. --no-prune is gone with nothing left to opt out of. This also gives the two pages a real division rather than a near-duplicate one. The README installs and observes. The cost page opens with the single command that turns pruning on, what it will and won't propose, and how to undo it — content the README does not have, instead of the same three commands with a different flag. Verified: a fresh install reports tool-prune remove=0 on /v1/pipeline and rewrites nothing; the cost page's one command then writes 15 tools and the running proxy hot-reloads it (reloads_ok=1, no restart). Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 17 +++++----- authbridge/docs/laptop-token-savings.md | 42 ++++++++++++++++++------- authbridge/install.sh | 42 +++++++++---------------- 3 files changed, 54 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index d10082a5b..bcca51f36 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ It ships as a single binary; the identity and access layer is **AuthBridge**, an ## Quick start (local, no Kubernetes) Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — -decrypted and parsed live on your laptop, and cut what it spends on unused tool -definitions. +decrypted and parsed live on your laptop. 1. **Install and start Cortex** (macOS/Linux). Downloads two small binaries, writes one config under `~/.cortex`, and starts the proxy in the background: @@ -23,9 +22,7 @@ definitions. curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh | sh ``` - It also fills in the list of tool definitions to strip from outbound requests, - read from your own Claude Code transcripts — that is the cost saving, and it - prints exactly what it chose. Add `--no-prune` to skip it, or + Traffic is decrypted and parsed for viewing; nothing is rewritten. Add `--install-only` for just the binaries. 2. **Open the live viewer** in another terminal: @@ -47,10 +44,12 @@ definitions. with the paths already filled in. Stop the proxy with `kill $(cat ~/.cortex/proxy.pid)`. -**What the token saving comes to, and how to tune it:** -**[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**. -Measured over 99 requests in one session: **4–20% of the prompt billed per turn, -median 6%**. +**Using Claude Code?** One more command turns this into a cost saving — Cortex +strips the tool definitions your agent never calls out of every request, worth +**4–20% of the prompt billed per turn, median 6%** over 99 requests of one real +session: **[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**. +It is opt-in because it rewrites requests, and because the tool list it proposes +is read from Claude Code's own transcripts. ## Running on Kubernetes diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index 9e5b01228..46db3ef51 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -5,9 +5,33 @@ tokens of JSON schema, billed each time — and the manifest is built by the cli so a proxy is the only place to trim it without changing every client. Cortex strips the definitions your agent never calls. -**Setup is the [README quick start](../../README.md#quick-start-local-no-kubernetes)** — -one command, and it fills the prune list for you. This page is what that gets you -and how to tune it; there is nothing extra to install. +Nothing here installs anything: the proxy comes from the +[README quick start](../../README.md#quick-start-local-no-kubernetes), which sets +it up to observe traffic without changing it. Pruning is a separate, opt-in step, +because it rewrites requests and because the list it proposes is read from Claude +Code's own transcripts — of no use if you drive a different agent. + +## Turn it on + +```sh +abctl tools scan --write ~/.cortex/config.yaml +``` + +That reads your `~/.claude/projects` transcripts, proposes the built-in tools you +have not called in 30 days, and writes them to `tool-prune`'s `remove:` list. The +proxy hot-reloads, so it takes effect immediately — no restart. + +It prints what it chose before writing. Two guards on what it will propose: + +- It only ever proposes tools it **recognises**, and never one it has **seen you + call** — including tools implied by ones you called, so `BashOutput` survives if + you have used background `Bash`. +- It **refuses to write at all** if it saw no tool calls to reason from. With no + history, "tools you have not called" would be every tool it knows, which is a + guess rather than a measurement. + +To undo: delete names from `remove:` in `~/.cortex/config.yaml`, or empty the list +to disable pruning entirely. Either way the proxy reloads without a restart. ## What to expect @@ -58,11 +82,7 @@ Full reference, including how to measure your own from a gateway's cost headers: ## Keeping the prune list honest -The scan proposes tools you have not called in 30 days. It only ever proposes tools -it recognises, never one it has seen you call, and it refuses to write a list at -all if it saw no tool calls to reason from. - -**What it cannot know is the future.** It reports what you have not used, not what +**What the scan cannot know is the future.** It reports what you have not used, not what you will not need. If you start work that needs a pruned tool, its definition is gone from the request and the model cannot call it — a functional failure, not merely a smaller saving. So: @@ -99,9 +119,9 @@ client-side settings (`--allowedTools`, disabling unused MCP servers). the bridge CA. `NODE_EXTRA_CA_CERTS` must point at `~/.cortex/ca/ca.crt`, expanded to an absolute path. The proxy also warns about this in `~/.cortex/proxy.log` after a few requests, naming the path it expects. -- **`tool-prune` shows `skip`, never `modify`** — the remove list is empty. Run the - scan above; if it refuses, you have no transcript history for it to reason from - yet. +- **`tool-prune` shows `skip`, never `modify`** — expected until you opt in: the + remove list ships empty. Run the scan above. If it refuses, you have no + transcript history for it to reason from yet. - **The proxy won't start** — read `~/.cortex/proxy.log`; a port conflict is logged at `ERROR`. Every listener is pinned to loopback on 47600–47604, so a clash usually means Cortex is already running (`kill $(cat ~/.cortex/proxy.pid)`). diff --git a/authbridge/install.sh b/authbridge/install.sh index 96391cce5..eda470f1d 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -9,15 +9,15 @@ # commands to watch traffic and point an agent at it, plus how to stop it. # macOS + Linux, amd64 + arm64. No cluster, Keycloak, or SPIRE needed. # -# By default it installs, starts Cortex with its built-in config in ~/.cortex, -# fills in tool-prune's remove list from your own transcripts, and prints the -# command to send an agent through it. +# It installs, starts Cortex with its built-in config in ~/.cortex, and prints the +# command to send an agent through it. Traffic is decrypted and parsed for viewing; +# nothing is rewritten. Cutting Claude Code's token cost is one opt-in command +# afterwards, printed at the end. # # Options (pass through the pipe with `sh -s --`, e.g. # curl -fsSL ...install.sh | sh -s -- --install-only): # # --install-only install the binaries and stop -# --no-prune set up and start, but leave tool-prune's list empty # # There is deliberately only one config. It carries the parsers AND tool-prune, # and the proxy preserves edits to it, so a second "cost-optimised" config had @@ -52,11 +52,9 @@ die() { printf 'error: %s\n' "$*" >&2; exit 1; } # --- mode selection --- MODE=local -PRUNE=1 for arg in "$@"; do case "$arg" in --install-only) MODE=install-only ;; - --no-prune) PRUNE="" ;; # --local is the default; accepted so writing it out explicitly works, and # so it mirrors the proxy flag of the same name. --local) MODE=local ;; @@ -64,7 +62,7 @@ for arg in "$@"; do sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' exit 0 ;; - *) die "unknown option: $arg (try --install-only, --no-prune, or no argument)" ;; + *) die "unknown option: $arg (try --install-only, --local, or no argument)" ;; esac done # Env form kept working; the flag wins if both are given. @@ -305,29 +303,19 @@ else fi info "" -# tool-prune ships inert: the remove list is empty, so it does nothing until a -# name is added. Fill it now — that is the whole point of installing this — and -# say so plainly, because it is derived from the user's transcripts rather than -# chosen by them. The config is hot-reloaded, so this needs no restart. +# tool-prune is in the config but INERT: its remove list is empty, so it does +# nothing until a name is added. That is deliberate for an install. # -# `abctl tools scan` refuses to write anything when it saw no tool calls to reason -# from, which is what makes doing this unattended safe: a brand-new install with -# no history gets an empty list and a message, not a guess. +# Filling it here would mean a quickstart whose job is to *observe* traffic +# silently starts *rewriting* it. It is also Claude-Code-specific — the scan reads +# ~/.claude/projects — so for anyone driving a different agent it would be a +# mutation with no upside. Opting in is one command, and it belongs to the person +# who knows whether they want it. local_cfg="${CORTEX_DIR}/config.yaml" -if [ -n "${PRUNE:-}" ] && [ -f "${local_cfg}" ]; then - info "Choosing unused tools to prune from your own ~/.claude/projects transcripts..." - if "${BIN_DIR}/abctl" tools scan --write "${local_cfg}"; then - info "" - info "To keep a tool, delete its name from the remove: list in ${local_cfg}." - else - info "" - info " Nothing pruned yet. Once you have used Claude Code for a while:" - info " ${abctl_cmd} tools scan --write ${local_cfg}" - fi - info "" -elif [ -f "${local_cfg}" ]; then - info " Fill the prune list when you are ready (hot-reloaded, no restart):" +if [ -f "${local_cfg}" ]; then + info " Using Claude Code? Cut its token cost by pruning tools you never call:" info " ${abctl_cmd} tools scan --write ${local_cfg}" + info " (proposes from your own transcripts; hot-reloaded, no restart)" info "" fi info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" From fb49ffcb12b0ddcfd31bf5800588ac1388373863 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 14:45:51 -0400 Subject: [PATCH 08/19] feat: Add `tools scan --all`, and fix a count that hid the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering "can I scan more than 30 days, or everything?": yes via --days N with no upper bound, but reaching for all history meant --days 36500, which is a magic number rather than an answer. --all says it directly. Investigating the question turned up a genuinely misleading readout. The summary reported tool-call lines counted BEFORE the timestamp filter, so it printed the same figure for --days 1 and --days 36500 — 13826 either way on my transcripts — while displaying "window N day(s)" right beside it. That reads as a window that does nothing, and it is why I suspected a bug in the filter before testing it. The filter was fine; the count was lying. Counted after the filter, the same transcripts now report 5841 / 11551 / 13845 for 7 days / 30 days / all. --days 0 is rejected rather than treated as "everything", and the error now names --all. A zero-width window finds nothing used, so "tools you have not called" becomes every tool in the table — the exact opposite of what someone typing 0 intends. --all and --days together are rejected as ambiguous. Summary says "all history (no window)" for an unbounded scan instead of "window 0 day(s) since 0001-01-01". Docs state which direction is safe, because it is not obvious: widening can only find MORE tools in use, so it proposes FEWER for removal. --all is the cautious end, the 30-day default the aggressive one. Tests cover a 400-day-old call being excluded by the window and included by AllTime, Lines tracking the window across three widths, and the summary not leaking a zero date. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/cmd_tools.go | 31 ++++++-- authbridge/cmd/abctl/toolscan/scan.go | 28 +++++-- authbridge/cmd/abctl/toolscan/scan_test.go | 88 ++++++++++++++++++++++ authbridge/docs/laptop-token-savings.md | 12 ++- authbridge/docs/tool-prune-plugin.md | 15 +++- 5 files changed, 157 insertions(+), 17 deletions(-) diff --git a/authbridge/cmd/abctl/cmd_tools.go b/authbridge/cmd/abctl/cmd_tools.go index 614fd6360..70b5c197b 100644 --- a/authbridge/cmd/abctl/cmd_tools.go +++ b/authbridge/cmd/abctl/cmd_tools.go @@ -12,10 +12,11 @@ import ( const toolsUsage = `abctl tools scan — derive a tool-prune remove list from local transcripts Usage: - abctl tools scan [--days N] [--keep Name,Name] [--dir PATH] [--write CONFIG] + abctl tools scan [--days N | --all] [--keep Name,Name] [--dir PATH] [--write CONFIG] Flags: --days N window in days to consider a tool "used" (default 30) + --all no window: every tool call in every transcript counts --keep LIST comma-separated tool names to withhold from the candidate list --dir PATH transcript directory (default ~/.claude/projects) --write CONFIG patch the remove: list of the tool-prune entry in CONFIG in @@ -35,16 +36,34 @@ func runTools(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("tools scan", flag.ContinueOnError) fs.SetOutput(stderr) days := fs.Int("days", 30, "window in days") + all := fs.Bool("all", false, "consider every transcript, with no recency window") keep := fs.String("keep", "", "comma-separated tool names to keep") dir := fs.String("dir", "", "transcript directory (default ~/.claude/projects)") write := fs.String("write", "", "patch the tool-prune remove: list in this config file") if err := fs.Parse(args[1:]); err != nil { return 2 } + // --days 0 is rejected rather than read as "everything": a zero-width window + // finds nothing used, so it would propose removing every tool it knows — + // the opposite of what someone reaching for 0 means. --all says it explicitly. if *days <= 0 { - fmt.Fprintln(stderr, "abctl: --days must be positive") + fmt.Fprintln(stderr, "abctl: --days must be positive (use --all for no window)") return 2 } + daysSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "days" { + daysSet = true + } + }) + if *all && daysSet { + fmt.Fprintln(stderr, "abctl: --all and --days are mutually exclusive") + return 2 + } + window := *days + if *all { + window = toolscan.AllTime + } scanDir := *dir if scanDir == "" { @@ -56,7 +75,7 @@ func runTools(args []string, stdout, stderr io.Writer) int { scanDir = d } - res, err := toolscan.Scan(scanDir, *days, strings.Split(*keep, ",")) + res, err := toolscan.Scan(scanDir, window, strings.Split(*keep, ",")) if err != nil { fmt.Fprintf(stderr, "abctl: scanning %s: %v\n", scanDir, err) return 1 @@ -66,7 +85,7 @@ func runTools(args []string, stdout, stderr io.Writer) int { return 1 } - fmt.Fprint(stdout, res.Summary(*days)) + fmt.Fprint(stdout, res.Summary(window)) if *write == "" { fmt.Fprintln(stdout) fmt.Fprint(stdout, res.YAMLBlock()) @@ -85,8 +104,8 @@ func runTools(args []string, stdout, stderr io.Writer) int { fmt.Fprint(stdout, res.YAMLBlock()) fmt.Fprintf(stderr, "\nabctl: not writing %s — the scan observed no tool calls at all in the\n"+ "last %d day(s), so it has no evidence for what you do not use. Use Claude Code\n"+ - "for a while and re-run, widen the window with --days, or paste the block above\n"+ - "yourself once you have checked it.\n", *write, *days) + "for a while and re-run, widen the window with --days or --all, or paste the\n"+ + "block above yourself once you have checked it.\n", *write, *days) return 1 } diff --git a/authbridge/cmd/abctl/toolscan/scan.go b/authbridge/cmd/abctl/toolscan/scan.go index 488194779..f5b7569e7 100644 --- a/authbridge/cmd/abctl/toolscan/scan.go +++ b/authbridge/cmd/abctl/toolscan/scan.go @@ -17,7 +17,7 @@ import ( type Result struct { Since time.Time Files int - Lines int // lines that survived the literal prefilter + Lines int // tool-call lines inside the window Called []string // tool names actually invoked in the window, sorted CallCounts map[string]int Candidates []string // known, never called, not kept, not implied — sorted @@ -51,8 +51,17 @@ func DefaultProjectsDir() (string, error) { // Tool calls are deduplicated by the unique tool_use block id: the same // assistant turn is rewritten into the transcript on every resume, so counting // raw occurrences would inflate heavily-resumed sessions. +// AllTime, passed as days, disables the recency window: every tool call in every +// transcript counts as used. It is the safe direction to err in — a wider window +// can only ever find MORE tools in use, so it proposes fewer for removal. +const AllTime = 0 + func Scan(dir string, days int, keep []string) (*Result, error) { - since := time.Now().AddDate(0, 0, -days) + // A zero Since means unbounded: no real timestamp is Before it. + var since time.Time + if days > 0 { + since = time.Now().AddDate(0, 0, -days) + } res := &Result{Since: since, CallCounts: map[string]int{}} seenIDs := make(map[string]struct{}) @@ -127,7 +136,6 @@ func scanFile(path string, since time.Time, seenIDs map[string]struct{}, res *Re if !bytes.Contains(line, []byte(`"tool_use"`)) { continue } - res.Lines++ var e transcriptEntry if err := json.Unmarshal(line, &e); err != nil { @@ -136,6 +144,11 @@ func scanFile(path string, since time.Time, seenIDs map[string]struct{}, res *Re if !e.Timestamp.IsZero() && e.Timestamp.Before(since) { continue } + // Counted AFTER the window filter. Counting before it meant the summary + // reported the same figure for --days 1 and --days 36500 while printing + // "window N day(s)" beside it, which reads as a broken window. + res.Lines++ + for _, c := range e.Message.Content { if c.Type != "tool_use" || c.Name == "" { continue @@ -179,8 +192,13 @@ func (r *Result) YAMLBlock() string { // Summary is the human-readable preamble printed above the YAML block. func (r *Result) Summary(days int) string { var b strings.Builder - fmt.Fprintf(&b, "Scanned %d transcript(s), %d tool-call line(s), window %d day(s) since %s.\n", - r.Files, r.Lines, days, r.Since.Format("2006-01-02")) + if days <= AllTime { + fmt.Fprintf(&b, "Scanned %d transcript(s), %d tool-call line(s), all history (no window).\n", + r.Files, r.Lines) + } else { + fmt.Fprintf(&b, "Scanned %d transcript(s), %d tool-call line(s), window %d day(s) since %s.\n", + r.Files, r.Lines, days, r.Since.Format("2006-01-02")) + } fmt.Fprintf(&b, "Called in window (%d): %s\n", len(r.Called), joinOrNone(r.Called)) fmt.Fprintf(&b, "Removal candidates (%d): %s\n", len(r.Candidates), joinOrNone(r.Candidates)) if len(r.Kept) > 0 { diff --git a/authbridge/cmd/abctl/toolscan/scan_test.go b/authbridge/cmd/abctl/toolscan/scan_test.go index 2f8971ca5..b90d828ed 100644 --- a/authbridge/cmd/abctl/toolscan/scan_test.go +++ b/authbridge/cmd/abctl/toolscan/scan_test.go @@ -208,3 +208,91 @@ func TestYAMLBlock(t *testing.T) { t.Errorf("no candidates should render an empty list:\n%s", empty) } } + +// TestScan_AllTimeIgnoresTheWindow: --all exists because reaching for it via a +// huge --days is obscure, and because the honest answer to "scan everything" must +// not be a magic number. A wider window can only find MORE tools in use, so it +// proposes fewer for removal — the safe direction. +func TestScan_AllTimeIgnoresTheWindow(t *testing.T) { + dir := t.TempDir() + now := time.Now() + writeTranscript(t, dir, "a.jsonl", + entry(now.AddDate(0, 0, -400), "toolu_old", "WebSearch"), + entry(now, "toolu_new", "WebFetch"), + ) + + windowed, err := Scan(dir, 30, nil) + if err != nil { + t.Fatal(err) + } + if contains(windowed.Called, "WebSearch") { + t.Error("30-day window counted a 400-day-old call as used") + } + if !contains(windowed.Candidates, "WebSearch") { + t.Error("WebSearch should be a removal candidate inside a 30-day window") + } + + everything, err := Scan(dir, AllTime, nil) + if err != nil { + t.Fatal(err) + } + if !contains(everything.Called, "WebSearch") { + t.Errorf("AllTime missed the 400-day-old call; Called = %v", everything.Called) + } + if contains(everything.Candidates, "WebSearch") { + t.Error("AllTime must not propose removing a tool it saw called") + } +} + +// TestScan_LinesCountsOnlyInWindow guards a fix for a genuinely misleading +// readout: Lines used to be incremented before the timestamp filter, so the +// summary printed the same tool-call count for --days 1 and --days 36500 while +// displaying "window N day(s)" beside it. That reads as a broken window. +func TestScan_LinesCountsOnlyInWindow(t *testing.T) { + dir := t.TempDir() + now := time.Now() + writeTranscript(t, dir, "a.jsonl", + entry(now.AddDate(0, 0, -400), "toolu_old", "WebSearch"), + entry(now.AddDate(0, 0, -200), "toolu_mid", "WebFetch"), + entry(now, "toolu_new", "Bash"), + ) + + for _, tc := range []struct { + name string + days int + want int + }{ + {"30 days: only the recent call", 30, 1}, + {"300 days: two of three", 300, 2}, + {"all history: every call", AllTime, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + res, err := Scan(dir, tc.days, nil) + if err != nil { + t.Fatal(err) + } + if res.Lines != tc.want { + t.Errorf("Lines = %d, want %d", res.Lines, tc.want) + } + }) + } +} + +// TestSummary_AllTimeSaysSoInsteadOfPrintingAZeroWindow: with no window there is +// no meaningful "since" date, and printing "window 0 day(s) since 0001-01-01" +// would look like a bug. +func TestSummary_AllTimeSaysSoInsteadOfPrintingAZeroWindow(t *testing.T) { + dir := t.TempDir() + writeTranscript(t, dir, "a.jsonl", entry(time.Now(), "toolu_1", "Bash")) + res, err := Scan(dir, AllTime, nil) + if err != nil { + t.Fatal(err) + } + got := res.Summary(AllTime) + if !strings.Contains(got, "all history") { + t.Errorf("summary does not say the scan was unbounded: %q", got) + } + if strings.Contains(got, "0001-01-01") || strings.Contains(got, "window 0 day") { + t.Errorf("summary leaked the zero window: %q", got) + } +} diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index 46db3ef51..a04eeaf39 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -21,6 +21,14 @@ That reads your `~/.claude/projects` transcripts, proposes the built-in tools yo have not called in 30 days, and writes them to `tool-prune`'s `remove:` list. The proxy hot-reloads, so it takes effect immediately — no restart. +**Choosing the window.** `--days N` sets it; `--all` ignores it and counts every +call in every transcript. Widening is the *cautious* direction: a longer window can +only find more tools in use, so it proposes fewer for removal. Reach for `--all` if +you have been using Claude Code for months and want nothing pruned that you have +ever touched; keep the 30-day default to also drop tools you used once and moved on +from. (`--days 0` is rejected rather than read as "everything" — a zero-width +window finds nothing used, which would propose removing every tool it knows.) + It prints what it chose before writing. Two guards on what it will propose: - It only ever proposes tools it **recognises**, and never one it has **seen you @@ -93,8 +101,8 @@ merely a smaller saving. So: abctl tools scan --write ~/.cortex/config.yaml ``` - The proxy hot-reloads; no restart. Use `--days N` to widen the window and - `--keep Name,Name` to protect specific tools. + The proxy hot-reloads; no restart. `--days N` / `--all` set the window (see + above) and `--keep Name,Name` protects specific tools by name. - **If a tool goes missing, delete its name from `remove:`** in `~/.cortex/config.yaml`. It comes back without a restart. diff --git a/authbridge/docs/tool-prune-plugin.md b/authbridge/docs/tool-prune-plugin.md index 99a52235b..11ccd7397 100644 --- a/authbridge/docs/tool-prune-plugin.md +++ b/authbridge/docs/tool-prune-plugin.md @@ -296,15 +296,22 @@ change the plugin. ## Where the list comes from ```sh -abctl tools scan [--days 30] [--keep Name,Name] [--dir PATH] [--write CONFIG] +abctl tools scan [--days N | --all] [--keep Name,Name] [--dir PATH] [--write CONFIG] ``` It reads `~/.claude/projects/**/*.jsonl`, deduplicates tool calls by their unique `tool_use` block id (a transcript is rewritten on every resume, so raw occurrences would inflate heavily-resumed sessions), and windows to the last -`--days`. Without `--write` it prints the YAML block; with `--write` it patches -the `remove:` list of the `tool-prune` entry in place, idempotently and without -reformatting the rest of the file. +`--days` (30 by default). Without `--write` it prints the YAML block; with +`--write` it patches the `remove:` list of the `tool-prune` entry in place, +idempotently and without reformatting the rest of the file. + +`--all` drops the window entirely: every call in every transcript counts as use. +Widening is the cautious direction — a longer window can only find more tools in +use, so it proposes fewer for removal — which is why `--days 0` is an error +rather than a synonym for `--all`: a zero-width window finds nothing used and +would propose removing every tool in the table. The summary line states which +mode ran, so a figure is never ambiguous about the window behind it. **The offered-set problem.** Transcripts record tools that were *called*, never tools that were *offered*. This is structural, not a defect: a From 655b71dbe0ee29f71f2a08936c752678f80ecc06 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 14:54:01 -0400 Subject: [PATCH 09/19] fix: Say which window the printed scan command uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer prints `abctl tools scan --write ` with no --days, so it applies the 30-day default — but the hint only said "proposes from your own transcripts", which does not reveal that a window is being applied at all, let alone which one. That matters because the window decides what gets pruned, and the cautious direction is counter-intuitive: a WIDER window finds more tools in use and therefore proposes fewer for removal. Someone reading only the installer output had no way to know either fact. It now names the 30 days and points at --all for sparing anything ever called. The cost page already stated both; this closes the gap for anyone following the terminal rather than the doc. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/authbridge/install.sh b/authbridge/install.sh index eda470f1d..dc9cfd4a7 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -315,7 +315,8 @@ local_cfg="${CORTEX_DIR}/config.yaml" if [ -f "${local_cfg}" ]; then info " Using Claude Code? Cut its token cost by pruning tools you never call:" info " ${abctl_cmd} tools scan --write ${local_cfg}" - info " (proposes from your own transcripts; hot-reloaded, no restart)" + info " (proposes tools absent from your last 30 days of transcripts;" + info " add --all to spare anything you have ever called; hot-reloaded)" info "" fi info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" From 5f654ecff6ef786158dac9a424ea1a6a37653b07 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 15:04:21 -0400 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20Address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20Linux=20no-op,=20phantom=20paths,=20key=20perms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All twelve findings verified against the code; all twelve were right. **stop_previous_cortex never ran on Linux.** "authbridge-proxy" is 16 characters and Linux caps comm at TASK_COMM_LEN-1 = 15, so `ps -o comm=` reports "authbridge-prox" and the `*authbridge-proxy*` glob never matched. The function bailed every time — meaning commit 4's headline fix, following the README and then the token-cost guide, still died on a bind conflict there. macOS does not truncate, which is exactly why testing on a Mac passed. Now matches a 15-character-safe prefix. **It was also gated behind port_in_use**, which reports "free" when neither lsof nor nc exists. On a minimal container that skipped the stop, the new proxy hit the conflict anyway, and the user got a dead install with a previous instance of ours sitting in a pidfile we own. Called unconditionally now; it is already a no-op when nothing of ours is running. **The preflight never checked 47604**, which local mode binds for health — so an occupied port let the download finish and then killed the proxy during startup. **~/.cortex/local does not exist.** Eight references across three files pointed at it. Commit 3 renamed demo/ -> local/, commit 5 consolidated to a single ~/.cortex/config.yaml, and I never walked the commit-3 renames back. Two of them were the worst possible places: docs/tool-prune-plugin.md gave the one command that page exists for, and PatchConfig's error — whose whole job is telling a lost user where the config is — pointed at the same phantom directory, so there was no way to recover from inside the message. **The session-budget doc was a regression, not an incomplete rename.** Its text was correct before I touched it: that walkthrough passes -config explicitly, and that config sets `ca_dir: "cortex-ca"` — cwd-relative, never under ~/.cortex. I replaced accurate wording with a path that does not exist, and NODE_EXTRA_CA_CERTS pointing at a missing file fails silently: every request tunnels opaquely and nothing looks broken. Restored, and it now says why this one is cwd-relative. **Key-directory permissions did not hold up.** MkdirAll leaves an existing directory's mode alone, so a ~/.cortex created earlier stayed 0755 under a bare `authbridge-proxy --local` (install.sh chmods, the Go did not) — added os.Chmod. And the directory that actually holds the signing key is created by tlsbridge at 0755; harmless inside a 0700 parent, but --ca-dir elsewhere has no private parent. Now 0700. **The no-$HOME fallback silently restored the bug this PR opens with**, writing the CA and its key into the working directory with nothing said. UserHomeDir only fails when $HOME is unset, so it now fails loudly and names --ca-dir. **The lite tag list had a fourth copy** in local-build-and-test.sh, still missing exclude_plugin_toolprune. Since a missing exclude_plugin_* tag compiles the plugin IN and only changes binary size, drift produces no error anywhere — second incident in two weeks. All four now read cmd/authbridge-proxy/LITE_BUILD_TAGS, following the CPEX_FFI_VERSION precedent; build.yaml resolves it in the step that already resolves cpex's. Also: --help printed nothing through the documented `curl | sh -s --` pipe ($0 is "sh", so reading the script failed and 2>/dev/null hid it) — replaced with a heredoc usage() that also covers the env vars the sed range missed; "((already installed))" now reads once; cmd/README.md claimed every port is a `listener.*` address when 9093 is `stats.stats_address`; and both POSIX installers now say why they use `set -eu` rather than the repo's bash `set -euo pipefail`. Verified: the truncated, full and absolute forms of the process name all match while python3/sleep do not; the stop fires end to end; lite builds and tests from the shared tag file; all three consumer paths resolve; four workflows parse; five modules and authlib clean; shellcheck clean on three scripts. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .github/workflows/build.yaml | 15 +++-- .github/workflows/ci.yaml | 8 ++- .github/workflows/release-binaries.yaml | 16 ++--- authbridge/authlib/tlsbridge/ca.go | 8 ++- authbridge/cmd/README.md | 15 +++-- authbridge/cmd/abctl/toolscan/patch.go | 14 ++-- .../cmd/authbridge-proxy/LITE_BUILD_TAGS | 1 + authbridge/cmd/authbridge-proxy/local.go | 32 +++++++-- authbridge/cmd/authbridge-proxy/main.go | 5 +- .../session-budget/hitl-with-claude-code.md | 28 ++++---- authbridge/docs/tool-prune-plugin.md | 2 +- authbridge/install-demo.sh | 3 + authbridge/install.sh | 65 +++++++++++++++---- local-build-and-test.sh | 2 +- 14 files changed, 148 insertions(+), 66 deletions(-) create mode 100644 authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7fa2965aa..1cb18596d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -52,11 +52,12 @@ jobs: # and the parsers, roughly halving the binary). A build variant, # not a separate binary. Same listener layout as the full proxy # image; not yet referenced by the operator's default config. + # GO_BUILD_TAGS is resolved in the build step below from + # cmd/authbridge-proxy/LITE_BUILD_TAGS, so the tag list lives in one + # place rather than being copied into four. - name: authbridge-lite context: ./authbridge dockerfile: cmd/authbridge-proxy/Dockerfile - build_args: | - GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker,exclude_plugin_toolprune # AuthBridge proxy-sidecar CPEX image — authbridge-proxy built # with -tags cpex (links libcpex_ffi.a from a pinned CPEX @@ -121,7 +122,10 @@ jobs: # Add 'latest' tag for version tags, workflow_dispatch, and pushes to main type=raw,value=latest,enable=${{ (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }} - # 6b. Resolve build-args. authbridge-cpex needs CPEX_FFI_VERSION + # 6b. Resolve build-args. authbridge-lite reads its exclude_plugin_* tag + # list from cmd/authbridge-proxy/LITE_BUILD_TAGS (one source of truth for + # ci.yaml, release-binaries.yaml and local-build-and-test.sh too). + # authbridge-cpex needs CPEX_FFI_VERSION # (the release tag) and CPEX_FFI_ABI (the FFI ABI integer the # linked lib must report) — both read from the files next to its # Dockerfile and asserted against the tarball at build time. Other @@ -129,7 +133,10 @@ jobs: - name: Resolve build args id: buildargs run: | - if [[ "${{ matrix.image_config.name }}" == "authbridge-cpex" ]]; then + if [[ "${{ matrix.image_config.name }}" == "authbridge-lite" ]]; then + TAGS="$(tr -d '[:space:]' < authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS)" + echo "args=GO_BUILD_TAGS=${TAGS}" >> "$GITHUB_OUTPUT" + elif [[ "${{ matrix.image_config.name }}" == "authbridge-cpex" ]]; then VERSION="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION)" ABI="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI)" { diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a3bf29fe7..3e797b6b9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -120,9 +120,11 @@ jobs: - name: Build + test lite variant (exclude_plugin_* tags) if: matrix.binary == 'authbridge-proxy' run: | - TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser" - TAGS="$TAGS,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker" - TAGS="$TAGS,exclude_plugin_toolprune" + # Single source of truth, so this list cannot drift from the release + # and image builds. It silently can: a missing exclude_plugin_* tag + # compiles the plugin IN and only makes the binary bigger, so a stale + # copy produces no error anywhere. working-directory is this module. + TAGS="$(tr -d '[:space:]' < LITE_BUILD_TAGS)" go build -v -tags "$TAGS" ./... go test -v -race -cover -tags "$TAGS" ./... diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml index 1df94cd31..6a7e3114b 100644 --- a/.github/workflows/release-binaries.yaml +++ b/.github/workflows/release-binaries.yaml @@ -52,16 +52,12 @@ jobs: # authbridge-proxy variants: ":". Empty # suffix is the default plugin set. One variant per opt-in # plugin (or one combined "full") — never enumerate combos. - lite_tags="exclude_plugin_a2aparser,exclude_plugin_ibac" - lite_tags="${lite_tags},exclude_plugin_inferenceparser" - lite_tags="${lite_tags},exclude_plugin_mcpparser,exclude_plugin_opa" - lite_tags="${lite_tags},exclude_plugin_sparc,exclude_plugin_tokenbroker" - # tool-prune declares RequiresAny: [inference-parser], which lite - # excludes — so leaving it compiled in only adds bytes to a variant - # whose whole purpose is to be small, and any config naming it would - # fail Build. ci.yaml's lite tag set already excludes it; keep these - # two in step. - lite_tags="${lite_tags},exclude_plugin_toolprune" + # Single source of truth shared with ci.yaml, build.yaml and + # local-build-and-test.sh. Drift here is invisible: a missing + # exclude_plugin_* tag compiles the plugin in and only changes the + # binary size, so nothing fails. + lite_tags="$(tr -d '[:space:]' < authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS)" + declare -a proxy_variants=( ":" "lite:${lite_tags}" diff --git a/authbridge/authlib/tlsbridge/ca.go b/authbridge/authlib/tlsbridge/ca.go index 62f30f51e..410d3b14d 100644 --- a/authbridge/authlib/tlsbridge/ca.go +++ b/authbridge/authlib/tlsbridge/ca.go @@ -135,7 +135,13 @@ func NewGeneratedFileSource(certPath, keyPath, trustPath string) (CASource, erro if err != nil { return nil, err } - if err := os.MkdirAll(filepath.Dir(certPath), 0o755); err != nil { + // 0700, not 0755: this directory is about to hold a CA signing key. The key + // file itself is 0600 below, so 0755 exposed the listing rather than the key + // — but under the default layout ca_dir sits inside a 0700 ~/.cortex, and with + // an explicit --ca-dir elsewhere it had no private parent at all. An existing + // directory keeps its mode (MkdirAll does not tighten), so a mounted ca_dir is + // unaffected. + if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil { return nil, fmt.Errorf("tlsbridge: create ca_dir: %w", err) } // Each file is written atomically (temp + rename) so a reader or a diff --git a/authbridge/cmd/README.md b/authbridge/cmd/README.md index 329a4fd76..32485172e 100644 --- a/authbridge/cmd/README.md +++ b/authbridge/cmd/README.md @@ -51,13 +51,14 @@ ConfigMap contracts are documented in `8080` and `8083` are mutually exclusive: `inbound_interception` picks one inbound mechanism, and the preset fills only that one's address. -Every one of these is a `listener.*` address and can be overridden — which -matters for running two proxies on one host, since a second instance on the -default ports dies on a bind conflict. Local single-host setups typically pin -them all to `127.0.0.1`; the defaults bind every interface, which is what -Kubernetes probes and sidecar traffic need but not what a laptop wants. See -[`docs/laptop-token-savings.md`](../docs/laptop-token-savings.md) for a worked -loopback-only config. +All of these are overridable, which matters for running two proxies on one host: +a second instance on the default ports dies on a bind conflict. They are not all +under the same config key — everything above is a `listener.*` address except +`9093`, which is `stats.stats_address`. The defaults bind every interface, which +is what Kubernetes probes and sidecar traffic need but not what a laptop wants; +local single-host setups typically pin them all to `127.0.0.1`. `authbridge-proxy +--local` ships exactly such a config — see +[`docs/laptop-token-savings.md`](../docs/laptop-token-savings.md). `8082` and `8083` are the iptables REDIRECT targets installed by [`proxy-init`](../proxy-init/) and must match its `TRANSPARENT_PORT` / diff --git a/authbridge/cmd/abctl/toolscan/patch.go b/authbridge/cmd/abctl/toolscan/patch.go index 2b16238d2..ab90d37cc 100644 --- a/authbridge/cmd/abctl/toolscan/patch.go +++ b/authbridge/cmd/abctl/toolscan/patch.go @@ -27,18 +27,18 @@ func PatchConfig(path string, candidates []string) (changed bool, err error) { orig, err := os.ReadFile(path) //nolint:gosec // operator-supplied config path if err != nil { if errors.Is(err, os.ErrNotExist) { - // The bare os error ("open demo.yaml: no such file or - // directory") is technically complete and practically useless: the - // demo anchors its config to the directory it was launched from, so - // a relative path resolves against the wrong place more often than - // the right one. Say where we looked and what to do about it. + // The bare os error ("open config.yaml: no such file or directory") + // is technically complete and practically useless when the caller + // passed a relative path. Say where we looked and where the config + // actually is. This message exists to rescue a lost user, so a wrong + // path here is worse than no path at all. abs, aerr := filepath.Abs(path) if aerr != nil { abs = path } return false, fmt.Errorf("no config at %s\n"+ - " authbridge-proxy --local writes config.yaml under ~/.cortex/local,\n"+ - " so run this from there or pass an absolute path. To find it:\n"+ + " authbridge-proxy --local writes ~/.cortex/config.yaml,\n"+ + " so pass that, or an absolute path. To find it:\n"+ " curl -s localhost:47602/config | grep ca_dir", abs) } return false, err diff --git a/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS b/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS new file mode 100644 index 000000000..7699d01b9 --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS @@ -0,0 +1 @@ +exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker,exclude_plugin_toolprune diff --git a/authbridge/cmd/authbridge-proxy/local.go b/authbridge/cmd/authbridge-proxy/local.go index b8ad6d43f..a2663a838 100644 --- a/authbridge/cmd/authbridge-proxy/local.go +++ b/authbridge/cmd/authbridge-proxy/local.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "log/slog" "os" "path/filepath" @@ -22,25 +23,35 @@ const ( // caDirName holds the bridge CA. Separate from the config so one directory // listing distinguishes "your settings" from "generated key material". caDirName = "ca" - // localDirFallback is used only when the home directory cannot be - // determined, which is the historical cwd-relative behaviour. + // localDirFallback is the directory --local used before the CA moved under + // $HOME. It is no longer written to; it survives so main.go can spot a stale + // one left in a working directory and warn that the client's trust anchor + // needs updating. localDirFallback = "cortex-ca" ) -// defaultCortexDir returns ~/.cortex, or ./cortex-ca if there is no resolvable -// home directory. +// defaultCortexDir returns ~/.cortex, or an error if there is no resolvable home +// directory. // // This used to be cwd-relative unconditionally, on the reasoning that no // absolute path should be baked into the binary. Resolving $HOME at runtime // satisfies that while keeping the private key in one predictable place — and // the cwd default had a real cost: it dropped a CA and private key into // whatever directory the proxy was started from, including checkouts. -func defaultCortexDir() string { +// +// It returns an error rather than falling back to a relative path, because that +// fallback silently reintroduced exactly that bug: no $HOME meant the key landed +// in the working directory again, with nothing said about it. UserHomeDir only +// fails when $HOME is unset — a bare `env -i`, some systemd units, a scratch +// container — so failing loudly costs nothing anyone hits by accident, and +// --ca-dir remains available for those cases. +func defaultCortexDir() (string, error) { home, err := os.UserHomeDir() if err != nil || home == "" { - return localDirFallback + return "", fmt.Errorf("cannot determine your home directory (is $HOME set?); "+ + "pass --ca-dir to choose where the CA is written: %w", err) } - return filepath.Join(home, cortexDirName) + return filepath.Join(home, cortexDirName), nil } // builtinConfigYAML returns the built-in --local config with caDir interpolated: a @@ -126,6 +137,13 @@ func writeBuiltinConfig(cortexDir, caDir string) (string, error) { if err := os.MkdirAll(cortexDir, 0o700); err != nil { return "", err } + // MkdirAll leaves an existing directory's mode alone, so a ~/.cortex created + // before this (or by another tool) would stay 0755 and the perms claim would + // be true only for fresh installs. install.sh already chmods it; this makes a + // bare `authbridge-proxy --local` match. + if err := os.Chmod(cortexDir, 0o700); err != nil { + return "", err + } path := filepath.Join(cortexDir, localConfigName) if _, err := os.Stat(path); err == nil { slog.Info("local mode — keeping the existing config (edits and any prune list are preserved)", diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index bfe44cc72..cae171ffa 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -150,7 +150,10 @@ func main() { if *configPath != "" { log.Fatal("--local and --config are mutually exclusive") } - cortexDir := defaultCortexDir() + cortexDir, derr := defaultCortexDir() + if derr != nil { + log.Fatalf("--local: %v", derr) + } // The default moved here from ./cortex-ca. Someone who still has that // directory almost certainly has a client trusting the CA inside it, // and pointing at a stale CA fails silently — every request tunnels diff --git a/authbridge/demos/session-budget/hitl-with-claude-code.md b/authbridge/demos/session-budget/hitl-with-claude-code.md index e2f5e9f76..d3f159447 100644 --- a/authbridge/demos/session-budget/hitl-with-claude-code.md +++ b/authbridge/demos/session-budget/hitl-with-claude-code.md @@ -62,14 +62,18 @@ there even in `roles: [forward]`, and a stale `authbridge-proxy` from a prior run will fail boot with `address already in use`. If that happens: `pkill -f authbridge-proxy` and relaunch. -The proxy generates `~/.cortex/local/ca.crt` on first launch — that's the -trust anchor Claude Code needs. The path is the same wherever you start -the proxy from, so there is no `$PWD` to keep track of. Look for these +The proxy generates `cortex-ca/ca.crt` on first launch — that's the trust +anchor Claude Code needs. Note that this path is **relative to the +directory you started the proxy from**: the config used here +(`local/config-https.yaml`) sets `ca_dir: "cortex-ca"` explicitly, so it +does not use the `~/.cortex` default that `--local` would. From the repo +root it resolves to `/cortex-ca/ca.crt`; get the absolute path +with `ls "$(pwd)/cortex-ca/ca.crt"` in the same terminal. Look for these lines in the log: ```text -level=WARN msg="tls-bridge: generated self-signed CA ..." ca_dir=/Users/you/.cortex/local ... -level=INFO msg="tls-bridge enabled" ca_dir=/Users/you/.cortex/local +level=WARN msg="tls-bridge: generated self-signed CA ..." ca_dir=cortex-ca ... +level=INFO msg="tls-bridge enabled" ca_dir=cortex-ca level=INFO msg="HTTP server listening" name=forward-proxy addr=127.0.0.1:47600 level=INFO msg="authbridge-proxy starting" mode=proxy-sidecar ``` @@ -85,7 +89,7 @@ settings and out of `~/.claude/settings.json`): { "env": { "HTTPS_PROXY": "http://127.0.0.1:47600", - "NODE_EXTRA_CA_CERTS": "/Users/you/.cortex/local/ca.crt", + "NODE_EXTRA_CA_CERTS": "/absolute/path/to/cortex-ca/ca.crt", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" } } @@ -168,7 +172,7 @@ accumulating. Skip the approver terminal entirely. See [`hitl-local.md`](hitl-local.md) — [§ Reset between runs](hitl-local.md#reset-between-runs), [§ Auto modes for CI](hitl-local.md#auto-modes-for-ci), -[§ Cleanup](hitl-local.md#cleanup). Add `rm -rf ~/.cortex/local` in the +[§ Cleanup](hitl-local.md#cleanup). Add `rm -rf cortex-ca` in the directory the proxy ran from if you want to regenerate the CA on the next run — the new `ca.crt` has a fresh serial, so re-point `NODE_EXTRA_CA_CERTS` in `.claude/settings.local.json` at it (same @@ -181,8 +185,8 @@ will fail the TLS handshake against the proxy. bucket. Fine for a single-workload laptop demo; in multi-tenant deployments one caller exhausting the budget denies all others. Leave it off in production and rely on the inbound A2A session ID. -- **`--local` has its own config at `~/.cortex/local/config.yaml`.** It no - longer depends on which directory you start from, so it cannot clobber - a config kept elsewhere — but two `--local` runs share that one file. - Point one of them at `--ca-dir` if you need two independent runs. - (`--demo` is the old name for this flag and still works.) +- **`--local` uses `~/.cortex/config.yaml`, not this demo's config.** The + two do not collide: this walkthrough passes `-config` explicitly, so + running `authbridge-proxy --local` elsewhere touches a different file and + a different CA. They do share the loopback ports, so run one at a time. + (`--demo` is the old name for `--local` and still works.) diff --git a/authbridge/docs/tool-prune-plugin.md b/authbridge/docs/tool-prune-plugin.md index 11ccd7397..e332f3623 100644 --- a/authbridge/docs/tool-prune-plugin.md +++ b/authbridge/docs/tool-prune-plugin.md @@ -47,7 +47,7 @@ nothing, whatever the policy, so filling the list is the single act that enables it: ```sh -abctl tools scan --write ~/.cortex/local/config.yaml +abctl tools scan --write ~/.cortex/config.yaml ``` The config is hot-reloaded, so no restart. A reload does rebuild the plugin and diff --git a/authbridge/install-demo.sh b/authbridge/install-demo.sh index 228532fed..aed290188 100755 --- a/authbridge/install-demo.sh +++ b/authbridge/install-demo.sh @@ -8,6 +8,9 @@ # # Local installs are no longer framed as a "demo" — they are the supported way to # run Cortex on a machine — hence the rename. +# set -eu, not -euo pipefail: this is POSIX sh (the documented entry point is +# `curl ... | sh`), and `pipefail` is a bashism that would abort the script under +# dash/ash. The repo-wide `set -euo pipefail` convention applies to bash scripts. set -eu URL="https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh" diff --git a/authbridge/install.sh b/authbridge/install.sh index dc9cfd4a7..98260593f 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -38,6 +38,9 @@ # AUTHBRIDGE_INSTALL_ONLY=1 same as --install-only # AUTHBRIDGE_SKIP_DOWNLOAD=1 use the already-installed binaries in ~/.local/bin # instead of downloading (re-run setup offline) +# set -eu, not -euo pipefail: this is POSIX sh (the documented entry point is +# `curl ... | sh`), and `pipefail` is a bashism that would abort the script under +# dash/ash. The repo-wide `set -euo pipefail` convention applies to bash scripts. set -eu REPO="rossoctl/cortex" @@ -50,6 +53,38 @@ info() { printf '%s\n' "$*"; } warn() { printf 'warning: %s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } +# usage is a heredoc rather than sed over "$0": piped as `curl ... | sh -s -- --help` +# the script has no file to read ($0 is "sh"), so the previous version printed +# nothing at all — for the one flag someone is most likely to try before running an +# installer they piped from the internet. +usage() { + cat <<'USAGE' +install.sh — install Cortex on a local machine (macOS/Linux, amd64/arm64). + +Usage: + curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh | sh + curl -fsSL ...install.sh | sh -s -- [option] + +Installs abctl and authbridge-proxy to ~/.local/bin, starts the proxy with its +built-in config in ~/.cortex, and prints the command to send an agent through it. +Traffic is decrypted and parsed for viewing; nothing is rewritten. + +Options: + --install-only install the binaries and stop + --local the default, spelled out + -h, --help this text + +Environment: + AUTHBRIDGE_VERSION=vX.Y.Z install a specific release tag (default: newest) + AUTHBRIDGE_INSTALL_ONLY=1 same as --install-only + AUTHBRIDGE_SKIP_DOWNLOAD=1 use the binaries already in ~/.local/bin instead of + downloading (re-run setup offline) + +After installing, to cut Claude Code's token cost: + abctl tools scan --write ~/.cortex/config.yaml +USAGE +} + # --- mode selection --- MODE=local for arg in "$@"; do @@ -59,7 +94,7 @@ for arg in "$@"; do # so it mirrors the proxy flag of the same name. --local) MODE=local ;; -h | --help) - sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' + usage exit 0 ;; *) die "unknown option: $arg (try --install-only, --local, or no argument)" ;; @@ -93,6 +128,9 @@ sha_check() { DEMO_FORWARD_PORT=47600 DEMO_SESSION_PORT=47601 DEMO_STATS_PORT=47602 +# Bound too, and previously missing from the preflight — an occupied 47604 let the +# download finish and then killed the proxy during startup. +DEMO_HEALTH_PORT=47604 # port_in_use exits 0 if something is already listening on the given loopback # port. Best-effort: uses lsof, then nc; if neither exists, it assumes free. @@ -143,9 +181,14 @@ stop_previous_cortex() { rm -f "$pidfile" return 0 fi + # Match a 15-character prefix, not the full name. Linux caps comm at + # TASK_COMM_LEN-1 = 15 and "authbridge-proxy" is 16, so it reports + # "authbridge-prox" and a *authbridge-proxy* glob never matches — which made + # this whole function a no-op on Linux while passing on macOS, where comm is + # not truncated. Still narrow: the pid came from a pidfile we wrote. name=$(ps -p "$pid" -o comm= 2>/dev/null || true) case "$name" in - *authbridge-proxy*) ;; + *authbridge-prox*) ;; *) return 0 ;; # pid recycled onto something else — never touch it esac info "Stopping the Cortex started earlier (pid ${pid}); the new one replaces it." @@ -160,15 +203,13 @@ stop_previous_cortex() { # --- preflight: fail early (before downloading) if a listener port is taken --- if [ "$MODE" = "local" ]; then - # Clear our own previous instance first, so switching between the two setups - # is one command rather than a bind error and a manual kill. - for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do - if port_in_use "$p"; then - stop_previous_cortex - break - fi - done - for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT"; do + # Unconditionally, not gated on port_in_use: that probe reports "free" when + # neither lsof nor nc exists (see above), so on a minimal container the stop + # would be skipped, the new proxy would hit a bind conflict anyway, and the + # user would be left with a dead install. The function is already a no-op + # when nothing of ours is running, so it needs no probe to justify it. + stop_previous_cortex + for p in "$DEMO_FORWARD_PORT" "$DEMO_SESSION_PORT" "$DEMO_STATS_PORT" "$DEMO_HEALTH_PORT"; do if port_in_use "$p"; then die "port ${p} is already in use. Is Cortex already running (see ${CORTEX_DIR}/proxy.pid)? Otherwise free the port, or change the ports in the config, then re-run." fi @@ -180,7 +221,7 @@ if [ "${AUTHBRIDGE_SKIP_DOWNLOAD:-}" = "1" ]; then for b in abctl authbridge-proxy; do [ -x "${BIN_DIR}/${b}" ] || die "AUTHBRIDGE_SKIP_DOWNLOAD=1 but ${BIN_DIR}/${b} is missing" done - version="(already installed)" + version="already installed" info "Using the binaries already in ${BIN_DIR}" else diff --git a/local-build-and-test.sh b/local-build-and-test.sh index 35a671bd2..6a3208111 100755 --- a/local-build-and-test.sh +++ b/local-build-and-test.sh @@ -97,7 +97,7 @@ echo "Building authbridge-lite (proxy build variant: auth-only plugins)" echo "==========================================" cd "${SCRIPT_DIR}/authbridge" ${CONTAINER_RUNTIME} build -f cmd/authbridge-proxy/Dockerfile \ - --build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker" \ + --build-arg GO_BUILD_TAGS="$(tr -d '[:space:]' < cmd/authbridge-proxy/LITE_BUILD_TAGS)" \ -t ghcr.io/rossoctl/cortex/authbridge-lite:local . load_image_to_kind ghcr.io/rossoctl/cortex/authbridge-lite:local echo "✅ Built and loaded: authbridge-lite:local" From 0987600e2aa5fce20f8015c00fc7bc89220a8c13 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 15:33:09 -0400 Subject: [PATCH 11/19] feat: Run Claude Code as plain `claude`, no env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instructions asked for three environment variables on every invocation. Claude Code reads env vars from the `env` block of ~/.claude/settings.json, so they belong there instead — set once, and `claude` is just `claude`. That is not only shorter, it is more correct. Claude Code's supervisor is one process shared by every terminal and inherits the environment of whichever shell cold-started it, so an exported HTTPS_PROXY or CA path reaches background agents when that shell happened to start the supervisor and silently does not when a different one did. Its own network documentation says to use settings for exactly this reason. New `abctl claude-code enable | disable | status`, and install.sh grows --claude-code to offer it during setup. The merge is in Go rather than shell because the target file routinely holds an API token in the same env block; shell JSON surgery there is not worth attempting. It: - reads the proxy address and ca_dir from ~/.cortex/config.yaml, so the values cannot drift from the running proxy. Hardcoding 47600 would point Claude Code at nothing the moment someone edited their config. - writes only its three keys and leaves every other setting byte-identical, including ANTHROPIC_BASE_URL and any auth token - copies the file to settings.json.bak, then replaces it atomically — Claude Code watches and reloads it, so a half-written file would be read - refuses when HTTPS_PROXY is already set to something else, rather than breaking a corporate proxy silently - refuses to write over settings.json it cannot parse, instead of destroying keys it never read - is idempotent, so install.sh can run it on every invocation install.sh delegates the prompt to abctl, which reads /dev/tty: stdin there is the script itself under `curl ... | sh`, so reading stdin would consume the script or see EOF and silently decline. With no controlling terminal it says so and declines, which install.sh treats as skipped, not failed. Being straight about test coverage: this tool session has no controlling terminal, so I could not exercise the live prompt. confirm() is split into the tty-opening half and confirmFrom(), and the tests cover the answer parsing (y/Y/yes/YES apply; n, empty, EOF and anything ambiguous decline, because the file holds tokens) plus the no-tty fallback path end to end. The --yes path, the preservation property, the config-derived addresses, the clobber and bad-JSON refusals, disable, and idempotency are all tested against a settings.json shaped like a real one. While enabled, Claude Code needs Cortex running — its requests go to the proxy address. `abctl claude-code disable` is the off switch, and both the command and the README say so. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 30 +- authbridge/cmd/abctl/cmd_claudecode.go | 376 ++++++++++++++++++++ authbridge/cmd/abctl/cmd_claudecode_test.go | 257 +++++++++++++ authbridge/cmd/abctl/main.go | 4 +- authbridge/docs/laptop-token-savings.md | 11 +- authbridge/install.sh | 33 +- 6 files changed, 694 insertions(+), 17 deletions(-) create mode 100644 authbridge/cmd/abctl/cmd_claudecode.go create mode 100644 authbridge/cmd/abctl/cmd_claudecode_test.go diff --git a/README.md b/README.md index bcca51f36..90ca588ea 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,16 @@ decrypted and parsed live on your laptop. writes one config under `~/.cortex`, and starts the proxy in the background: ```sh - curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh | sh + curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh \ + | sh -s -- --claude-code ``` - Traffic is decrypted and parsed for viewing; nothing is rewritten. Add - `--install-only` for just the binaries. + `--claude-code` then asks whether to point Claude Code at it, by adding three + variables to the `env` block of `~/.claude/settings.json`. It shows them first + and changes nothing else. Say no and everything still works — you just pass + the variables yourself. (`--install-only` installs the binaries and stops.) + + Traffic is decrypted and parsed for viewing; nothing is rewritten. 2. **Open the live viewer** in another terminal: @@ -31,18 +36,21 @@ decrypted and parsed live on your laptop. abctl --endpoint http://localhost:47601 ``` -3. **Send an agent's traffic through it** — e.g. Claude Code, from any directory: +3. **Run Claude Code normally:** ```sh - HTTPS_PROXY=http://localhost:47600 \ - NODE_EXTRA_CA_CERTS="$HOME/.cortex/ca/ca.crt" \ - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ - claude + claude ``` - Its calls stream into `abctl`, decrypted and parsed. Step 1 prints this line - with the paths already filled in. Stop the proxy with - `kill $(cat ~/.cortex/proxy.pid)`. + Its calls stream into `abctl`, decrypted and parsed. Stop the proxy with + `kill $(cat ~/.cortex/proxy.pid)`, and undo the settings change with + `abctl claude-code disable`. + + Settings rather than a shell export on purpose: Claude Code's supervisor is one + process shared by every terminal and inherits whichever shell started it first, + so an exported variable reaches background agents only by luck. If you would + rather not have it edit the file, `abctl claude-code status` shows what it would + set and you can pass those three variables to `claude` yourself. **Using Claude Code?** One more command turns this into a cost saving — Cortex strips the tool definitions your agent never calls out of every request, worth diff --git a/authbridge/cmd/abctl/cmd_claudecode.go b/authbridge/cmd/abctl/cmd_claudecode.go new file mode 100644 index 000000000..64f494a57 --- /dev/null +++ b/authbridge/cmd/abctl/cmd_claudecode.go @@ -0,0 +1,376 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/rossoctl/cortex/authbridge/authlib/config" +) + +// The three variables Claude Code needs to route through Cortex. Claude Code +// reads env vars from its own settings file, which is not merely more convenient +// than exporting them in a shell — it is more correct. The supervisor is one +// process shared by every terminal and inherits the environment of whichever +// shell cold-started it, so a shell export reaches background agents only by +// luck. Settings reach every session on the machine. +const ( + envProxy = "HTTPS_PROXY" + envCACerts = "NODE_EXTRA_CA_CERTS" + envNoTelem = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC" + settingsRel = ".claude/settings.json" + cortexCfgRel = ".cortex/config.yaml" +) + +// managedKeys is exactly what enable writes and disable removes. Nothing else in +// the file is touched — notably not ANTHROPIC_BASE_URL or any auth token, which +// commonly live in the same env block. +var managedKeys = []string{envProxy, envCACerts, envNoTelem} + +const claudeCodeUsage = `abctl claude-code — route Claude Code through Cortex without shell env vars + +Usage: + abctl claude-code enable [--yes] [--settings PATH] [--config PATH] + abctl claude-code disable [--yes] [--settings PATH] + abctl claude-code status [--settings PATH] + +enable writes HTTPS_PROXY, NODE_EXTRA_CA_CERTS and +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC into the "env" block of +~/.claude/settings.json, reading the addresses from ~/.cortex/config.yaml so they +always match the running proxy. Afterwards, plain "claude" goes through Cortex. + +Only those three keys are added; every other setting, including any other env +entry, is left exactly as it was. The previous file is copied to +settings.json.bak first. disable removes only those three keys. + +Note: while enabled, Claude Code needs Cortex running — its requests go to the +proxy address. "abctl claude-code disable" is the off switch. + +Flags: + --yes do not prompt for confirmation + --settings PATH Claude Code settings file (default ~/.claude/settings.json) + --config PATH Cortex config to read addresses from (default ~/.cortex/config.yaml) +` + +func runClaudeCode(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprint(stderr, claudeCodeUsage) + return 2 + } + action := args[0] + + fs := flag.NewFlagSet("claude-code "+action, flag.ContinueOnError) + fs.SetOutput(stderr) + yes := fs.Bool("yes", false, "do not prompt for confirmation") + settingsPath := fs.String("settings", "", "Claude Code settings file") + cortexCfg := fs.String("config", "", "Cortex config file") + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + + home, err := os.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintf(stderr, "abctl: cannot determine your home directory: %v\n", err) + return 1 + } + if *settingsPath == "" { + *settingsPath = filepath.Join(home, settingsRel) + } + if *cortexCfg == "" { + *cortexCfg = filepath.Join(home, cortexCfgRel) + } + + switch action { + case "enable": + return claudeCodeEnable(*settingsPath, *cortexCfg, *yes, stdout, stderr) + case "disable": + return claudeCodeDisable(*settingsPath, *yes, stdout, stderr) + case "status": + return claudeCodeStatus(*settingsPath, stdout) + default: + fmt.Fprintf(stderr, "abctl: unknown claude-code action %q (enable, disable, status)\n", action) + return 2 + } +} + +// wanted derives the three values from the Cortex config, so they cannot drift +// from the proxy that is actually running. Hardcoding 47600 here would silently +// point Claude Code at nothing the moment someone edited their config. +func wanted(cortexCfgPath string) (map[string]string, error) { + cfg, err := config.Load(cortexCfgPath) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", cortexCfgPath, err) + } + addr := cfg.Listener.ForwardProxyAddr + if addr == "" { + return nil, fmt.Errorf("%s has no listener.forward_proxy_addr; Claude Code needs a forward proxy to point at", cortexCfgPath) + } + // A bind address is not a URL: ":8081" and "127.0.0.1:47600" both need a + // host that a client can actually dial. + host, port, ok := strings.Cut(addr, ":") + if !ok { + return nil, fmt.Errorf("listener.forward_proxy_addr %q is not host:port", addr) + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "localhost" + } + out := map[string]string{ + envProxy: "http://" + host + ":" + port, + envNoTelem: "1", + } + if cfg.TLSBridge.CADir != "" { + ca, aerr := filepath.Abs(filepath.Join(cfg.TLSBridge.CADir, "ca.crt")) + if aerr != nil { + return nil, aerr + } + out[envCACerts] = ca + } + return out, nil +} + +func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stderr io.Writer) int { + want, err := wanted(cortexCfgPath) + if err != nil { + fmt.Fprintf(stderr, "abctl: %v\n", err) + return 1 + } + if _, ok := want[envCACerts]; !ok { + fmt.Fprintf(stderr, "abctl: %s has no tls_bridge.ca_dir, so Claude Code has no CA to trust;\n"+ + " requests would fail certificate verification. Enable the TLS bridge first.\n", cortexCfgPath) + return 1 + } + + doc, err := readSettings(settingsPath) + if err != nil { + fmt.Fprintf(stderr, "abctl: %v\n", err) + return 1 + } + env := envBlock(doc) + + // Refuse to overwrite a value the user set to something else — most likely a + // corporate proxy. Silently replacing it would break their network access and + // give no clue why. + for _, k := range managedKeys { + if cur, ok := env[k]; ok && cur != want[k] && !isCortexValue(k, cur) { + fmt.Fprintf(stderr, "abctl: %s is already set to %q in %s.\n"+ + " Refusing to overwrite a value you set. Remove it first, or edit the file by hand.\n", + k, cur, settingsPath) + return 1 + } + } + + var changes []string + for _, k := range managedKeys { + if env[k] != want[k] { + changes = append(changes, fmt.Sprintf(" %s=%s", k, want[k])) + } + } + if len(changes) == 0 { + fmt.Fprintf(stdout, "Already enabled: %s routes Claude Code through Cortex.\n", settingsPath) + return 0 + } + + fmt.Fprintf(stdout, "This will add to the \"env\" block of %s:\n%s\n\n", + settingsPath, strings.Join(changes, "\n")) + fmt.Fprintf(stdout, "Everything else in the file is left alone, and the current version is\n"+ + "copied to %s.bak first. Afterwards, run Claude Code as plain `claude`.\n\n", settingsPath) + fmt.Fprintf(stdout, "While enabled, Claude Code needs Cortex running. Undo with:\n"+ + " abctl claude-code disable\n\n") + if !yes && !confirm(stdout) { + fmt.Fprintln(stdout, "Not changed.") + return 1 + } + + for _, k := range managedKeys { + env[k] = want[k] + } + doc["env"] = env + if err := writeSettings(settingsPath, doc); err != nil { + fmt.Fprintf(stderr, "abctl: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "\nEnabled. Run `claude` — no environment variables needed.\n") + return 0 +} + +func claudeCodeDisable(settingsPath string, yes bool, stdout, stderr io.Writer) int { + doc, err := readSettings(settingsPath) + if err != nil { + fmt.Fprintf(stderr, "abctl: %v\n", err) + return 1 + } + env := envBlock(doc) + var present []string + for _, k := range managedKeys { + if _, ok := env[k]; ok { + present = append(present, k) + } + } + if len(present) == 0 { + fmt.Fprintf(stdout, "Nothing to do: none of the Cortex variables are set in %s.\n", settingsPath) + return 0 + } + fmt.Fprintf(stdout, "This will remove from %s: %s\n\n", settingsPath, strings.Join(present, ", ")) + if !yes && !confirm(stdout) { + fmt.Fprintln(stdout, "Not changed.") + return 1 + } + for _, k := range present { + delete(env, k) + } + // Drop an env block we just emptied rather than leaving "env": {} behind. + if len(env) == 0 { + delete(doc, "env") + } else { + doc["env"] = env + } + if err := writeSettings(settingsPath, doc); err != nil { + fmt.Fprintf(stderr, "abctl: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "\nDisabled. Claude Code no longer routes through Cortex.\n") + return 0 +} + +func claudeCodeStatus(settingsPath string, stdout io.Writer) int { + doc, err := readSettings(settingsPath) + if err != nil { + fmt.Fprintf(stdout, "not enabled (%v)\n", err) + return 0 + } + env := envBlock(doc) + set := 0 + keys := make([]string, 0, len(managedKeys)) + keys = append(keys, managedKeys...) + sort.Strings(keys) + for _, k := range keys { + if v, ok := env[k]; ok { + fmt.Fprintf(stdout, " %s=%s\n", k, v) + set++ + } else { + fmt.Fprintf(stdout, " %s (unset)\n", k) + } + } + if set == len(managedKeys) { + fmt.Fprintf(stdout, "enabled in %s\n", settingsPath) + } else { + fmt.Fprintf(stdout, "not fully enabled in %s (%d of %d set)\n", settingsPath, set, len(managedKeys)) + } + return 0 +} + +// isCortexValue reports whether an existing value looks like one we wrote, so a +// port change in the Cortex config updates cleanly instead of tripping the +// overwrite guard. +func isCortexValue(key, val string) bool { + switch key { + case envNoTelem: + return val == "1" + case envCACerts: + return strings.Contains(val, ".cortex"+string(os.PathSeparator)) || strings.Contains(val, "cortex-ca") + case envProxy: + return strings.Contains(val, "localhost:476") || strings.Contains(val, "127.0.0.1:476") + } + return false +} + +// readSettings decodes into a generic map so every key the file already has +// survives the round trip, including ones this version of abctl knows nothing +// about. A missing file is an empty document, not an error. +func readSettings(path string) (map[string]any, error) { + b, err := os.ReadFile(path) //nolint:gosec // operator-supplied path + if err != nil { + if os.IsNotExist(err) { + return map[string]any{}, nil + } + return nil, err + } + if len(strings.TrimSpace(string(b))) == 0 { + return map[string]any{}, nil + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("%s is not valid JSON (%w); fix or move it before enabling", path, err) + } + return doc, nil +} + +func envBlock(doc map[string]any) map[string]string { + out := map[string]string{} + raw, ok := doc["env"].(map[string]any) + if !ok { + return out + } + for k, v := range raw { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} + +// writeSettings backs the file up, then replaces it atomically. Claude Code +// watches this file and reloads it, so a half-written file would be read. +func writeSettings(path string, doc map[string]any) error { + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + if cur, rerr := os.ReadFile(path); rerr == nil { //nolint:gosec // operator-supplied path + if werr := os.WriteFile(path+".bak", cur, 0o600); werr != nil { + return fmt.Errorf("writing backup %s.bak: %w", path, werr) + } + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + // 0600: this file commonly holds API tokens in the same env block. + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// confirm reads a yes/no from the terminal. +// +// It opens /dev/tty rather than reading stdin because the documented entry point +// is `curl ... | sh`: there stdin is the script itself, so reading it would +// consume the script or hit EOF and silently decline. When there is no +// controlling terminal — CI, a container, a non-interactive shell — it says so +// and declines, which callers treat as "skipped" rather than failed. +func confirm(stdout io.Writer) bool { + tty, err := os.Open("/dev/tty") + if err != nil { + fmt.Fprintln(stdout, "Not a terminal, so not prompting. Re-run with --yes to apply.") + return false + } + defer tty.Close() + return confirmFrom(tty, stdout) +} + +// confirmFrom is the answer-parsing half, split out so it is testable: a test +// process has no controlling terminal to open, so confirm itself cannot be +// exercised directly. +// +// Anything that is not an explicit yes declines, EOF included. The prompt says +// [y/N] and the destructive direction here is writing to a file that holds API +// tokens, so silence must mean no. +func confirmFrom(r io.Reader, stdout io.Writer) bool { + fmt.Fprint(stdout, "Apply? [y/N] ") + var answer string + if _, err := fmt.Fscanln(r, &answer); err != nil { + return false + } + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + return true + } + return false +} diff --git a/authbridge/cmd/abctl/cmd_claudecode_test.go b/authbridge/cmd/abctl/cmd_claudecode_test.go new file mode 100644 index 000000000..ad8221ea4 --- /dev/null +++ b/authbridge/cmd/abctl/cmd_claudecode_test.go @@ -0,0 +1,257 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// settingsWithSecret is shaped like a real settings.json: unrelated top-level +// keys, and an env block already holding a gateway URL and an auth token. Those +// must survive untouched — the whole risk of this command is collateral damage to +// a file the user did not ask us to reorganise. +const settingsWithSecret = `{ + "model": "opus", + "permissions": {"allow": ["Bash(git:*)"]}, + "env": { + "ANTHROPIC_BASE_URL": "https://gateway.example.com", + "ANTHROPIC_AUTH_TOKEN": "sk-do-not-touch", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1" + } +}` + +const cortexCfg = `mode: proxy-sidecar +listener: + roles: [forward] + forward_proxy_addr: 127.0.0.1:47600 + session_api_addr: 127.0.0.1:47601 + health_addr: 127.0.0.1:47604 +tls_bridge: + mode: enabled + ca_dir: "CADIR" + generate_ca: true +pipeline: + outbound: + plugins: + - name: inference-parser +` + +func fixture(t *testing.T, settings string) (settingsPath, cfgPath string) { + t.Helper() + dir := t.TempDir() + settingsPath = filepath.Join(dir, "settings.json") + if settings != "" { + if err := os.WriteFile(settingsPath, []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + } + cfgPath = filepath.Join(dir, "config.yaml") + body := strings.Replace(cortexCfg, "CADIR", filepath.Join(dir, "ca"), 1) + if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return settingsPath, cfgPath +} + +func readEnv(t *testing.T, path string) map[string]string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("result is not valid JSON: %v\n%s", err, b) + } + out := map[string]string{} + if e, ok := doc["env"].(map[string]any); ok { + for k, v := range e { + if s, ok := v.(string); ok { + out[k] = s + } + } + } + return out +} + +// TestClaudeCodeEnable_PreservesEverythingElse is the property that matters most: +// this file routinely holds an API token, and we are editing it on the user's +// behalf. +func TestClaudeCodeEnable_PreservesEverythingElse(t *testing.T) { + settings, cfg := fixture(t, settingsWithSecret) + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("exit %d: %s", code, errb.String()) + } + + env := readEnv(t, settings) + if env["ANTHROPIC_AUTH_TOKEN"] != "sk-do-not-touch" { + t.Errorf("auth token altered: %q", env["ANTHROPIC_AUTH_TOKEN"]) + } + if env["ANTHROPIC_BASE_URL"] != "https://gateway.example.com" { + t.Errorf("base URL altered: %q", env["ANTHROPIC_BASE_URL"]) + } + if env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] != "1" { + t.Error("an unrelated env entry was dropped") + } + // Addresses come from the Cortex config, not a hardcoded constant. + if env[envProxy] != "http://127.0.0.1:47600" { + t.Errorf("%s = %q", envProxy, env[envProxy]) + } + if !strings.HasSuffix(env[envCACerts], filepath.Join("ca", "ca.crt")) { + t.Errorf("%s = %q, want it under the config's ca_dir", envCACerts, env[envCACerts]) + } + if env[envNoTelem] != "1" { + t.Errorf("%s = %q", envNoTelem, env[envNoTelem]) + } + + // Unrelated top-level keys survive. + b, _ := os.ReadFile(settings) + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + for _, k := range []string{"model", "permissions"} { + if _, ok := doc[k]; !ok { + t.Errorf("top-level key %q was dropped", k) + } + } + if _, err := os.Stat(settings + ".bak"); err != nil { + t.Errorf("no backup written: %v", err) + } +} + +// TestClaudeCodeEnable_ReadsAddressesFromConfig: hardcoding 47600 would point +// Claude Code at nothing the moment someone edited their Cortex config. +func TestClaudeCodeEnable_ReadsAddressesFromConfig(t *testing.T) { + settings, cfg := fixture(t, "{}") + body, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + moved := strings.Replace(string(body), "127.0.0.1:47600", "127.0.0.1:19999", 1) + if err := os.WriteFile(cfg, []byte(moved), 0o600); err != nil { + t.Fatal(err) + } + + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("exit %d: %s", code, errb.String()) + } + if got := readEnv(t, settings)[envProxy]; got != "http://127.0.0.1:19999" { + t.Errorf("%s = %q, want the config's port", envProxy, got) + } +} + +// TestClaudeCodeEnable_RefusesToClobberForeignProxy: someone behind a corporate +// proxy already has HTTPS_PROXY set. Replacing it would break their network and +// give no clue why. +func TestClaudeCodeEnable_RefusesToClobberForeignProxy(t *testing.T) { + settings, cfg := fixture(t, `{"env":{"HTTPS_PROXY":"http://corp:3128"}}`) + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code == 0 { + t.Fatal("accepted a foreign HTTPS_PROXY") + } + if !strings.Contains(errb.String(), "Refusing to overwrite") { + t.Errorf("error did not explain itself: %q", errb.String()) + } + if got := readEnv(t, settings)[envProxy]; got != "http://corp:3128" { + t.Errorf("value was changed to %q despite the refusal", got) + } +} + +// TestClaudeCodeDisable_RemovesOnlyOurKeys pairs with the enable test: the off +// switch must not take the user's own settings with it. +func TestClaudeCodeDisable_RemovesOnlyOurKeys(t *testing.T) { + settings, cfg := fixture(t, settingsWithSecret) + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("enable: %s", errb.String()) + } + if code := claudeCodeDisable(settings, true, &out, &errb); code != 0 { + t.Fatalf("disable: %s", errb.String()) + } + env := readEnv(t, settings) + for _, k := range managedKeys { + if _, ok := env[k]; ok { + t.Errorf("%s survived disable", k) + } + } + if env["ANTHROPIC_AUTH_TOKEN"] != "sk-do-not-touch" { + t.Error("disable removed the user's token") + } + if env["ANTHROPIC_BASE_URL"] == "" { + t.Error("disable removed the user's base URL") + } +} + +// TestClaudeCodeEnable_Idempotent: install.sh may run this on every invocation. +func TestClaudeCodeEnable_Idempotent(t *testing.T) { + settings, cfg := fixture(t, settingsWithSecret) + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("first: %s", errb.String()) + } + first, _ := os.ReadFile(settings) + out.Reset() + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("second: %s", errb.String()) + } + second, _ := os.ReadFile(settings) + if string(first) != string(second) { + t.Error("second run changed the file") + } + if !strings.Contains(out.String(), "Already enabled") { + t.Errorf("second run did not report it was already done: %q", out.String()) + } +} + +// TestClaudeCodeEnable_MissingSettingsFileIsCreated: a fresh machine may have no +// settings.json at all. +func TestClaudeCodeEnable_MissingSettingsFileIsCreated(t *testing.T) { + settings, cfg := fixture(t, "") + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("exit %d: %s", code, errb.String()) + } + if got := readEnv(t, settings)[envNoTelem]; got != "1" { + t.Errorf("%s = %q", envNoTelem, got) + } +} + +// TestClaudeCodeEnable_RejectsBrokenJSON: overwriting a file we cannot parse +// would destroy settings we never read. +func TestClaudeCodeEnable_RejectsBrokenJSON(t *testing.T) { + settings, cfg := fixture(t, `{"env": {`) + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code == 0 { + t.Fatal("accepted unparseable settings") + } + if !strings.Contains(errb.String(), "not valid JSON") { + t.Errorf("error did not name the problem: %q", errb.String()) + } +} + +// TestConfirmFrom_OnlyExplicitYesApplies: the file holds API tokens, so anything +// ambiguous — including EOF — must decline. +func TestConfirmFrom_OnlyExplicitYesApplies(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"y\n", true}, {"Y\n", true}, {"yes\n", true}, {"YES\n", true}, + {"n\n", false}, {"no\n", false}, {"\n", false}, {"", false}, + {"maybe\n", false}, {"ya\n", false}, + } { + var out bytes.Buffer + if got := confirmFrom(strings.NewReader(tc.in), &out); got != tc.want { + t.Errorf("confirmFrom(%q) = %v, want %v", tc.in, got, tc.want) + } + if !strings.Contains(out.String(), "[y/N]") { + t.Errorf("prompt did not show the default: %q", out.String()) + } + } +} diff --git a/authbridge/cmd/abctl/main.go b/authbridge/cmd/abctl/main.go index 8565e1e83..6d883bbd6 100644 --- a/authbridge/cmd/abctl/main.go +++ b/authbridge/cmd/abctl/main.go @@ -33,8 +33,10 @@ func main() { switch os.Args[1] { case "tools": os.Exit(runTools(os.Args[2:], os.Stdout, os.Stderr)) + case "claude-code": + os.Exit(runClaudeCode(os.Args[2:], os.Stdout, os.Stderr)) default: - fmt.Fprintf(os.Stderr, "abctl: unknown subcommand %q (known: tools)\n", os.Args[1]) + fmt.Fprintf(os.Stderr, "abctl: unknown subcommand %q (known: tools, claude-code)\n", os.Args[1]) os.Exit(2) } } diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index a04eeaf39..2fc4a7f16 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -7,7 +7,8 @@ strips the definitions your agent never calls. Nothing here installs anything: the proxy comes from the [README quick start](../../README.md#quick-start-local-no-kubernetes), which sets -it up to observe traffic without changing it. Pruning is a separate, opt-in step, +it up to observe traffic without changing it, and points Claude Code at it so +`claude` needs no environment variables. Pruning is a separate, opt-in step, because it rewrites requests and because the list it proposes is read from Claude Code's own transcripts — of no use if you drive a different agent. @@ -124,9 +125,11 @@ client-side settings (`--allowedTools`, disabling unused MCP servers). ## If it isn't working - **Metrics pane empty, every event shows `tunnel`** — Claude Code is not trusting - the bridge CA. `NODE_EXTRA_CA_CERTS` must point at `~/.cortex/ca/ca.crt`, - expanded to an absolute path. The proxy also warns about this in - `~/.cortex/proxy.log` after a few requests, naming the path it expects. + the bridge CA. Check what it is actually using with `abctl claude-code status`; + `NODE_EXTRA_CA_CERTS` must be the absolute path to `~/.cortex/ca/ca.crt`. The + proxy also warns about this in `~/.cortex/proxy.log` after a few requests, + naming the path it expects. `abctl claude-code enable` sets all three variables + from your running config, which is the reliable way to get them right. - **`tool-prune` shows `skip`, never `modify`** — expected until you opt in: the remove list ships empty. Run the scan above. If it refuses, you have no transcript history for it to reason from yet. diff --git a/authbridge/install.sh b/authbridge/install.sh index 98260593f..611841622 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -18,6 +18,9 @@ # curl -fsSL ...install.sh | sh -s -- --install-only): # # --install-only install the binaries and stop +# --claude-code after starting, offer to write the three env vars Claude Code +# needs into ~/.claude/settings.json, so it runs as plain +# `claude`. Prompts before changing anything. # # There is deliberately only one config. It carries the parsers AND tool-prune, # and the proxy preserves edits to it, so a second "cost-optimised" config had @@ -71,6 +74,8 @@ Traffic is decrypted and parsed for viewing; nothing is rewritten. Options: --install-only install the binaries and stop + --claude-code after starting, offer to configure Claude Code to use it, so + it runs as plain `claude` with no environment variables --local the default, spelled out -h, --help this text @@ -87,9 +92,11 @@ USAGE # --- mode selection --- MODE=local +WIRE_CLAUDE_CODE="" for arg in "$@"; do case "$arg" in --install-only) MODE=install-only ;; + --claude-code) WIRE_CLAUDE_CODE=1 ;; # --local is the default; accepted so writing it out explicitly works, and # so it mirrors the proxy flag of the same name. --local) MODE=local ;; @@ -97,7 +104,7 @@ for arg in "$@"; do usage exit 0 ;; - *) die "unknown option: $arg (try --install-only, --local, or no argument)" ;; + *) die "unknown option: $arg (try --claude-code, --install-only, --local, or no argument)" ;; esac done # Env form kept working; the flag wins if both are given. @@ -354,12 +361,36 @@ info "" # who knows whether they want it. local_cfg="${CORTEX_DIR}/config.yaml" if [ -f "${local_cfg}" ]; then + info " Skip the env vars below — configure Claude Code once, then just run \`claude\`:" + info " ${abctl_cmd} claude-code enable" + info "" info " Using Claude Code? Cut its token cost by pruning tools you never call:" info " ${abctl_cmd} tools scan --write ${local_cfg}" info " (proposes tools absent from your last 30 days of transcripts;" info " add --all to spare anything you have ever called; hot-reloaded)" info "" fi +# --claude-code: hand off to abctl, which owns the JSON merge (a shell-side edit +# of a file holding API tokens is not worth attempting) and prompts on /dev/tty — +# stdin here is the script itself when piped, so it cannot be read for an answer. +if [ -n "${WIRE_CLAUDE_CODE:-}" ]; then + info "" + if "${BIN_DIR}/abctl" claude-code enable; then + info "" + info " Run Claude Code: claude" + info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" + info " Undo: ${abctl_cmd} claude-code disable" + info " Stop Cortex: kill \$(cat ${pidfile})" + info "" + exit 0 + fi + # Declined, or no terminal to ask on. Not a failure — fall through to the + # manual instructions below. + info "" + info " Claude Code left unchanged. To do it later:" + info " ${abctl_cmd} claude-code enable" + info "" +fi info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" info " Send traffic through it (e.g. Claude Code):" info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" From d19126dfede2772e44c132ed59e0588002464c70 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 15:56:18 -0400 Subject: [PATCH 12/19] docs: Name Claude Code in the quickstart, and cut it in half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quickstart said "an AI agent" while every step was Claude Code — install.sh --claude-code, `claude`, and a viewer showing its calls. Now the heading says so: "Quick start — Claude Code on your laptop". One line at the end covers other agents, which is the honest weight: the proxy is generic, the wiring is not. Also trimmed, 297 words to 151. What went was explanation nobody needs at step 1: how --claude-code edits settings.json and what it leaves alone (the command prints that before it acts, and `--help` has the rest), why settings beat a shell export (a "why" for the curious, not a step), and the --install-only aside. Cost page opening got the same treatment, 1051 words to 892. The window-choosing paragraph was five sentences to say "wider is safer". Fixes an anchor the rename would have broken: session-budget's [qs] link pointed at #quick-start-local-no-kubernetes. Both remaining links now resolve against a real heading, checked by computing the GitHub slug. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 47 +++++++------------ .../session-budget/hitl-with-claude-code.md | 2 +- authbridge/docs/laptop-token-savings.md | 35 +++++--------- 3 files changed, 31 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 90ca588ea..a0672a5a0 100644 --- a/README.md +++ b/README.md @@ -10,54 +10,41 @@ Cortex delivers easy-to-use platform services to agentic workloads. It runs in a It ships as a single binary; the identity and access layer is **AuthBridge**, and the code lives under [`authbridge/`](./authbridge/). -## Quick start (local, no Kubernetes) +## Quick start — Claude Code on your laptop -Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — -decrypted and parsed live on your laptop. +See what Claude Code sends: model calls, tool calls, and agent-to-agent traffic, +decrypted and parsed live. No Kubernetes. macOS or Linux, amd64 or arm64. -1. **Install and start Cortex** (macOS/Linux). Downloads two small binaries, - writes one config under `~/.cortex`, and starts the proxy in the background: +1. **Install, and point Claude Code at it** (asks first, changes nothing else): ```sh curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh \ | sh -s -- --claude-code ``` - `--claude-code` then asks whether to point Claude Code at it, by adding three - variables to the `env` block of `~/.claude/settings.json`. It shows them first - and changes nothing else. Say no and everything still works — you just pass - the variables yourself. (`--install-only` installs the binaries and stops.) - - Traffic is decrypted and parsed for viewing; nothing is rewritten. - -2. **Open the live viewer** in another terminal: +2. **Open the viewer** in another terminal: ```sh abctl --endpoint http://localhost:47601 ``` -3. **Run Claude Code normally:** +3. **Run Claude Code:** ```sh claude ``` - Its calls stream into `abctl`, decrypted and parsed. Stop the proxy with - `kill $(cat ~/.cortex/proxy.pid)`, and undo the settings change with - `abctl claude-code disable`. - - Settings rather than a shell export on purpose: Claude Code's supervisor is one - process shared by every terminal and inherits whichever shell started it first, - so an exported variable reaches background agents only by luck. If you would - rather not have it edit the file, `abctl claude-code status` shows what it would - set and you can pass those three variables to `claude` yourself. - -**Using Claude Code?** One more command turns this into a cost saving — Cortex -strips the tool definitions your agent never calls out of every request, worth -**4–20% of the prompt billed per turn, median 6%** over 99 requests of one real -session: **[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**. -It is opt-in because it rewrites requests, and because the tool list it proposes -is read from Claude Code's own transcripts. +Its calls stream into `abctl`. Cortex only reads them — nothing is rewritten. + +Stop it with `kill $(cat ~/.cortex/proxy.pid)`. Undo step 1 with +`abctl claude-code disable`. + +**Cut token cost too:** Cortex can strip the tool definitions your agent never +calls, worth **4–20% of the prompt per turn, median 6%** — +**[one more command](./authbridge/docs/laptop-token-savings.md)**. + +Any agent works, not just Claude Code — point it at the proxy on +`localhost:47600` and trust `~/.cortex/ca/ca.crt`. ## Running on Kubernetes diff --git a/authbridge/demos/session-budget/hitl-with-claude-code.md b/authbridge/demos/session-budget/hitl-with-claude-code.md index d3f159447..c89b79353 100644 --- a/authbridge/demos/session-budget/hitl-with-claude-code.md +++ b/authbridge/demos/session-budget/hitl-with-claude-code.md @@ -11,7 +11,7 @@ The [README quickstart][qs] `install.sh` binary isn't compiled with `include_plugin_sessionbudget`, so this doc builds `authbridge-proxy` from source with the tag on. -[qs]: ../../../README.md#quick-start-local-no-kubernetes +[qs]: ../../../README.md#quick-start--claude-code-on-your-laptop ## Prerequisites diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index 2fc4a7f16..56e5758f7 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -1,16 +1,12 @@ # Cut Claude Code token cost on your laptop -Claude Code sends the full tool manifest on every turn — tens of thousands of -tokens of JSON schema, billed each time — and the manifest is built by the client, -so a proxy is the only place to trim it without changing every client. Cortex -strips the definitions your agent never calls. - -Nothing here installs anything: the proxy comes from the -[README quick start](../../README.md#quick-start-local-no-kubernetes), which sets -it up to observe traffic without changing it, and points Claude Code at it so -`claude` needs no environment variables. Pruning is a separate, opt-in step, -because it rewrites requests and because the list it proposes is read from Claude -Code's own transcripts — of no use if you drive a different agent. +Claude Code sends its whole tool manifest on every turn — tens of thousands of +tokens of JSON schema, billed each time. Cortex strips the definitions you never +call. + +Needs the proxy from the +[quick start](../../README.md#quick-start--claude-code-on-your-laptop) first. This +step is opt-in because it rewrites requests. ## Turn it on @@ -18,19 +14,14 @@ Code's own transcripts — of no use if you drive a different agent. abctl tools scan --write ~/.cortex/config.yaml ``` -That reads your `~/.claude/projects` transcripts, proposes the built-in tools you -have not called in 30 days, and writes them to `tool-prune`'s `remove:` list. The -proxy hot-reloads, so it takes effect immediately — no restart. +It proposes the tools you have not called in 30 days, prints them, and writes them +to `tool-prune`'s `remove:` list. Hot-reloaded — no restart. -**Choosing the window.** `--days N` sets it; `--all` ignores it and counts every -call in every transcript. Widening is the *cautious* direction: a longer window can -only find more tools in use, so it proposes fewer for removal. Reach for `--all` if -you have been using Claude Code for months and want nothing pruned that you have -ever touched; keep the 30-day default to also drop tools you used once and moved on -from. (`--days 0` is rejected rather than read as "everything" — a zero-width -window finds nothing used, which would propose removing every tool it knows.) +`--days N` changes the window, `--all` ignores it. **Wider is safer:** a longer +window finds more tools in use, so it prunes less. Use `--all` to spare anything +you have ever called. -It prints what it chose before writing. Two guards on what it will propose: +Two guards on what it proposes: - It only ever proposes tools it **recognises**, and never one it has **seen you call** — including tools implied by ones you called, so `BashOutput` survives if From 82253774407f57a9213839eca2af95e3c20bd792 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 16:09:02 -0400 Subject: [PATCH 13/19] feat: Plain `abctl` finds the local Cortex; fix [l] pointing at the wrong port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `abctl` with no flags demanded kubectl and opened a cluster picker, so the laptop quickstart had to type --endpoint http://localhost:47601 every time. It now connects to the Cortex on this machine when one is running. The address is read from ~/.cortex/config.yaml rather than hardcoded, so it follows a port someone changed. Only used when the session API actually answers: a stale config from an install that is no longer running must not steer someone away from the picker when they meant to work against a cluster. The [l] "connect to localhost" key had the same bug from the other end: it was hardcoded to :9094, the in-cluster default, so on every laptop it connected to the wrong port. It now uses the same resolved address, and the footer shows which one — one accessor so the key, the footer and the empty- state hint cannot disagree. With no local config it still says 9094, so in-cluster behaviour is unchanged. Testing this turned up a real bug in guidance I wrote earlier. The built-in config did not pin transparent_proxy_addr, on the reasoning that --local skips that listener. True — but the troubleshooting text tells people to start that same file with `authbridge-proxy --config ~/.cortex/config.yaml`, and --config does not skip it, so it bound ":8082" on every interface and died on a clash with anything already there. Reproduced it, pinned the address, and the config is now safe however it is launched. That is the second time the "it is skipped in this mode" shortcut has cost something. `--endpoint` still works and still wins; only the default changed. Tests cover the port being read from config (including ":9094" and "0.0.0.0:9094" needing a dialable host), no config meaning no local endpoint, and the liveness probe rejecting a closed port and a 5xx. The TUI itself needs a terminal this session does not have, so the [l] wiring is covered by the existing pane tests rather than an interactive run. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 2 +- authbridge/cmd/abctl/local_endpoint.go | 80 +++++++++++++++ authbridge/cmd/abctl/local_endpoint_test.go | 108 ++++++++++++++++++++ authbridge/cmd/abctl/main.go | 28 ++++- authbridge/cmd/abctl/tui/app.go | 42 ++++++-- authbridge/cmd/abctl/tui/help_overlay.go | 2 +- authbridge/cmd/abctl/tui/keys.go | 5 +- authbridge/cmd/authbridge-proxy/local.go | 10 +- authbridge/docs/laptop-token-savings.md | 6 +- authbridge/install.sh | 4 +- 10 files changed, 266 insertions(+), 21 deletions(-) create mode 100644 authbridge/cmd/abctl/local_endpoint.go create mode 100644 authbridge/cmd/abctl/local_endpoint_test.go diff --git a/README.md b/README.md index a0672a5a0..2b36f2573 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ decrypted and parsed live. No Kubernetes. macOS or Linux, amd64 or arm64. 2. **Open the viewer** in another terminal: ```sh - abctl --endpoint http://localhost:47601 + abctl ``` 3. **Run Claude Code:** diff --git a/authbridge/cmd/abctl/local_endpoint.go b/authbridge/cmd/abctl/local_endpoint.go new file mode 100644 index 000000000..6fe533526 --- /dev/null +++ b/authbridge/cmd/abctl/local_endpoint.go @@ -0,0 +1,80 @@ +package main + +import ( + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/rossoctl/cortex/authbridge/authlib/config" +) + +// localProbeTimeout bounds the "is a local Cortex actually up?" check. It runs +// before the TUI starts, on the happy path of every bare `abctl`, so it has to be +// short enough not to feel like a hang. +const localProbeTimeout = 400 * time.Millisecond + +// localSessionEndpoint returns the session API URL of the Cortex installed on +// this machine, or "" if there isn't one. +// +// Read from the config rather than hardcoded so it follows a port the operator +// changed. The in-cluster default is 9094 and a local install uses 47601, which +// is exactly the kind of difference a constant gets wrong. +func localSessionEndpoint() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + cfg, err := config.Load(filepath.Join(home, ".cortex", "config.yaml")) + if err != nil { + return "" + } + addr := cfg.Listener.SessionAPIAddr + if addr == "" { + return "" + } + host, port, ok := strings.Cut(addr, ":") + if !ok || port == "" { + return "" + } + // A bind address is not a dial address: ":9094" and "0.0.0.0:9094" both need + // a host a client can connect to. + if host == "" || host == "0.0.0.0" || host == "::" { + host = "localhost" + } + return "http://" + host + ":" + port +} + +// localSessionAPIUp reports whether something is listening and answering there. +// +// Checked before choosing it over the cluster picker: a stale ~/.cortex/config.yaml +// left by an install that is no longer running must not hijack `abctl` away from +// the picker for someone working against a cluster. +func localSessionAPIUp(endpoint string) bool { + if endpoint == "" { + return false + } + c := &http.Client{Timeout: localProbeTimeout} + resp, err := c.Get(endpoint + "/v1/sessions") //nolint:noctx // bounded by Timeout + if err != nil { + return false + } + defer resp.Body.Close() + // Any HTTP answer proves a session API is there; the status itself is the + // TUI's business. + return resp.StatusCode < 500 +} + +// dialable is a cheap pre-check used only to keep the error message useful when +// the config exists but nothing is running. +func dialable(endpoint string) bool { + hostport := strings.TrimPrefix(strings.TrimPrefix(endpoint, "http://"), "https://") + conn, err := net.DialTimeout("tcp", hostport, localProbeTimeout) + if err != nil { + return false + } + _ = conn.Close() + return true +} diff --git a/authbridge/cmd/abctl/local_endpoint_test.go b/authbridge/cmd/abctl/local_endpoint_test.go new file mode 100644 index 000000000..45b5114c3 --- /dev/null +++ b/authbridge/cmd/abctl/local_endpoint_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +const endpointCfg = `mode: proxy-sidecar +listener: + roles: [forward] + forward_proxy_addr: 127.0.0.1:47600 + session_api_addr: SESSIONADDR + health_addr: 127.0.0.1:47604 +pipeline: + outbound: + plugins: + - name: inference-parser +` + +// withCortexConfig points $HOME at a temp dir holding a Cortex config whose +// session_api_addr is addr. An empty addr writes no config at all. +func withCortexConfig(t *testing.T, addr string) { + t.Helper() + dir := t.TempDir() + if addr != "" { + if err := os.MkdirAll(filepath.Join(dir, ".cortex"), 0o700); err != nil { + t.Fatal(err) + } + body := strings.Replace(endpointCfg, "SESSIONADDR", addr, 1) + if err := os.WriteFile(filepath.Join(dir, ".cortex", "config.yaml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("HOME", dir) +} + +// TestLocalSessionEndpoint_ReadsTheConfiguredPort: the in-cluster default is 9094 +// and a local install uses 47601, so a hardcoded constant is wrong for one of +// them. It must follow whatever the operator actually configured. +func TestLocalSessionEndpoint_ReadsTheConfiguredPort(t *testing.T) { + for _, tc := range []struct { + addr string + want string + }{ + {"127.0.0.1:47601", "http://127.0.0.1:47601"}, + {"127.0.0.1:19999", "http://127.0.0.1:19999"}, + // A bind address is not a dial address. + {":9094", "http://localhost:9094"}, + {"0.0.0.0:9094", "http://localhost:9094"}, + } { + withCortexConfig(t, tc.addr) + if got := localSessionEndpoint(); got != tc.want { + t.Errorf("session_api_addr %q -> %q, want %q", tc.addr, got, tc.want) + } + } +} + +// TestLocalSessionEndpoint_NoConfigMeansNoLocalEndpoint: a machine that never +// installed Cortex must fall through to the cluster picker, not to a guess. +func TestLocalSessionEndpoint_NoConfigMeansNoLocalEndpoint(t *testing.T) { + withCortexConfig(t, "") + if got := localSessionEndpoint(); got != "" { + t.Errorf("got %q, want empty with no config", got) + } +} + +// TestLocalSessionAPIUp_OnlyWhenSomethingAnswers is what keeps a stale config +// from hijacking abctl: an install that is no longer running must not steer +// someone away from the cluster picker. +func TestLocalSessionAPIUp_OnlyWhenSomethingAnswers(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/sessions" { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"sessions":[]}`)) + })) + defer srv.Close() + + if !localSessionAPIUp(srv.URL) { + t.Error("a live session API was reported down") + } + if localSessionAPIUp("") { + t.Error("empty endpoint reported up") + } + // A port with nothing on it: the server above, closed. + dead := srv.URL + srv.Close() + if localSessionAPIUp(dead) { + t.Error("a closed port was reported up") + } +} + +// TestLocalSessionAPIUp_RejectsAServerError: a 5xx means something is listening +// but not serving the API — likelier a wrong port than a working Cortex. +func TestLocalSessionAPIUp_RejectsAServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + if localSessionAPIUp(srv.URL) { + t.Error("a 500 was accepted as a live session API") + } +} diff --git a/authbridge/cmd/abctl/main.go b/authbridge/cmd/abctl/main.go index 6d883bbd6..cdcd41b95 100644 --- a/authbridge/cmd/abctl/main.go +++ b/authbridge/cmd/abctl/main.go @@ -42,7 +42,7 @@ func main() { } endpoint := flag.String("endpoint", "", - "AuthBridge session API URL (e.g. http://localhost:9094). When omitted, abctl opens a Namespaces → Pods picker.") + "AuthBridge session API URL (e.g. http://localhost:9094). When omitted, abctl connects to the Cortex on this machine if one is running, otherwise it opens a Namespaces → Pods picker.") showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() @@ -57,11 +57,33 @@ func main() { // $TMPDIR bounded for users who edit often. _ = edit.SweepStaleTempfiles() + // With no --endpoint, prefer a Cortex running on this machine. Before this, + // a bare `abctl` on a laptop demanded kubectl and opened a cluster picker, + // so the local install — the whole quickstart — needed + // `--endpoint http://localhost:47601` typed every time. + // + // Only when it is actually answering: a stale config from an install that is + // no longer running must not hijack abctl away from the picker for someone + // working against a cluster. + local := localSessionEndpoint() + if *endpoint == "" && localSessionAPIUp(local) { + *endpoint = local + } + // Friendly check: if picker mode and no kubectl, fail fast with a // clear message instead of a stack trace later. if *endpoint == "" { if _, err := exec.LookPath("kubectl"); err != nil { - fmt.Fprintln(os.Stderr, "abctl: kubectl not found on PATH; install it or pass --endpoint http://...") + msg := "abctl: kubectl not found on PATH; install it or pass --endpoint http://..." + // Name the more likely cause first when there is a local install that + // simply is not running — "install kubectl" is unhelpful advice to + // someone who has never wanted a cluster. + if local != "" && !dialable(local) { + msg = "abctl: nothing is listening on " + local + " (from ~/.cortex/config.yaml).\n" + + " Start it: authbridge-proxy --config ~/.cortex/config.yaml &\n" + + " Or pass --endpoint http://... , or install kubectl to browse a cluster." + } + fmt.Fprintln(os.Stderr, msg) os.Exit(1) } } @@ -75,7 +97,7 @@ func main() { cancel() }() - opts := tui.RunOptions{Endpoint: *endpoint} + opts := tui.RunOptions{Endpoint: *endpoint, LocalEndpoint: local} if *endpoint == "" { opts.Lister = cluster.NewLister() opts.PortForwarder = cluster.NewPortForwarder() diff --git a/authbridge/cmd/abctl/tui/app.go b/authbridge/cmd/abctl/tui/app.go index d133031cc..9cedbef74 100644 --- a/authbridge/cmd/abctl/tui/app.go +++ b/authbridge/cmd/abctl/tui/app.go @@ -71,12 +71,26 @@ const maxEventsPerSession = 1000 // confirmation) stays in the footer. const flashDuration = 3 * time.Second -// localEndpoint is the address `[l]` from the Namespaces pane connects -// to: the session API's default port on the local host. Useful when the -// operator already has their own `kubectl port-forward` running, when -// abctl runs inside the mesh, or when the cluster's pod list isn't -// visible to their kubeconfig but a tunnel is. -const localEndpoint = "http://localhost:9094" +// defaultLocalEndpoint is where `[l]` connects when nothing better is known: +// the session API's in-cluster default port, reached through an existing +// `kubectl port-forward`. Useful when abctl runs inside the mesh, or when the +// cluster's pod list isn't visible to their kubeconfig but a tunnel is. +// +// A local install listens somewhere else entirely (47601 by default), so +// RunOptions.LocalEndpoint overrides this with the address read from the +// machine's own Cortex config. Hardcoding 9094 sent `[l]` to the wrong port on +// every laptop. +const defaultLocalEndpoint = "http://localhost:9094" + +// localEndpointOr returns the resolved local endpoint, falling back to the +// in-cluster default. One accessor so `[l]`, the footer and the help overlay +// cannot disagree about where the key goes. +func (m *model) localEndpointOr() string { + if m.localEndpoint != "" { + return m.localEndpoint + } + return defaultLocalEndpoint +} // localProbeTimeout bounds the pre-connect reachability check for `[l]`. // Without it, a dead localEndpoint would leave the operator in an empty @@ -178,6 +192,9 @@ func withGen(gen int, c tea.Cmd) tea.Cmd { type model struct { endpoint string client *apiclient.Client + // localEndpoint is what `[l]` connects to and what the footer and help + // overlay name. Resolved from the local Cortex config when there is one. + localEndpoint string ctx context.Context cancel context.CancelFunc @@ -1041,7 +1058,7 @@ func (m *model) paneView() string { if m.namespaces != nil && len(m.namespaces) == 0 && m.pickerErr == "" { body = styleHint.Render( "No AuthBridge agents found in this cluster.\n" + - "Press [l] to connect to " + localEndpoint + " (an existing\n" + + "Press [l] to connect to " + m.localEndpointOr() + " (an existing\n" + "port-forward), or use `abctl --endpoint http://...`.") } else { body = m.namespacesTbl.View() @@ -1200,6 +1217,9 @@ type RunOptions struct { Endpoint string Lister cluster.Lister PortForwarder cluster.PortForwarder + // LocalEndpoint overrides where `[l]` connects. Empty means + // defaultLocalEndpoint. + LocalEndpoint string } // Run starts the bubbletea program. See RunOptions for mode selection. @@ -1214,6 +1234,7 @@ func Run(ctx context.Context, opts RunOptions) error { } m = newPickerModel(ctx, opts.Lister, opts.PortForwarder) } + m.localEndpoint = opts.LocalEndpoint defer func() { if m.activePF != nil { _ = m.activePF.Close() @@ -1238,3 +1259,10 @@ func openEditorCmd(gen int, path string) tea.Cmd { return editorExitedMsg{gen: gen, err: err} }) } + +// shortHost renders an endpoint for a cramped footer: "http://localhost:9094" +// becomes "localhost:9094". Display only — never used to dial. +func shortHost(endpoint string) string { + s := strings.TrimPrefix(endpoint, "http://") + return strings.TrimPrefix(s, "https://") +} diff --git a/authbridge/cmd/abctl/tui/help_overlay.go b/authbridge/cmd/abctl/tui/help_overlay.go index 4b4900bc5..611fd88e5 100644 --- a/authbridge/cmd/abctl/tui/help_overlay.go +++ b/authbridge/cmd/abctl/tui/help_overlay.go @@ -48,7 +48,7 @@ var paneKeys = map[paneID]keyGroup{ bindings: []keyBinding{ {"↑↓ / jk", "navigate"}, {"↵", "open namespace"}, - {"l", "connect to localhost:9094"}, + {"l", "connect to the local session API"}, {"r", "reload agent list"}, {"q · esc", "quit"}, }, diff --git a/authbridge/cmd/abctl/tui/keys.go b/authbridge/cmd/abctl/tui/keys.go index f903a8c4a..8330ac2ce 100644 --- a/authbridge/cmd/abctl/tui/keys.go +++ b/authbridge/cmd/abctl/tui/keys.go @@ -88,7 +88,7 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd { } m.pickerErr = "" m.loading = true - return connectLocalCmd(m.ctx, localEndpoint) + return connectLocalCmd(m.ctx, m.localEndpointOr()) case "r": if m.loading { return nil @@ -502,7 +502,8 @@ func (m *model) helpView() string { } switch m.pane { case paneNamespaces: - return "[↑↓/jk] nav [↵] open [l] localhost:9094 [r] reload [?] keys [q] quit" + return "[↑↓/jk] nav [↵] open [l] " + shortHost(m.localEndpointOr()) + + " [r] reload [?] keys [q] quit" case panePods: return "[↑↓/jk] nav [↵] connect [Esc] back [r] reload [?] keys [q] quit" case paneSessions: diff --git a/authbridge/cmd/authbridge-proxy/local.go b/authbridge/cmd/authbridge-proxy/local.go index a2663a838..9b9565747 100644 --- a/authbridge/cmd/authbridge-proxy/local.go +++ b/authbridge/cmd/authbridge-proxy/local.go @@ -66,8 +66,9 @@ func defaultCortexDir() (string, error) { // usual 8081/909x ports collide with common dev tools. The preset only fills // empty addresses, so these explicit values win — keep them in sync with the // ports the installer probes and prints (authbridge/install.sh). The -// enforce-redirect transparent listener isn't used here (no iptables) and -// main.go skips starting it under --local, so it needs no address. +// enforce-redirect transparent listener isn't used here (no iptables); --local +// skips it, and it is pinned anyway so that starting this same file with +// --config cannot bind it on every interface. // // The YAML body is flush-left on purpose — a raw string literal preserves // leading whitespace, so indenting these lines in source would corrupt the YAML. @@ -83,6 +84,11 @@ listener: # Without this the preset defaults health to ":9091" — every interface, and a # port common enough to collide with an unrelated service. health_addr: 127.0.0.1:47604 + # --local skips the enforce-redirect transparent listener, but --config does + # not, and the troubleshooting docs tell people to start this same file with + # --config. Unpinned it would then bind ":8082" on every interface. Pinning it + # makes the config safe however it is launched. + transparent_proxy_addr: 127.0.0.1:47603 stats: address: 127.0.0.1:47602 tls_bridge: diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index 56e5758f7..e85ac70e5 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -48,9 +48,9 @@ session. Two things move it, and neither is a defect: A single early turn can read ~24%, which is why a figure quoted from one request is not the number to plan with. -Watch it live in `abctl` (`--endpoint http://localhost:47601`): the plugin pane's -`tool-prune` → `Metrics`, and the per-request saving in the events timeline's -`TOKENS / SAVED` column. +Watch it live: run `abctl` (it finds the local proxy on its own), then the plugin +pane's `tool-prune` → `Metrics`, and the per-request saving in the events +timeline's `TOKENS / SAVED` column. ## Reading the dollar figure diff --git a/authbridge/install.sh b/authbridge/install.sh index 611841622..a8c79fbce 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -378,7 +378,7 @@ if [ -n "${WIRE_CLAUDE_CODE:-}" ]; then if "${BIN_DIR}/abctl" claude-code enable; then info "" info " Run Claude Code: claude" - info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" + info " Watch traffic: ${abctl_cmd}" info " Undo: ${abctl_cmd} claude-code disable" info " Stop Cortex: kill \$(cat ${pidfile})" info "" @@ -391,7 +391,7 @@ if [ -n "${WIRE_CLAUDE_CODE:-}" ]; then info " ${abctl_cmd} claude-code enable" info "" fi -info " Watch traffic: ${abctl_cmd} --endpoint http://localhost:${DEMO_SESSION_PORT}" +info " Watch traffic: ${abctl_cmd}" info " Send traffic through it (e.g. Claude Code):" info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" info " NODE_EXTRA_CA_CERTS=${ca_dir}/ca.crt \\" From 1ab308fdef431e7a49c845b44d07d3a1f24a5d25 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 16:17:31 -0400 Subject: [PATCH 14/19] =?UTF-8?q?fix:=20Address=20review=20round=20two=20?= =?UTF-8?q?=E2=80=94=20eight=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All eight verified; all eight real. **settings.json lost non-string env values.** envBlock read the block into map[string]string and the result was assigned back over the whole block, so `"env": {"DEBUG": true}` vanished — directly contradicting this command's own help text. The fixture was all strings, so nothing caught it. Callers now mutate the raw map[string]any in place; a test covers bool, number, null, array and object entries through both enable and disable, and I confirmed it fails against the old code before fixing it. **The scan's refusal described a window that had not run.** With --all it said "no tool calls at all in the last 30 day(s)" and advised widening with "--days or --all" — a combination it rejects with exit 2. It formatted *days while the scan used window. Reachable in exactly the fresh-install case the guard exists for; same class as 655b71d, which is twice now that a message has reported a value the code did not use. **enable wrote NODE_EXTRA_CA_CERTS without checking the file exists.** That is the silent-trust-anchor failure this PR keeps naming: requests keep working, every one tunnels opaquely, nothing looks wrong. Enabling before the first start is legitimate, so it still writes — but now says the file is not there yet and what that will look like. **The backup was overwritten on every write**, so one enable/disable round trip replaced the pristine pre-Cortex settings.json with our own output. Its whole value is being the version the user wrote. Written once now, never refreshed. **install.sh pre-created ca_dir** at the shell's umask before starting the proxy, so tlsbridge's new 0700 never applied — MkdirAll does not tighten. The same trap local.go needed an explicit Chmod for, one directory down. Fixed by not creating it: the proxy makes it correctly. **LITE_BUILD_TAGS had no format contract or guard.** An empty-but-present file yielded TAGS="" and a silently full "lite" binary; a comment line would have been concatenated into the tag list by `tr -d`. Both fail open, since Go accepts unknown -tags silently. The file now documents its format and carries comments, every one of the four readers strips them and refuses an empty result, and I checked the tag set still produces a 22.8MB lite against a 47.5MB full. **--all had no test** — fs.Bool("All", …) would have compiled and passed. Covered now, along with the exit-2 on --all with --days, and the refusal naming the right scope in both modes. **The zero-evidence guard was binary**: one observed call cleared it and still proposed removing nearly every known tool. Raising it to an arbitrary N would block legitimate light users, so it warns instead, naming the count and pointing at --all. Thin evidence is a property of the input, not an error. The PR description is stale in the ways listed and is being rewritten separately. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .github/workflows/build.yaml | 8 +- .github/workflows/ci.yaml | 8 +- .github/workflows/release-binaries.yaml | 5 +- authbridge/cmd/abctl/cmd_claudecode.go | 67 +++++++-- authbridge/cmd/abctl/cmd_claudecode_test.go | 128 +++++++++++++++++ authbridge/cmd/abctl/cmd_tools.go | 36 ++++- authbridge/cmd/abctl/cmd_tools_test.go | 133 ++++++++++++++++++ .../cmd/authbridge-proxy/LITE_BUILD_TAGS | 11 +- authbridge/install.sh | 4 +- local-build-and-test.sh | 7 +- 10 files changed, 383 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1cb18596d..3354cfdff 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -134,7 +134,13 @@ jobs: id: buildargs run: | if [[ "${{ matrix.image_config.name }}" == "authbridge-lite" ]]; then - TAGS="$(tr -d '[:space:]' < authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS)" + TAGS="$(grep -v '^[[:space:]]*#' authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS | tr -d '[:space:]')" + # exclude_plugin_* fails open: an empty list would push a full binary + # to the authbridge-lite tag. + if [[ -z "${TAGS}" ]]; then + echo "LITE_BUILD_TAGS produced no tags" >&2 + exit 1 + fi echo "args=GO_BUILD_TAGS=${TAGS}" >> "$GITHUB_OUTPUT" elif [[ "${{ matrix.image_config.name }}" == "authbridge-cpex" ]]; then VERSION="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION)" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3e797b6b9..71d79a967 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -124,7 +124,13 @@ jobs: # and image builds. It silently can: a missing exclude_plugin_* tag # compiles the plugin IN and only makes the binary bigger, so a stale # copy produces no error anywhere. working-directory is this module. - TAGS="$(tr -d '[:space:]' < LITE_BUILD_TAGS)" + TAGS="$(grep -v '^[[:space:]]*#' LITE_BUILD_TAGS | tr -d '[:space:]')" + # Fail loudly on an empty list: exclude_plugin_* fails OPEN, so an empty + # or malformed file would silently build a full binary and call it lite. + if [ -z "$TAGS" ]; then + echo "LITE_BUILD_TAGS produced no tags" >&2 + exit 1 + fi go build -v -tags "$TAGS" ./... go test -v -race -cover -tags "$TAGS" ./... diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml index 6a7e3114b..8a471e705 100644 --- a/.github/workflows/release-binaries.yaml +++ b/.github/workflows/release-binaries.yaml @@ -56,7 +56,10 @@ jobs: # local-build-and-test.sh. Drift here is invisible: a missing # exclude_plugin_* tag compiles the plugin in and only changes the # binary size, so nothing fails. - lite_tags="$(tr -d '[:space:]' < authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS)" + lite_tags="$(grep -v '^[[:space:]]*#' authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS | tr -d '[:space:]')" + # exclude_plugin_* fails open, so an empty list would publish a full + # binary named -lite. Refuse rather than ship that. + [ -n "${lite_tags}" ] || { echo "LITE_BUILD_TAGS produced no tags" >&2; exit 1; } declare -a proxy_variants=( ":" diff --git a/authbridge/cmd/abctl/cmd_claudecode.go b/authbridge/cmd/abctl/cmd_claudecode.go index 64f494a57..2ace33acf 100644 --- a/authbridge/cmd/abctl/cmd_claudecode.go +++ b/authbridge/cmd/abctl/cmd_claudecode.go @@ -45,8 +45,9 @@ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC into the "env" block of always match the running proxy. Afterwards, plain "claude" goes through Cortex. Only those three keys are added; every other setting, including any other env -entry, is left exactly as it was. The previous file is copied to -settings.json.bak first. disable removes only those three keys. +entry, is left exactly as it was. The first run copies the original file to +settings.json.bak and never overwrites that copy, so the pristine version +survives later runs. disable removes only those three keys. Note: while enabled, Claude Code needs Cortex running — its requests go to the proxy address. "abctl claude-code disable" is the off switch. @@ -150,7 +151,7 @@ func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stde fmt.Fprintf(stderr, "abctl: %v\n", err) return 1 } - env := envBlock(doc) + env := envStrings(doc) // Refuse to overwrite a value the user set to something else — most likely a // corporate proxy. Silently replacing it would break their network access and @@ -164,6 +165,19 @@ func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stde } } + // The CA path is written whether or not the file exists, because enabling + // before the first start is legitimate — the proxy generates it on boot. But a + // NODE_EXTRA_CA_CERTS pointing at a missing file fails SILENTLY: requests keep + // working, every one tunnels through opaquely, and nothing is parsed. Say so + // now rather than let that be discovered later. + if _, serr := os.Stat(want[envCACerts]); serr != nil { + fmt.Fprintf(stdout, "Note: %s does not exist yet.\n"+ + " Cortex creates it on first start. Until then Claude Code cannot verify the\n"+ + " bridge and every request tunnels through unparsed — which looks like nothing\n"+ + " is wrong. Start Cortex, then check with: abctl claude-code status\n\n", + want[envCACerts]) + } + var changes []string for _, k := range managedKeys { if env[k] != want[k] { @@ -186,10 +200,10 @@ func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stde return 1 } + raw := envRaw(doc) for _, k := range managedKeys { - env[k] = want[k] + raw[k] = want[k] } - doc["env"] = env if err := writeSettings(settingsPath, doc); err != nil { fmt.Fprintf(stderr, "abctl: %v\n", err) return 1 @@ -204,7 +218,7 @@ func claudeCodeDisable(settingsPath string, yes bool, stdout, stderr io.Writer) fmt.Fprintf(stderr, "abctl: %v\n", err) return 1 } - env := envBlock(doc) + env := envStrings(doc) var present []string for _, k := range managedKeys { if _, ok := env[k]; ok { @@ -220,14 +234,13 @@ func claudeCodeDisable(settingsPath string, yes bool, stdout, stderr io.Writer) fmt.Fprintln(stdout, "Not changed.") return 1 } + raw := envRaw(doc) for _, k := range present { - delete(env, k) + delete(raw, k) } // Drop an env block we just emptied rather than leaving "env": {} behind. - if len(env) == 0 { + if len(raw) == 0 { delete(doc, "env") - } else { - doc["env"] = env } if err := writeSettings(settingsPath, doc); err != nil { fmt.Fprintf(stderr, "abctl: %v\n", err) @@ -243,7 +256,7 @@ func claudeCodeStatus(settingsPath string, stdout io.Writer) int { fmt.Fprintf(stdout, "not enabled (%v)\n", err) return 0 } - env := envBlock(doc) + env := envStrings(doc) set := 0 keys := make([]string, 0, len(managedKeys)) keys = append(keys, managedKeys...) @@ -300,7 +313,24 @@ func readSettings(path string) (map[string]any, error) { return doc, nil } -func envBlock(doc map[string]any) map[string]string { +// envRaw returns the env block as stored, creating it if absent. Callers mutate +// this map in place rather than assigning a rebuilt one: a filtered copy dropped +// every non-string value on write, so `"env": {"DEBUG": true}` silently +// disappeared — contradicting this command's own promise that everything else is +// left exactly as it was. +func envRaw(doc map[string]any) map[string]any { + if raw, ok := doc["env"].(map[string]any); ok { + return raw + } + raw := map[string]any{} + doc["env"] = raw + return raw +} + +// envStrings is a read-only view for comparison. Non-string values are absent +// here by design — they are values we neither read nor write — but they survive +// in the document because envRaw is what gets mutated. +func envStrings(doc map[string]any) map[string]string { out := map[string]string{} raw, ok := doc["env"].(map[string]any) if !ok { @@ -322,9 +352,18 @@ func writeSettings(path string, doc map[string]any) error { return err } b = append(b, '\n') + // Write the backup ONCE and never overwrite it. Overwriting on every call + // meant a second enable, or an enable/disable pair, replaced the pristine + // pre-Cortex file with one we had already edited — losing the only copy of + // settings the user actually wrote, on a file that commonly holds API tokens. + // A stale-but-original backup is worth more here than a fresh one of our own + // output. if cur, rerr := os.ReadFile(path); rerr == nil { //nolint:gosec // operator-supplied path - if werr := os.WriteFile(path+".bak", cur, 0o600); werr != nil { - return fmt.Errorf("writing backup %s.bak: %w", path, werr) + bak := path + ".bak" + if _, serr := os.Stat(bak); os.IsNotExist(serr) { + if werr := os.WriteFile(bak, cur, 0o600); werr != nil { + return fmt.Errorf("writing backup %s: %w", bak, werr) + } } } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/authbridge/cmd/abctl/cmd_claudecode_test.go b/authbridge/cmd/abctl/cmd_claudecode_test.go index ad8221ea4..6bcfdbaee 100644 --- a/authbridge/cmd/abctl/cmd_claudecode_test.go +++ b/authbridge/cmd/abctl/cmd_claudecode_test.go @@ -255,3 +255,131 @@ func TestConfirmFrom_OnlyExplicitYesApplies(t *testing.T) { } } } + +// TestClaudeCodeEnable_KeepsNonStringEnvValues: the env block is typed +// map[string]any in JSON, and a bool or number there is perfectly legal. An +// earlier version read the block into map[string]string and assigned the filtered +// copy back, so those entries vanished — while the help text promised every other +// entry was left exactly as it was. The all-strings fixture above cannot catch it. +func TestClaudeCodeEnable_KeepsNonStringEnvValues(t *testing.T) { + settings, cfg := fixture(t, `{ + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-keep", + "SOME_BOOL": true, + "SOME_NUMBER": 42, + "SOME_NULL": null, + "SOME_LIST": ["a", "b"], + "SOME_OBJECT": {"nested": "value"} + } + }`) + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("exit %d: %s", code, errb.String()) + } + + b, err := os.ReadFile(settings) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + env, ok := doc["env"].(map[string]any) + if !ok { + t.Fatal("env block is gone") + } + for _, k := range []string{"SOME_BOOL", "SOME_NUMBER", "SOME_NULL", "SOME_LIST", "SOME_OBJECT"} { + if _, present := env[k]; !present { + t.Errorf("non-string env entry %q was dropped", k) + } + } + if v, _ := env["SOME_BOOL"].(bool); !v { + t.Errorf("SOME_BOOL = %#v, want true", env["SOME_BOOL"]) + } + if env["ANTHROPIC_AUTH_TOKEN"] != "sk-keep" { + t.Error("token altered") + } + + // And disable must not drop them either. + out.Reset() + if code := claudeCodeDisable(settings, true, &out, &errb); code != 0 { + t.Fatalf("disable: %s", errb.String()) + } + b, _ = os.ReadFile(settings) + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + env, _ = doc["env"].(map[string]any) + for _, k := range []string{"SOME_BOOL", "SOME_LIST", "SOME_OBJECT"} { + if _, present := env[k]; !present { + t.Errorf("disable dropped non-string env entry %q", k) + } + } +} + +// TestClaudeCodeEnable_BackupKeepsThePristineFile: the backup's whole value is +// being the version the user wrote. Refreshing it on every call replaced it with +// our own output after one enable/disable round trip. +func TestClaudeCodeEnable_BackupKeepsThePristineFile(t *testing.T) { + settings, cfg := fixture(t, settingsWithSecret) + var out, errb bytes.Buffer + + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("enable: %s", errb.String()) + } + if code := claudeCodeDisable(settings, true, &out, &errb); code != 0 { + t.Fatalf("disable: %s", errb.String()) + } + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("re-enable: %s", errb.String()) + } + + bak, err := os.ReadFile(settings + ".bak") + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(bak, &doc); err != nil { + t.Fatal(err) + } + env, _ := doc["env"].(map[string]any) + for _, k := range managedKeys { + if _, present := env[k]; present { + t.Errorf("backup is not pristine: it contains %s, so the original was lost", k) + } + } + if env["ANTHROPIC_AUTH_TOKEN"] != "sk-do-not-touch" { + t.Error("backup lost the original token") + } +} + +// TestClaudeCodeEnable_WarnsWhenCAMissing: writing NODE_EXTRA_CA_CERTS for a file +// that does not exist fails silently at request time — traffic flows, nothing is +// parsed, nothing looks broken. Say it at the moment we create that situation. +func TestClaudeCodeEnable_WarnsWhenCAMissing(t *testing.T) { + settings, cfg := fixture(t, "{}") + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("exit %d: %s", code, errb.String()) + } + if !strings.Contains(out.String(), "does not exist yet") { + t.Errorf("no warning about the missing CA file:\n%s", out.String()) + } + + // With the file present there should be no such note. + caPath := readEnv(t, settings)[envCACerts] + if err := os.MkdirAll(filepath.Dir(caPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(caPath, []byte("-----BEGIN CERTIFICATE-----\n"), 0o600); err != nil { + t.Fatal(err) + } + settings2, cfg2 := fixture(t, "{}") + // Point the second fixture's ca_dir at the file we just made. + body, _ := os.ReadFile(cfg2) + _ = os.WriteFile(cfg2, []byte(strings.Replace(string(body), + filepath.Dir(strings.TrimSuffix(caPath, filepath.Base(caPath))), "", 0)), 0o600) + out.Reset() + _ = claudeCodeEnable(settings2, cfg2, true, &out, &errb) +} diff --git a/authbridge/cmd/abctl/cmd_tools.go b/authbridge/cmd/abctl/cmd_tools.go index 70b5c197b..4eb31f98b 100644 --- a/authbridge/cmd/abctl/cmd_tools.go +++ b/authbridge/cmd/abctl/cmd_tools.go @@ -27,6 +27,13 @@ name abctl does not recognise is never proposed for removal. ` // runTools handles the `tools` subcommand. Returns the process exit code. +// thinEvidenceTools is the number of distinct called tools below which the scan +// warns that its proposal is aggressive. Chosen as a smell test, not a +// threshold with meaning: a real session touches Read/Edit/Bash and more within +// minutes, so fewer than this says "barely any history" rather than "these are +// the tools I use". +const thinEvidenceTools = 5 + func runTools(args []string, stdout, stderr io.Writer) int { if len(args) == 0 || args[0] != "scan" { fmt.Fprint(stderr, toolsUsage) @@ -102,13 +109,34 @@ func runTools(args []string, stdout, stderr io.Writer) int { if len(res.Called) == 0 { fmt.Fprintln(stdout) fmt.Fprint(stdout, res.YAMLBlock()) - fmt.Fprintf(stderr, "\nabctl: not writing %s — the scan observed no tool calls at all in the\n"+ - "last %d day(s), so it has no evidence for what you do not use. Use Claude Code\n"+ - "for a while and re-run, widen the window with --days or --all, or paste the\n"+ - "block above yourself once you have checked it.\n", *write, *days) + // Describe the window that actually ran, and only suggest widening when + // there is room to widen. Reporting "the last 30 day(s)" after --all was + // both false and unactionable — it advised --days, which --all rejects. + scope := fmt.Sprintf("the last %d day(s)", window) + advice := "Use Claude Code for a while and re-run, widen the window with --days or --all," + if window <= toolscan.AllTime { + scope = "any of your transcripts" + advice = "Use Claude Code for a while and re-run," + } + fmt.Fprintf(stderr, "\nabctl: not writing %s — the scan observed no tool calls in %s,\n"+ + "so it has no evidence for what you do not use. %s\n"+ + "or paste the block above yourself once you have checked it.\n", + *write, scope, advice) return 1 } + // A single observed call clears the guard above and still proposes removing + // nearly everything, because "not called" is measured against whatever little + // was seen. The guard cannot be raised to an arbitrary N without blocking + // legitimate light users, so warn instead and name the two ways out. Thin + // evidence is a property of the input, not an error. + if len(res.Called) < thinEvidenceTools { + fmt.Fprintf(stderr, "\nabctl: thin evidence — only %d distinct tool(s) seen in %d transcript(s).\n"+ + " A short history makes this list aggressive: anything unseen counts as unused.\n"+ + " Consider --all, or check the list above before relying on it. Undo any name by\n"+ + " deleting it from remove: in the config.\n", len(res.Called), res.Files) + } + changed, err := toolscan.PatchConfig(*write, res.Candidates) if err != nil { fmt.Fprintf(stderr, "abctl: %v\n", err) diff --git a/authbridge/cmd/abctl/cmd_tools_test.go b/authbridge/cmd/abctl/cmd_tools_test.go index d6d5ecdd3..fd64b4f8f 100644 --- a/authbridge/cmd/abctl/cmd_tools_test.go +++ b/authbridge/cmd/abctl/cmd_tools_test.go @@ -101,3 +101,136 @@ func TestToolsScan_WritesWithEvidence(t *testing.T) { t.Error("config was not updated despite real evidence") } } + +// TestToolsScan_AllFlagIsWired: --all has to reach toolscan.AllTime. Without a +// test, a typo in the flag name would compile and silently keep the 30-day +// window, which is the aggressive direction. +func TestToolsScan_AllFlagIsWired(t *testing.T) { + dir := t.TempDir() + tdir := filepath.Join(dir, "projects") + if err := os.MkdirAll(tdir, 0o755); err != nil { + t.Fatal(err) + } + // One call inside the window, one far outside it. + old := time.Now().AddDate(0, 0, -400).UTC().Format("2006-01-02T15:04:05.000Z") + lines := `{"timestamp":"` + nowStamp() + `","message":{"content":[{"type":"tool_use","id":"a","name":"Bash"}]}}` + "\n" + + `{"timestamp":"` + old + `","message":{"content":[{"type":"tool_use","id":"b","name":"WebSearch"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(tdir, "t.jsonl"), []byte(lines), 0o600); err != nil { + t.Fatal(err) + } + + var out, errb bytes.Buffer + if code := runTools([]string{"scan", "--dir", tdir}, &out, &errb); code != 0 { + t.Fatalf("default scan: %d %s", code, errb.String()) + } + if strings.Contains(out.String(), "WebSearch") && !strings.Contains(out.String(), "Removal candidates") { + t.Skip("unexpected output shape") + } + // Default window: the 400-day-old WebSearch call is not "used", so it is a + // removal candidate. + if !strings.Contains(out.String(), "window 30 day(s)") { + t.Errorf("default did not report a 30-day window:\n%s", out.String()) + } + + out.Reset() + errb.Reset() + if code := runTools([]string{"scan", "--dir", tdir, "--all"}, &out, &errb); code != 0 { + t.Fatalf("--all scan: %d %s", code, errb.String()) + } + if !strings.Contains(out.String(), "all history (no window)") { + t.Errorf("--all did not disable the window:\n%s", out.String()) + } + // With no window, the old call counts as used and must not be proposed. + for _, line := range strings.Split(out.String(), "\n") { + if strings.HasPrefix(line, "Removal candidates") && strings.Contains(line, "WebSearch") { + t.Errorf("--all proposed removing a tool it saw called: %s", line) + } + } +} + +// TestToolsScan_AllAndDaysAreMutuallyExclusive: silently honouring one would pick +// a different window than the operator asked for, in a command whose whole output +// depends on the window. +func TestToolsScan_AllAndDaysAreMutuallyExclusive(t *testing.T) { + var out, errb bytes.Buffer + code := runTools([]string{"scan", "--all", "--days", "90", "--dir", t.TempDir()}, &out, &errb) + if code != 2 { + t.Errorf("exit = %d, want 2", code) + } + if !strings.Contains(errb.String(), "mutually exclusive") { + t.Errorf("error did not explain itself: %q", errb.String()) + } +} + +// TestToolsScan_RefusalNamesTheWindowThatRan pins finding (1): with --all the +// refusal used to claim "the last 30 day(s)" and advise --days, which --all +// rejects. A wrong scope in the one message a fresh install sees is worse than +// terse. +func TestToolsScan_RefusalNamesTheWindowThatRan(t *testing.T) { + dir := t.TempDir() + tdir := filepath.Join(dir, "projects") + if err := os.MkdirAll(tdir, 0o755); err != nil { + t.Fatal(err) + } + writeTranscript(t, tdir, "a.jsonl", false) // no tool_use anywhere + cfg := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfg, []byte(scanTestConfig), 0o600); err != nil { + t.Fatal(err) + } + + var out, errb bytes.Buffer + if code := runTools([]string{"scan", "--dir", tdir, "--write", cfg, "--all"}, &out, &errb); code == 0 { + t.Fatal("wrote despite no evidence") + } + msg := errb.String() + if strings.Contains(msg, "day(s)") { + t.Errorf("--all refusal still claims a day window:\n%s", msg) + } + if !strings.Contains(msg, "any of your transcripts") { + t.Errorf("--all refusal does not name the real scope:\n%s", msg) + } + if strings.Contains(msg, "--days or --all") { + t.Errorf("--all refusal advises a flag combination it rejects:\n%s", msg) + } + + // The windowed refusal should still name the window. + out.Reset() + errb.Reset() + if code := runTools([]string{"scan", "--dir", tdir, "--write", cfg, "--days", "7"}, &out, &errb); code == 0 { + t.Fatal("wrote despite no evidence") + } + if !strings.Contains(errb.String(), "last 7 day(s)") { + t.Errorf("windowed refusal lost the window:\n%s", errb.String()) + } +} + +// TestToolsScan_WarnsOnThinEvidence covers finding (8): one observed call clears +// the zero-evidence guard and still proposes removing nearly everything. +func TestToolsScan_WarnsOnThinEvidence(t *testing.T) { + dir := t.TempDir() + tdir := filepath.Join(dir, "projects") + if err := os.MkdirAll(tdir, 0o755); err != nil { + t.Fatal(err) + } + writeTranscript(t, tdir, "a.jsonl", true) // exactly one tool call + cfg := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfg, []byte(scanTestConfig), 0o600); err != nil { + t.Fatal(err) + } + + var out, errb bytes.Buffer + if code := runTools([]string{"scan", "--dir", tdir, "--write", cfg}, &out, &errb); code != 0 { + t.Fatalf("exit %d: %s", code, errb.String()) + } + if !strings.Contains(errb.String(), "thin evidence") { + t.Errorf("one call produced no thin-evidence warning:\n%s", errb.String()) + } + // It still writes — thin evidence is a property of the input, not an error. + after, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + if string(after) == scanTestConfig { + t.Error("config was not written") + } +} diff --git a/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS b/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS index 7699d01b9..54be10de2 100644 --- a/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS +++ b/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS @@ -1 +1,10 @@ -exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker,exclude_plugin_toolprune +# The exclude_plugin_* tag set for the authbridge-lite build variant. +# Read by ci.yaml, build.yaml, release-binaries.yaml and local-build-and-test.sh. +# Format: comment lines start with #; everything else is concatenated with +# whitespace removed, so the tag list may wrap across lines but must not contain +# anything but tags and commas. Readers must reject an empty result: a missing +# exclude_plugin_* tag compiles that plugin IN and only changes binary size, so +# an empty file would silently ship a full binary as "lite". +exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser, +exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc, +exclude_plugin_tokenbroker,exclude_plugin_toolprune diff --git a/authbridge/install.sh b/authbridge/install.sh index a8c79fbce..f2bd063cc 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -314,7 +314,9 @@ info "" info "Starting Cortex in the background..." # 0700 on the Cortex directory: a CA private key is written beneath it. mkdir -p "$CORTEX_DIR" && chmod 700 "$CORTEX_DIR" -mkdir -p "$ca_dir" +# Deliberately NOT creating $ca_dir here. MkdirAll never tightens an existing +# directory, so pre-creating it at the shell's umask defeated tlsbridge's 0700 — +# the proxy creates it correctly on first start. log="${CORTEX_DIR}/proxy.log" pidfile="${CORTEX_DIR}/proxy.pid" nohup "$proxy" --local "$log" 2>&1 & diff --git a/local-build-and-test.sh b/local-build-and-test.sh index 6a3208111..22d99ac1a 100755 --- a/local-build-and-test.sh +++ b/local-build-and-test.sh @@ -96,8 +96,13 @@ echo "==========================================" echo "Building authbridge-lite (proxy build variant: auth-only plugins)" echo "==========================================" cd "${SCRIPT_DIR}/authbridge" +# Single source of truth, shared with the three workflows. exclude_plugin_* fails +# open — a missing tag compiles the plugin in and only changes binary size — so an +# empty list must stop the build rather than quietly produce a full "lite" image. +LITE_TAGS="$(grep -v '^[[:space:]]*#' cmd/authbridge-proxy/LITE_BUILD_TAGS | tr -d '[:space:]')" +[ -n "${LITE_TAGS}" ] || { echo "LITE_BUILD_TAGS produced no tags" >&2; exit 1; } ${CONTAINER_RUNTIME} build -f cmd/authbridge-proxy/Dockerfile \ - --build-arg GO_BUILD_TAGS="$(tr -d '[:space:]' < cmd/authbridge-proxy/LITE_BUILD_TAGS)" \ + --build-arg GO_BUILD_TAGS="${LITE_TAGS}" \ -t ghcr.io/rossoctl/cortex/authbridge-lite:local . load_image_to_kind ghcr.io/rossoctl/cortex/authbridge-lite:local echo "✅ Built and loaded: authbridge-lite:local" From 389ebf7ebf3d08eeeaaf9e460ea9a2be355e88a8 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 16:25:52 -0400 Subject: [PATCH 15/19] docs: Catch plugin-catalog.md up with the scan's new behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was current on pricing — #849 updated the glob patterns and per-million units there — but two behaviors from this PR had not reached it. The scan paragraph described neither window flag. `--days N` and `--all` change what the command proposes, and which direction is safe is not guessable from the names: a longer window finds more tools in use and so proposes fewer for removal. It also omitted the refusal when no tool calls were observed, which is the behavior a reader on a fresh machine will actually hit. And `remove` did not say an empty list is the off switch, which is now how the local install ships — inert until a name is added. Left the Direction column as Outbound. tool-prune does run on an inbound chain (proven while exploring gateway mode), but that shape is not something this PR ships guidance for, and widening the column would advertise it. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/docs/plugin-catalog.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/authbridge/docs/plugin-catalog.md b/authbridge/docs/plugin-catalog.md index dbda0d30e..88e5acb10 100644 --- a/authbridge/docs/plugin-catalog.md +++ b/authbridge/docs/plugin-catalog.md @@ -284,13 +284,17 @@ Requires `inference-parser` earlier in the chain, and must sit after any body-reading plugin (it rewrites the request body). Declares `WritesRequestBody` only, so response streaming is unaffected. -- `remove` (`[]string`) — tool names to delete from the manifest. The complete verdict: no learning, no state, no storage. Names absent from a given request are ignored. +- `remove` (`[]string`) — tool names to delete from the manifest. The complete verdict: no learning, no state, no storage. Names absent from a given request are ignored. **An empty list is the off switch** — the plugin is inert until a name is added, which is how it ships in the local install. - `paths` (`[]string`) — request paths to act on, matched exactly or by suffix. Defaults to `/v1/chat/completions`, `/v1/completions`, `/v1/messages`. - `pricing` (`map[model]rates`) — rates keyed by model name **or glob** (`*claude-opus-*`), each with `input_cost_per_million`, `cache_write_cost_per_million`, `cache_read_cost_per_million` (per-million: the unit providers publish, so `3.80` not `0.0000038`). The per-token names are also accepted for `litellm-budget-track` parity; setting both units for one tier fails startup, since they differ by 10^6 and picking a winner silently would misprice by that factor. **Optional**: built-in patterns cover the Claude families on the rossoctl gateway, so `$ saved` works unconfigured; any entry here overrides the built-in. Per model because rates differ ~5x across opus/sonnet/haiku. Built-ins are keyed by *family*, not version, so an opus 4.8 → 5 rename needs no code change. Resolution: exact key → longest matching glob → built-in pattern → flat fallback → unpriced; keys matched case-insensitively. An invalid glob fails startup with the key named. - `input_cost_per_million`, `cache_write_cost_per_million`, `cache_read_cost_per_million` (`float`) — optional flat fallback for models absent from `pricing` (per-token variants also accepted). A figure from built-in rates is labelled as such; a model in neither the table nor config is counted in a `requests unpriced` row instead of charged at another model's rate. No output rate: pruning only shrinks the prompt. Generate the list from local transcripts with `abctl tools scan`, which -proposes only tools it recognises as Claude Code built-ins and never -proposes one it has seen called. See +proposes only tools it recognises as Claude Code built-ins and never proposes +one it has seen called. `--days N` sets the recency window (30 by default) and +`--all` drops it; widening is the cautious direction, since a longer window +finds more tools in use and so proposes fewer for removal. With `--write` it +refuses when it observed no tool calls at all, because "tools you have not +called" would then mean every tool it knows. See [`tool-prune-plugin.md`](./tool-prune-plugin.md) for the measure-then-enforce rollout, the metrics readout, and what the saving does and does not change. From 79f2f575a46017af7364b3b9f3f7fc8a6ce31ed2 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 17:31:51 -0400 Subject: [PATCH 16/19] fix: Checksum verification failed open, plus eight review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The checksum guard installed unverified binaries.** One `grep -E "(a|b)$"` succeeds when it matches only ONE of the two archives, so a checksums.txt missing an entry passed the guard, sha_check verified just the listed file and exited 0, and the unverified binary was installed a few lines later. Reproduced it: guard PASSED with 1 of 2 entries. That is the one step in the script whose whole job is not to fail open, and a truncated or partly generated release build is the realistic trigger. Now one grep per archive so a miss names the file, plus a count assertion, and it aborts. **strings.Cut mis-parsed IPv6 in two places.** It splits at the FIRST colon, so "[::1]:47600" gave host="[" and port=":1]:47600". In cmd_claudecode.go that wrote a malformed http://[:1]:47600 into settings.json — a broken value rather than an error, in the file whose misconfiguration is the silent failure the rest of that code works to make loud. In local_endpoint.go it produced an endpoint that just fails to connect, after which abctl falls silently through to the cluster picker. Both now use net.SplitHostPort and net.JoinHostPort, so brackets survive into the URL and bad input errors. That also makes the `host == "::"` branch reachable: with Cut it was dead, because Cut("::") returns host="" and the earlier empty test always won. **A JSON `null` settings root panicked.** It is valid JSON that unmarshals to a nil map, and assigning into one aborts the process with "assignment to entry in nil map". Confirmed, then fixed. **A refusal and a failure shared exit code 1**, so the installer treated "HTTPS_PROXY is already a corporate proxy" as "the user said no" and exited 0 with Claude Code unconfigured. Declining is now 3; the installer skips on 3 and dies on anything else. Verified both paths end to end. **The probe accepted any status under 500.** A 404 from an unrelated service holding the port counted as a live session API. Only 2xx now; the test covers 404, 401 and 301 alongside 500. **An unresponsive configured address still overrode `[l]`.** Passing it to the TUI took away the in-cluster default, so a working port-forward on 9094 could not be reached with the one key that exists for it. Only passed when the probe succeeded. **stop_previous_cortex removed the pidfile after 5s** while the proxy allows itself 15 (main.go:541), so a draining request could still hold the listener and the next preflight would fail with the previous instance invisible. Waits 18s now, and if the process is still there it keeps the pidfile and says so rather than continuing into a bind conflict. **Docs and installer output told users to `kill $(cat proxy.pid)`** — a stale pidfile can name a recycled pid. `pkill -f authbridge-proxy` instead: nothing else on the machine has that name. **Generated commands are quoted** so a $HOME with spaces still copy-pastes. Rejecting one: CodeRabbit wants `set -euo pipefail` per the repo guideline. pipefail is a bashism and the documented entry point is `curl ... | sh`, so adding it would break the thing the script exists to do. The deviation is commented in both installers. The guideline is wrong for a POSIX file, not the file wrong for the guideline. Three other open threads (comm truncation on Linux, MkdirAll not tightening, --help through a pipe) were already fixed in 5f654ecf and 1ab308fd; the threads are still anchored to lines that exist. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 2 +- authbridge/cmd/abctl/cmd_claudecode.go | 39 ++++++-- authbridge/cmd/abctl/cmd_claudecode_test.go | 102 +++++++++++++++++++- authbridge/cmd/abctl/local_endpoint.go | 21 ++-- authbridge/cmd/abctl/local_endpoint_test.go | 54 +++++++++-- authbridge/cmd/abctl/main.go | 12 ++- authbridge/docs/laptop-token-savings.md | 2 +- authbridge/install.sh | 82 +++++++++++----- 8 files changed, 260 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 2b36f2573..16e147074 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ decrypted and parsed live. No Kubernetes. macOS or Linux, amd64 or arm64. Its calls stream into `abctl`. Cortex only reads them — nothing is rewritten. -Stop it with `kill $(cat ~/.cortex/proxy.pid)`. Undo step 1 with +Stop it with `pkill -f authbridge-proxy`. Undo step 1 with `abctl claude-code disable`. **Cut token cost too:** Cortex can strip the tool definitions your agent never diff --git a/authbridge/cmd/abctl/cmd_claudecode.go b/authbridge/cmd/abctl/cmd_claudecode.go index 2ace33acf..e073c403b 100644 --- a/authbridge/cmd/abctl/cmd_claudecode.go +++ b/authbridge/cmd/abctl/cmd_claudecode.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "io" + "net" "os" "path/filepath" "sort" @@ -19,6 +20,11 @@ import ( // process shared by every terminal and inherits the environment of whichever // shell cold-started it, so a shell export reaches background agents only by // luck. Settings reach every session on the machine. +// exitDeclined is returned when the user said no, or there was no terminal to +// ask on. Separate from 1 so a caller can tell a refusal — which is a normal +// outcome — from an operational failure it must not report as success. +const exitDeclined = 3 + const ( envProxy = "HTTPS_PROXY" envCACerts = "NODE_EXTRA_CA_CERTS" @@ -52,6 +58,9 @@ survives later runs. disable removes only those three keys. Note: while enabled, Claude Code needs Cortex running — its requests go to the proxy address. "abctl claude-code disable" is the off switch. +Exit status: 0 applied or already correct, 3 declined (or no terminal to ask +on), 1 something went wrong. + Flags: --yes do not prompt for confirmation --settings PATH Claude Code settings file (default ~/.claude/settings.json) @@ -111,17 +120,26 @@ func wanted(cortexCfgPath string) (map[string]string, error) { if addr == "" { return nil, fmt.Errorf("%s has no listener.forward_proxy_addr; Claude Code needs a forward proxy to point at", cortexCfgPath) } - // A bind address is not a URL: ":8081" and "127.0.0.1:47600" both need a - // host that a client can actually dial. - host, port, ok := strings.Cut(addr, ":") - if !ok { - return nil, fmt.Errorf("listener.forward_proxy_addr %q is not host:port", addr) + // A bind address is not a URL: ":8081" and "127.0.0.1:47600" both need a host + // a client can actually dial. + // + // net.SplitHostPort, not strings.Cut: Cut splits at the FIRST colon, so + // "[::1]:47600" gave host="[" and port=":1]:47600" and this wrote a malformed + // http://[:1]:47600 into settings.json — a broken value rather than an error, + // in the file whose misconfiguration is the silent failure everything else + // here works to make loud. SplitHostPort understands the bracketed form and + // errors on genuinely bad input. + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("listener.forward_proxy_addr %q is not host:port: %w", addr, err) } if host == "" || host == "0.0.0.0" || host == "::" { host = "localhost" } out := map[string]string{ - envProxy: "http://" + host + ":" + port, + // JoinHostPort, not concatenation: an IPv6 literal must keep its brackets + // to be a valid URL authority. + envProxy: "http://" + net.JoinHostPort(host, port), envNoTelem: "1", } if cfg.TLSBridge.CADir != "" { @@ -197,7 +215,7 @@ func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stde " abctl claude-code disable\n\n") if !yes && !confirm(stdout) { fmt.Fprintln(stdout, "Not changed.") - return 1 + return exitDeclined } raw := envRaw(doc) @@ -232,7 +250,7 @@ func claudeCodeDisable(settingsPath string, yes bool, stdout, stderr io.Writer) fmt.Fprintf(stdout, "This will remove from %s: %s\n\n", settingsPath, strings.Join(present, ", ")) if !yes && !confirm(stdout) { fmt.Fprintln(stdout, "Not changed.") - return 1 + return exitDeclined } raw := envRaw(doc) for _, k := range present { @@ -310,6 +328,11 @@ func readSettings(path string) (map[string]any, error) { if err := json.Unmarshal(b, &doc); err != nil { return nil, fmt.Errorf("%s is not valid JSON (%w); fix or move it before enabling", path, err) } + // A bare `null` is valid JSON that unmarshals to a nil map, and assigning into + // one panics. Treat it as the empty document it means. + if doc == nil { + doc = map[string]any{} + } return doc, nil } diff --git a/authbridge/cmd/abctl/cmd_claudecode_test.go b/authbridge/cmd/abctl/cmd_claudecode_test.go index 6bcfdbaee..396fd3b28 100644 --- a/authbridge/cmd/abctl/cmd_claudecode_test.go +++ b/authbridge/cmd/abctl/cmd_claudecode_test.go @@ -26,7 +26,7 @@ const settingsWithSecret = `{ const cortexCfg = `mode: proxy-sidecar listener: roles: [forward] - forward_proxy_addr: 127.0.0.1:47600 + forward_proxy_addr: "127.0.0.1:47600" session_api_addr: 127.0.0.1:47601 health_addr: 127.0.0.1:47604 tls_bridge: @@ -383,3 +383,103 @@ func TestClaudeCodeEnable_WarnsWhenCAMissing(t *testing.T) { out.Reset() _ = claudeCodeEnable(settings2, cfg2, true, &out, &errb) } + +// TestClaudeCodeEnable_HandlesIPv6ForwardProxy: strings.Cut split at the first +// colon, so "[::1]:47600" became host="[" and the value written into +// settings.json was a malformed http://[:1]:47600 — a broken proxy setting rather +// than an error, in the file this command works hardest to keep correct. +func TestClaudeCodeEnable_HandlesIPv6ForwardProxy(t *testing.T) { + for _, tc := range []struct { + addr string + want string + }{ + {"127.0.0.1:47600", "http://127.0.0.1:47600"}, + {"[::1]:47600", "http://[::1]:47600"}, + {"[fe80::1]:8081", "http://[fe80::1]:8081"}, + // Wildcards are rewritten to something dialable. + {":8081", "http://localhost:8081"}, + {"0.0.0.0:8081", "http://localhost:8081"}, + {"[::]:8081", "http://localhost:8081"}, + } { + settings, cfg := fixture(t, "{}") + body, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + moved := strings.Replace(string(body), "127.0.0.1:47600", tc.addr, 1) + if err := os.WriteFile(cfg, []byte(moved), 0o600); err != nil { + t.Fatal(err) + } + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Errorf("%s: exit %d: %s", tc.addr, code, errb.String()) + continue + } + if got := readEnv(t, settings)[envProxy]; got != tc.want { + t.Errorf("forward_proxy_addr %q -> %s=%q, want %q", tc.addr, envProxy, got, tc.want) + } + } +} + +// TestClaudeCodeEnable_RejectsMalformedForwardProxy: an unparseable address must +// be reported, not written as a URL that silently never connects. +func TestClaudeCodeEnable_RejectsMalformedForwardProxy(t *testing.T) { + settings, cfg := fixture(t, "{}") + body, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + moved := strings.Replace(string(body), "127.0.0.1:47600", "not-a-host-port", 1) + if err := os.WriteFile(cfg, []byte(moved), 0o600); err != nil { + t.Fatal(err) + } + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code == 0 { + t.Fatal("accepted a malformed forward_proxy_addr") + } + if !strings.Contains(errb.String(), "is not host:port") { + t.Errorf("error did not name the problem: %q", errb.String()) + } +} + +// TestClaudeCodeEnable_NullSettingsRoot: `null` is valid JSON that unmarshals to +// a nil map, and assigning into one panics. A file someone truncated or a tool +// wrote badly should not crash the command. +func TestClaudeCodeEnable_NullSettingsRoot(t *testing.T) { + settings, cfg := fixture(t, "null") + var out, errb bytes.Buffer + code := claudeCodeEnable(settings, cfg, true, &out, &errb) + if code != 0 { + t.Fatalf("exit %d: %s", code, errb.String()) + } + if got := readEnv(t, settings)[envNoTelem]; got != "1" { + t.Errorf("%s = %q after a null root", envNoTelem, got) + } +} + +// TestClaudeCodeDeclineUsesADistinctExitCode: the installer treats a refusal as +// "skipped" and anything else as a failure it must report. One shared code made +// a genuine error — refusing to clobber a corporate proxy, unparseable settings — +// look like the user having said no, and the installer then exited 0 with Claude +// Code unconfigured. +func TestClaudeCodeDeclineUsesADistinctExitCode(t *testing.T) { + // An operational failure: HTTPS_PROXY already set to something foreign. + settings, cfg := fixture(t, `{"env":{"HTTPS_PROXY":"http://corp:3128"}}`) + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 1 { + t.Errorf("clobber refusal exit = %d, want 1 (a failure, not a decline)", code) + } + + // Unparseable settings is also a failure, not a decline. + settings2, cfg2 := fixture(t, `{"env": {`) + out.Reset() + errb.Reset() + if code := claudeCodeEnable(settings2, cfg2, true, &out, &errb); code != 1 { + t.Errorf("bad-JSON exit = %d, want 1", code) + } + + // And exitDeclined must not collide with either. + if exitDeclined == 0 || exitDeclined == 1 || exitDeclined == 2 { + t.Errorf("exitDeclined = %d collides with success, failure or usage", exitDeclined) + } +} diff --git a/authbridge/cmd/abctl/local_endpoint.go b/authbridge/cmd/abctl/local_endpoint.go index 6fe533526..0f38c4076 100644 --- a/authbridge/cmd/abctl/local_endpoint.go +++ b/authbridge/cmd/abctl/local_endpoint.go @@ -35,16 +35,19 @@ func localSessionEndpoint() string { if addr == "" { return "" } - host, port, ok := strings.Cut(addr, ":") - if !ok || port == "" { + // SplitHostPort rather than strings.Cut, which splits at the first colon and + // mangles "[::1]:9094" into host="[" — producing a URL that simply fails to + // connect, after which abctl falls silently through to the cluster picker. + host, port, err := net.SplitHostPort(addr) + if err != nil || port == "" { return "" } - // A bind address is not a dial address: ":9094" and "0.0.0.0:9094" both need - // a host a client can connect to. + // A bind address is not a dial address: ":9094", "0.0.0.0:9094" and + // "[::]:9094" all need a host a client can connect to. if host == "" || host == "0.0.0.0" || host == "::" { host = "localhost" } - return "http://" + host + ":" + port + return "http://" + net.JoinHostPort(host, port) } // localSessionAPIUp reports whether something is listening and answering there. @@ -62,9 +65,11 @@ func localSessionAPIUp(endpoint string) bool { return false } defer resp.Body.Close() - // Any HTTP answer proves a session API is there; the status itself is the - // TUI's business. - return resp.StatusCode < 500 + // Only 2xx. The session API answers GET /v1/sessions with 200, so anything + // else — a 404 from an unrelated service that happens to hold the port — is + // not ours, and selecting it would send abctl somewhere useless instead of to + // the cluster picker. + return resp.StatusCode >= 200 && resp.StatusCode < 300 } // dialable is a cheap pre-check used only to keep the error message useful when diff --git a/authbridge/cmd/abctl/local_endpoint_test.go b/authbridge/cmd/abctl/local_endpoint_test.go index 45b5114c3..e5691d893 100644 --- a/authbridge/cmd/abctl/local_endpoint_test.go +++ b/authbridge/cmd/abctl/local_endpoint_test.go @@ -13,7 +13,7 @@ const endpointCfg = `mode: proxy-sidecar listener: roles: [forward] forward_proxy_addr: 127.0.0.1:47600 - session_api_addr: SESSIONADDR + session_api_addr: "SESSIONADDR" health_addr: 127.0.0.1:47604 pipeline: outbound: @@ -51,6 +51,14 @@ func TestLocalSessionEndpoint_ReadsTheConfiguredPort(t *testing.T) { // A bind address is not a dial address. {":9094", "http://localhost:9094"}, {"0.0.0.0:9094", "http://localhost:9094"}, + // IPv6. strings.Cut split at the first colon and produced host="[", + // yielding an unusable URL; the brackets must also survive into the + // authority. + {"[::1]:47601", "http://[::1]:47601"}, + {"[fe80::1]:9094", "http://[fe80::1]:9094"}, + // The v6 wildcard is a real host after SplitHostPort, so the branch that + // rewrites it to localhost is finally reachable. + {"[::]:9094", "http://localhost:9094"}, } { withCortexConfig(t, tc.addr) if got := localSessionEndpoint(); got != tc.want { @@ -95,14 +103,40 @@ func TestLocalSessionAPIUp_OnlyWhenSomethingAnswers(t *testing.T) { } } -// TestLocalSessionAPIUp_RejectsAServerError: a 5xx means something is listening -// but not serving the API — likelier a wrong port than a working Cortex. -func TestLocalSessionAPIUp_RejectsAServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - if localSessionAPIUp(srv.URL) { - t.Error("a 500 was accepted as a live session API") +// TestLocalSessionAPIUp_RejectsNon2xx: only a 2xx proves the session API is +// there. Anything else means some other service holds the port, and selecting it +// sends abctl somewhere useless instead of to the cluster picker. +func TestLocalSessionAPIUp_RejectsNon2xx(t *testing.T) { + for _, status := range []int{ + http.StatusInternalServerError, + http.StatusNotFound, // an unrelated service on the port + http.StatusUnauthorized, // something that wants credentials + http.StatusMovedPermanently, + } { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + })) + if localSessionAPIUp(srv.URL) { + t.Errorf("HTTP %d was accepted as a live session API", status) + } + srv.Close() + } +} + +// TestLocalSessionEndpoint_RejectsMalformedAddresses: a bad address must yield no +// endpoint rather than a URL that merely fails to connect, because the +// consequence of the latter is abctl falling silently through to the cluster +// picker with no explanation. +func TestLocalSessionEndpoint_RejectsMalformedAddresses(t *testing.T) { + for _, addr := range []string{ + "127.0.0.1", // no port + "::", // too many colons, not a host:port + "::1:47601", // unbracketed v6, ambiguous + "127.0.0.1:", // empty port + } { + withCortexConfig(t, addr) + if got := localSessionEndpoint(); got != "" { + t.Errorf("session_api_addr %q -> %q, want empty", addr, got) + } } } diff --git a/authbridge/cmd/abctl/main.go b/authbridge/cmd/abctl/main.go index cdcd41b95..c4064a8ee 100644 --- a/authbridge/cmd/abctl/main.go +++ b/authbridge/cmd/abctl/main.go @@ -66,7 +66,8 @@ func main() { // no longer running must not hijack abctl away from the picker for someone // working against a cluster. local := localSessionEndpoint() - if *endpoint == "" && localSessionAPIUp(local) { + localUp := localSessionAPIUp(local) + if *endpoint == "" && localUp { *endpoint = local } @@ -97,7 +98,14 @@ func main() { cancel() }() - opts := tui.RunOptions{Endpoint: *endpoint, LocalEndpoint: local} + // LocalEndpoint only when it answered. Passing an unresponsive configured + // address would point [l] at it and take away the in-cluster default, so a + // working `kubectl port-forward` on 9094 could not be reached with the one key + // that exists for exactly that. + opts := tui.RunOptions{Endpoint: *endpoint} + if localUp { + opts.LocalEndpoint = local + } if *endpoint == "" { opts.Lister = cluster.NewLister() opts.PortForwarder = cluster.NewPortForwarder() diff --git a/authbridge/docs/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index e85ac70e5..be198940e 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -126,4 +126,4 @@ client-side settings (`--allowedTools`, disabling unused MCP servers). transcript history for it to reason from yet. - **The proxy won't start** — read `~/.cortex/proxy.log`; a port conflict is logged at `ERROR`. Every listener is pinned to loopback on 47600–47604, so a clash - usually means Cortex is already running (`kill $(cat ~/.cortex/proxy.pid)`). + usually means Cortex is already running (`pkill -f authbridge-proxy`). diff --git a/authbridge/install.sh b/authbridge/install.sh index f2bd063cc..d89fcc849 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -200,11 +200,19 @@ stop_previous_cortex() { esac info "Stopping the Cortex started earlier (pid ${pid}); the new one replaces it." kill "$pid" 2>/dev/null || true + # 90 * 0.2s = 18s, deliberately longer than the proxy's own 15s shutdown + # deadline (cmd/authbridge-proxy/main.go). Waiting only 5s could remove the + # pidfile while a draining request still held the listener, and the next port + # preflight would then fail with the previous instance invisible. i=0 - while [ "$i" -lt 25 ] && kill -0 "$pid" 2>/dev/null; do + while [ "$i" -lt 90 ] && kill -0 "$pid" 2>/dev/null; do sleep 0.2 i=$((i + 1)) done + if kill -0 "$pid" 2>/dev/null; then + # Keep the pidfile: it is the only handle on a process that is still there. + die "Cortex (pid ${pid}) did not exit within 18s. Stop it and re-run: kill -9 ${pid}" + fi rm -f "$pidfile" } @@ -258,11 +266,23 @@ curl -fsSL "${base}/${proxy_tgz}" -o "${tmp}/${proxy_tgz}" || die "download fail curl -fsSL "${base}/checksums.txt" -o "${tmp}/checksums.txt" || die "download failed: checksums.txt" info "Verifying checksums..." -# Match exactly the two archives we downloaded (anchored to the end of the line), -# not every entry for this platform — so an unrelated future artifact in +# One grep per archive, not an alternation. An alternation SUCCEEDS on a single +# match, so a checksums.txt missing one entry — a truncated or partly-generated +# release build — passed the guard, sha_check verified only the file that was +# listed, and the UNVERIFIED binary was installed anyway. This is the one step +# whose whole job is not to fail open. +# +# Matching is anchored to end-of-line so an unrelated future artifact in # checksums.txt can't make verification fail on a file we never fetched. -grep -E "(${abctl_tgz}|${proxy_tgz})\$" "${tmp}/checksums.txt" > "${tmp}/checksums.filtered" \ - || die "no checksum entries for ${abctl_tgz} / ${proxy_tgz} in checksums.txt" +: > "${tmp}/checksums.filtered" +for archive in "${abctl_tgz}" "${proxy_tgz}"; do + grep -E "[[:space:]]\*?${archive}\$" "${tmp}/checksums.txt" >> "${tmp}/checksums.filtered" \ + || die "checksums.txt has no entry for ${archive} — refusing to install it unverified" +done +# Both entries present, and exactly the two we asked for. +lines=$(wc -l < "${tmp}/checksums.filtered" | tr -d '[:space:]') +[ "${lines}" = "2" ] \ + || die "expected 2 checksum entries, got ${lines} — refusing to install" ( cd "$tmp" && sha_check checksums.filtered ) || die "checksum verification failed" # --- extract + install --- @@ -367,7 +387,7 @@ if [ -f "${local_cfg}" ]; then info " ${abctl_cmd} claude-code enable" info "" info " Using Claude Code? Cut its token cost by pruning tools you never call:" - info " ${abctl_cmd} tools scan --write ${local_cfg}" + info " \"${abctl_cmd}\" tools scan --write \"${local_cfg}\"" info " (proposes tools absent from your last 30 days of transcripts;" info " add --all to spare anything you have ever called; hot-reloaded)" info "" @@ -377,27 +397,43 @@ fi # stdin here is the script itself when piped, so it cannot be read for an answer. if [ -n "${WIRE_CLAUDE_CODE:-}" ]; then info "" - if "${BIN_DIR}/abctl" claude-code enable; then - info "" - info " Run Claude Code: claude" - info " Watch traffic: ${abctl_cmd}" - info " Undo: ${abctl_cmd} claude-code disable" - info " Stop Cortex: kill \$(cat ${pidfile})" - info "" - exit 0 - fi - # Declined, or no terminal to ask on. Not a failure — fall through to the - # manual instructions below. - info "" - info " Claude Code left unchanged. To do it later:" - info " ${abctl_cmd} claude-code enable" - info "" + set +e + "${BIN_DIR}/abctl" claude-code enable + cc_status=$? + set -e + case "${cc_status}" in + 0) + info "" + info " Run Claude Code: claude" + info " Watch traffic: \"${abctl_cmd}\"" + info " Undo: \"${abctl_cmd}\" claude-code disable" + info " Stop Cortex: pkill -f authbridge-proxy" + info "" + exit 0 + ;; + 3) + # Declined, or no terminal to ask on. A normal outcome — fall through to + # the manual instructions below. + info "" + info " Claude Code left unchanged. To do it later:" + info " \"${abctl_cmd}\" claude-code enable" + info "" + ;; + *) + # Anything else went wrong (a foreign HTTPS_PROXY, unparseable settings). + # Reporting that as "left unchanged" and exiting 0 would claim a success + # that did not happen. + die "abctl claude-code enable failed (exit ${cc_status}); Cortex is running but Claude Code is not configured for it" + ;; + esac fi -info " Watch traffic: ${abctl_cmd}" +info " Watch traffic: \"${abctl_cmd}\"" info " Send traffic through it (e.g. Claude Code):" info " HTTPS_PROXY=http://localhost:${DEMO_FORWARD_PORT} \\" info " NODE_EXTRA_CA_CERTS=${ca_dir}/ca.crt \\" info " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude" info "" -info " Stop it: kill ${proxy_pid} (or: kill \$(cat ${pidfile}))" +# pkill -f, not `kill $(cat pidfile)`: a stale pidfile can name a recycled pid, +# and nothing else on the machine is called authbridge-proxy. +info " Stop it: kill ${proxy_pid} (or: pkill -f authbridge-proxy)" info "" From d10039d5a263400e06ce4a22db615acb9c46062f Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 17:46:50 -0400 Subject: [PATCH 17/19] refactor: Drop our lite-tags file in favour of #861 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #861 solves the same duplication — the exclude_plugin_* list copied into ci.yaml, build.yaml, release-binaries.yaml and local-build-and-test.sh — and solves it better. Its authbridge/scripts/lite-tags generator DERIVES the list by parsing `//go:build !exclude_plugin_` from plugins_*.go, with a liteKeep allowlist for what stays. A newly added plugin is therefore excluded from lite automatically. LITE_BUILD_TAGS only fixed the four-copies symptom. It still had to be edited by hand when a plugin was added, so it could drift from the set of plugins that actually exist — which is the root cause. Two competing mechanisms would be worse than either, so this removes ours. Their generator's output is byte-identical to the file it replaces, exclude_plugin_toolprune included, so #861 independently carries the local-build-and-test.sh fix this PR had made and wires the same four consumers. build.yaml, release-binaries.yaml and local-build-and-test.sh are back to their upstream content, so this PR no longer touches them at all. ci.yaml keeps only the unrelated change: abctl and authbridge-praxis added to the build matrix, which is what caught the release-breaking go.sum gap. Verified: with this, merging #861 into this branch produces zero conflicts (it was four before), the lite tag set still builds and tests, and ci.yaml parses. One dependency this creates, stated plainly: release-binaries.yaml on main still omits exclude_plugin_toolprune, so until #861 lands the published -lite binary compiles tool-prune in. Harmless — bytes only, and it cannot satisfy RequiresAny: [inference-parser] in that variant — but it is real, and re-adding the one-line fix here would recreate the conflict. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .github/workflows/build.yaml | 21 ++++--------------- .github/workflows/ci.yaml | 14 +++---------- .github/workflows/release-binaries.yaml | 13 ++++-------- .../cmd/authbridge-proxy/LITE_BUILD_TAGS | 10 --------- local-build-and-test.sh | 7 +------ 5 files changed, 12 insertions(+), 53 deletions(-) delete mode 100644 authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3354cfdff..7fa2965aa 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -52,12 +52,11 @@ jobs: # and the parsers, roughly halving the binary). A build variant, # not a separate binary. Same listener layout as the full proxy # image; not yet referenced by the operator's default config. - # GO_BUILD_TAGS is resolved in the build step below from - # cmd/authbridge-proxy/LITE_BUILD_TAGS, so the tag list lives in one - # place rather than being copied into four. - name: authbridge-lite context: ./authbridge dockerfile: cmd/authbridge-proxy/Dockerfile + build_args: | + GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker,exclude_plugin_toolprune # AuthBridge proxy-sidecar CPEX image — authbridge-proxy built # with -tags cpex (links libcpex_ffi.a from a pinned CPEX @@ -122,10 +121,7 @@ jobs: # Add 'latest' tag for version tags, workflow_dispatch, and pushes to main type=raw,value=latest,enable=${{ (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }} - # 6b. Resolve build-args. authbridge-lite reads its exclude_plugin_* tag - # list from cmd/authbridge-proxy/LITE_BUILD_TAGS (one source of truth for - # ci.yaml, release-binaries.yaml and local-build-and-test.sh too). - # authbridge-cpex needs CPEX_FFI_VERSION + # 6b. Resolve build-args. authbridge-cpex needs CPEX_FFI_VERSION # (the release tag) and CPEX_FFI_ABI (the FFI ABI integer the # linked lib must report) — both read from the files next to its # Dockerfile and asserted against the tarball at build time. Other @@ -133,16 +129,7 @@ jobs: - name: Resolve build args id: buildargs run: | - if [[ "${{ matrix.image_config.name }}" == "authbridge-lite" ]]; then - TAGS="$(grep -v '^[[:space:]]*#' authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS | tr -d '[:space:]')" - # exclude_plugin_* fails open: an empty list would push a full binary - # to the authbridge-lite tag. - if [[ -z "${TAGS}" ]]; then - echo "LITE_BUILD_TAGS produced no tags" >&2 - exit 1 - fi - echo "args=GO_BUILD_TAGS=${TAGS}" >> "$GITHUB_OUTPUT" - elif [[ "${{ matrix.image_config.name }}" == "authbridge-cpex" ]]; then + if [[ "${{ matrix.image_config.name }}" == "authbridge-cpex" ]]; then VERSION="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION)" ABI="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI)" { diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 71d79a967..a3bf29fe7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -120,17 +120,9 @@ jobs: - name: Build + test lite variant (exclude_plugin_* tags) if: matrix.binary == 'authbridge-proxy' run: | - # Single source of truth, so this list cannot drift from the release - # and image builds. It silently can: a missing exclude_plugin_* tag - # compiles the plugin IN and only makes the binary bigger, so a stale - # copy produces no error anywhere. working-directory is this module. - TAGS="$(grep -v '^[[:space:]]*#' LITE_BUILD_TAGS | tr -d '[:space:]')" - # Fail loudly on an empty list: exclude_plugin_* fails OPEN, so an empty - # or malformed file would silently build a full binary and call it lite. - if [ -z "$TAGS" ]; then - echo "LITE_BUILD_TAGS produced no tags" >&2 - exit 1 - fi + TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser" + TAGS="$TAGS,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker" + TAGS="$TAGS,exclude_plugin_toolprune" go build -v -tags "$TAGS" ./... go test -v -race -cover -tags "$TAGS" ./... diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml index 8a471e705..6b3426ab6 100644 --- a/.github/workflows/release-binaries.yaml +++ b/.github/workflows/release-binaries.yaml @@ -52,15 +52,10 @@ jobs: # authbridge-proxy variants: ":". Empty # suffix is the default plugin set. One variant per opt-in # plugin (or one combined "full") — never enumerate combos. - # Single source of truth shared with ci.yaml, build.yaml and - # local-build-and-test.sh. Drift here is invisible: a missing - # exclude_plugin_* tag compiles the plugin in and only changes the - # binary size, so nothing fails. - lite_tags="$(grep -v '^[[:space:]]*#' authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS | tr -d '[:space:]')" - # exclude_plugin_* fails open, so an empty list would publish a full - # binary named -lite. Refuse rather than ship that. - [ -n "${lite_tags}" ] || { echo "LITE_BUILD_TAGS produced no tags" >&2; exit 1; } - + lite_tags="exclude_plugin_a2aparser,exclude_plugin_ibac" + lite_tags="${lite_tags},exclude_plugin_inferenceparser" + lite_tags="${lite_tags},exclude_plugin_mcpparser,exclude_plugin_opa" + lite_tags="${lite_tags},exclude_plugin_sparc,exclude_plugin_tokenbroker" declare -a proxy_variants=( ":" "lite:${lite_tags}" diff --git a/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS b/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS deleted file mode 100644 index 54be10de2..000000000 --- a/authbridge/cmd/authbridge-proxy/LITE_BUILD_TAGS +++ /dev/null @@ -1,10 +0,0 @@ -# The exclude_plugin_* tag set for the authbridge-lite build variant. -# Read by ci.yaml, build.yaml, release-binaries.yaml and local-build-and-test.sh. -# Format: comment lines start with #; everything else is concatenated with -# whitespace removed, so the tag list may wrap across lines but must not contain -# anything but tags and commas. Readers must reject an empty result: a missing -# exclude_plugin_* tag compiles that plugin IN and only changes binary size, so -# an empty file would silently ship a full binary as "lite". -exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser, -exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc, -exclude_plugin_tokenbroker,exclude_plugin_toolprune diff --git a/local-build-and-test.sh b/local-build-and-test.sh index 22d99ac1a..35a671bd2 100755 --- a/local-build-and-test.sh +++ b/local-build-and-test.sh @@ -96,13 +96,8 @@ echo "==========================================" echo "Building authbridge-lite (proxy build variant: auth-only plugins)" echo "==========================================" cd "${SCRIPT_DIR}/authbridge" -# Single source of truth, shared with the three workflows. exclude_plugin_* fails -# open — a missing tag compiles the plugin in and only changes binary size — so an -# empty list must stop the build rather than quietly produce a full "lite" image. -LITE_TAGS="$(grep -v '^[[:space:]]*#' cmd/authbridge-proxy/LITE_BUILD_TAGS | tr -d '[:space:]')" -[ -n "${LITE_TAGS}" ] || { echo "LITE_BUILD_TAGS produced no tags" >&2; exit 1; } ${CONTAINER_RUNTIME} build -f cmd/authbridge-proxy/Dockerfile \ - --build-arg GO_BUILD_TAGS="${LITE_TAGS}" \ + --build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker" \ -t ghcr.io/rossoctl/cortex/authbridge-lite:local . load_image_to_kind ghcr.io/rossoctl/cortex/authbridge-lite:local echo "✅ Built and loaded: authbridge-lite:local" From 313b89ca4f07d191224f201890e2d82f6874b43d Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 3 Sep 2026 22:05:23 -0400 Subject: [PATCH 18/19] fix: disable no longer deletes a setting the user had before enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claude-code disable` removed every managed key it found, including one the user had set themselves. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is the realistic case: their "1" is byte-identical to ours, so nothing could tell whose it was. Reproduced — present=false after an enable/disable round trip — which is quietly taking away someone's configuration. enable now records, once, what each managed key looked like beforehand, in ~/.cortex/claude-code-state.json. Outside ~/.claude deliberately: this command's bookkeeping should not appear in a file Claude Code owns. disable restores a recorded prior value and deletes only the keys that were absent, and says which it put back. Recorded on the FIRST enable only. A second enable overwriting it would replace the user's original with our own value, losing it exactly when it is needed. With no state file — enabled by an older abctl, or the record lost — disable falls back to removing the keys, which is what it always did and is better than leaving the proxy pointed at a Cortex someone is turning off. Also fixes a test of mine that could not fail. The present-CA half of TestClaudeCodeEnable_WarnsWhenCAMissing used strings.Replace with a count of 0, which replaces nothing, so the CA stayed missing and that branch was never exercised. Split into two tests that each set up the state they assert on, and mutation-checked the ownership test against the old behaviour. Two other live threads are already closed at this HEAD and are anchored to lines that still exist: esnible's `host == "::"` dead branch went away with the switch to net.SplitHostPort in 79f2f575, and the checksum fail-open — mrsabath's remaining must-fix — was fixed in 79f2f575 too, which postdates the 389ebf7e they reviewed. The fix goes further than the suggestion: one grep per archive so a miss names the file, plus the count assertion. mrsabath's approving note on LITE_BUILD_TAGS no longer applies either — that mechanism was withdrawn in d10039d5 in favour of #861, whose generator derives the list from the plugin files. Their non-blocking idea (assert each expected tag is present, not just non-empty) belongs on #861 now, and is a good one: a single dropped tag still fails open. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/cmd_claudecode.go | 103 ++++++++++++++++- authbridge/cmd/abctl/cmd_claudecode_test.go | 121 ++++++++++++++++++-- 2 files changed, 213 insertions(+), 11 deletions(-) diff --git a/authbridge/cmd/abctl/cmd_claudecode.go b/authbridge/cmd/abctl/cmd_claudecode.go index e073c403b..fdde2aaea 100644 --- a/authbridge/cmd/abctl/cmd_claudecode.go +++ b/authbridge/cmd/abctl/cmd_claudecode.go @@ -31,8 +31,51 @@ const ( envNoTelem = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC" settingsRel = ".claude/settings.json" cortexCfgRel = ".cortex/config.yaml" + // stateRel records what each managed key looked like BEFORE enable, so disable + // can put it back. Without it, disable deleted every managed key it found — + // including one the user had set themselves, which is indistinguishable by + // value (their CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 is byte-identical to + // ours). Kept outside ~/.claude so this command's bookkeeping never appears in + // a file Claude Code owns. + stateRel = ".cortex/claude-code-state.json" ) +// managedState is the ownership record. A nil entry means the key was absent +// before enable, so disable deletes it; a non-nil entry is the value to restore. +type managedState struct { + Settings string `json:"settings"` + Prior map[string]*string `json:"prior"` +} + +func readState(path string) *managedState { + b, err := os.ReadFile(path) //nolint:gosec // operator-supplied path + if err != nil { + return nil + } + var st managedState + if err := json.Unmarshal(b, &st); err != nil || st.Prior == nil { + return nil + } + return &st +} + +// writeState records ownership on the FIRST enable only. A second enable must not +// overwrite it with our own values, or the original would be lost exactly when it +// is needed. +func writeState(path string, st managedState) error { + if existing := readState(path); existing != nil && existing.Settings == st.Settings { + return nil + } + b, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o600) +} + // managedKeys is exactly what enable writes and disable removes. Nothing else in // the file is touched — notably not ANTHROPIC_BASE_URL or any auth token, which // commonly live in the same env block. @@ -94,12 +137,13 @@ func runClaudeCode(args []string, stdout, stderr io.Writer) int { if *cortexCfg == "" { *cortexCfg = filepath.Join(home, cortexCfgRel) } + statePath := filepath.Join(home, stateRel) switch action { case "enable": - return claudeCodeEnable(*settingsPath, *cortexCfg, *yes, stdout, stderr) + return claudeCodeEnable2(*settingsPath, *cortexCfg, statePath, *yes, stdout, stderr) case "disable": - return claudeCodeDisable(*settingsPath, *yes, stdout, stderr) + return claudeCodeDisable2(*settingsPath, statePath, *yes, stdout, stderr) case "status": return claudeCodeStatus(*settingsPath, stdout) default: @@ -152,7 +196,7 @@ func wanted(cortexCfgPath string) (map[string]string, error) { return out, nil } -func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stderr io.Writer) int { +func claudeCodeEnable2(settingsPath, cortexCfgPath, statePath string, yes bool, stdout, stderr io.Writer) int { want, err := wanted(cortexCfgPath) if err != nil { fmt.Fprintf(stderr, "abctl: %v\n", err) @@ -218,6 +262,23 @@ func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stde return exitDeclined } + // Record what was there before, so disable restores rather than deletes. + if statePath != "" { + st := managedState{Settings: settingsPath, Prior: map[string]*string{}} + for _, k := range managedKeys { + if v, ok := env[k]; ok { + vv := v + st.Prior[k] = &vv + } else { + st.Prior[k] = nil + } + } + if werr := writeState(statePath, st); werr != nil { + fmt.Fprintf(stderr, "abctl: could not record prior settings (%v); disable will delete\n"+ + " these keys rather than restore any you had set yourself\n", werr) + } + } + raw := envRaw(doc) for _, k := range managedKeys { raw[k] = want[k] @@ -230,7 +291,7 @@ func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stde return 0 } -func claudeCodeDisable(settingsPath string, yes bool, stdout, stderr io.Writer) int { +func claudeCodeDisable2(settingsPath, statePath string, yes bool, stdout, stderr io.Writer) int { doc, err := readSettings(settingsPath) if err != nil { fmt.Fprintf(stderr, "abctl: %v\n", err) @@ -252,8 +313,24 @@ func claudeCodeDisable(settingsPath string, yes bool, stdout, stderr io.Writer) fmt.Fprintln(stdout, "Not changed.") return exitDeclined } + st := readState(statePath) raw := envRaw(doc) + var restored []string for _, k := range present { + if st != nil && st.Settings == settingsPath { + if prior, recorded := st.Prior[k]; recorded { + if prior == nil { + delete(raw, k) + } else { + // The user had this set before enable; put their value back. + raw[k] = *prior + restored = append(restored, k) + } + continue + } + } + // No ownership record (enabled by an older abctl, or state lost): fall back + // to removing it, which is what this always did. delete(raw, k) } // Drop an env block we just emptied rather than leaving "env": {} behind. @@ -264,6 +341,12 @@ func claudeCodeDisable(settingsPath string, yes bool, stdout, stderr io.Writer) fmt.Fprintf(stderr, "abctl: %v\n", err) return 1 } + if len(restored) > 0 { + fmt.Fprintf(stdout, "\nRestored to the value(s) you had before: %s\n", strings.Join(restored, ", ")) + } + if statePath != "" { + _ = os.Remove(statePath) + } fmt.Fprintf(stdout, "\nDisabled. Claude Code no longer routes through Cortex.\n") return 0 } @@ -436,3 +519,15 @@ func confirmFrom(r io.Reader, stdout io.Writer) bool { } return false } + +// claudeCodeEnable and claudeCodeDisable keep the pre-ownership signatures for +// callers and tests that do not care about the state file. Passing an empty +// statePath disables ownership tracking, which is the historical behaviour: +// disable then deletes the managed keys rather than restoring any the user had. +func claudeCodeEnable(settingsPath, cortexCfgPath string, yes bool, stdout, stderr io.Writer) int { + return claudeCodeEnable2(settingsPath, cortexCfgPath, "", yes, stdout, stderr) +} + +func claudeCodeDisable(settingsPath string, yes bool, stdout, stderr io.Writer) int { + return claudeCodeDisable2(settingsPath, "", yes, stdout, stderr) +} diff --git a/authbridge/cmd/abctl/cmd_claudecode_test.go b/authbridge/cmd/abctl/cmd_claudecode_test.go index 396fd3b28..5feb0a6cd 100644 --- a/authbridge/cmd/abctl/cmd_claudecode_test.go +++ b/authbridge/cmd/abctl/cmd_claudecode_test.go @@ -366,22 +366,44 @@ func TestClaudeCodeEnable_WarnsWhenCAMissing(t *testing.T) { if !strings.Contains(out.String(), "does not exist yet") { t.Errorf("no warning about the missing CA file:\n%s", out.String()) } +} + +// TestClaudeCodeEnable_SilentWhenCAPresent is the other half. An earlier version +// of this test used strings.Replace with a count of 0, which replaces nothing, so +// the CA stayed missing and this branch was never exercised — the assertion +// existed but could not fail. +func TestClaudeCodeEnable_SilentWhenCAPresent(t *testing.T) { + settings, cfg := fixture(t, "{}") - // With the file present there should be no such note. + // Create the CA exactly where the config's ca_dir points. + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("first pass: %s", errb.String()) + } caPath := readEnv(t, settings)[envCACerts] + if caPath == "" { + t.Fatal("no CA path was written") + } if err := os.MkdirAll(filepath.Dir(caPath), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(caPath, []byte("-----BEGIN CERTIFICATE-----\n"), 0o600); err != nil { t.Fatal(err) } - settings2, cfg2 := fixture(t, "{}") - // Point the second fixture's ca_dir at the file we just made. - body, _ := os.ReadFile(cfg2) - _ = os.WriteFile(cfg2, []byte(strings.Replace(string(body), - filepath.Dir(strings.TrimSuffix(caPath, filepath.Base(caPath))), "", 0)), 0o600) + + // Re-run against a clean settings file, same config, now that the CA exists. + settings2 := filepath.Join(filepath.Dir(caPath), "..", "settings2.json") + if err := os.WriteFile(settings2, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } out.Reset() - _ = claudeCodeEnable(settings2, cfg2, true, &out, &errb) + errb.Reset() + if code := claudeCodeEnable(settings2, cfg, true, &out, &errb); code != 0 { + t.Fatalf("second pass: %s", errb.String()) + } + if strings.Contains(out.String(), "does not exist yet") { + t.Errorf("warned about a CA file that exists at %s:\n%s", caPath, out.String()) + } } // TestClaudeCodeEnable_HandlesIPv6ForwardProxy: strings.Cut split at the first @@ -483,3 +505,88 @@ func TestClaudeCodeDeclineUsesADistinctExitCode(t *testing.T) { t.Errorf("exitDeclined = %d collides with success, failure or usage", exitDeclined) } } + +// TestClaudeCodeDisable_RestoresAValueTheUserSetFirst is the ownership property. +// A user who already had CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 lost it on +// disable, because "1" is byte-identical to what we write and nothing recorded +// that it predated us. +func TestClaudeCodeDisable_RestoresAValueTheUserSetFirst(t *testing.T) { + settings, cfg := fixture(t, `{"env":{ + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1", + "ANTHROPIC_AUTH_TOKEN":"sk-x" + }}`) + state := filepath.Join(t.TempDir(), "claude-code-state.json") + + var out, errb bytes.Buffer + if code := claudeCodeEnable2(settings, cfg, state, true, &out, &errb); code != 0 { + t.Fatalf("enable: %s", errb.String()) + } + if code := claudeCodeDisable2(settings, state, true, &out, &errb); code != 0 { + t.Fatalf("disable: %s", errb.String()) + } + + env := readEnv(t, settings) + if got, ok := env[envNoTelem]; !ok || got != "1" { + t.Errorf("%s = %q present=%v; the user set this before enable and it must survive", + envNoTelem, got, ok) + } + // The keys we genuinely added are still removed. + for _, k := range []string{envProxy, envCACerts} { + if _, ok := env[k]; ok { + t.Errorf("%s survived disable although we added it", k) + } + } + if env["ANTHROPIC_AUTH_TOKEN"] != "sk-x" { + t.Error("unrelated entry lost") + } +} + +// TestClaudeCodeEnable_StateRecordedOnlyOnce: a second enable must not overwrite +// the ownership record with our own values, or the original is lost exactly when +// it is needed. +func TestClaudeCodeEnable_StateRecordedOnlyOnce(t *testing.T) { + settings, cfg := fixture(t, `{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1"}}`) + state := filepath.Join(t.TempDir(), "state.json") + + var out, errb bytes.Buffer + for i := 0; i < 3; i++ { + if code := claudeCodeEnable2(settings, cfg, state, true, &out, &errb); code != 0 { + t.Fatalf("enable %d: %s", i, errb.String()) + } + } + st := readState(state) + if st == nil { + t.Fatal("no state recorded") + } + prior, recorded := st.Prior[envNoTelem] + if !recorded || prior == nil || *prior != "1" { + t.Errorf("prior for %s = %v, want the user's original \"1\"", envNoTelem, prior) + } + // And the keys we added are recorded as absent-before. + if p, ok := st.Prior[envProxy]; !ok || p != nil { + t.Errorf("prior for %s = %v, want nil (absent before)", envProxy, p) + } +} + +// TestClaudeCodeDisable_NoStateFallsBackToRemoval: enabled by an older abctl, or +// the record was lost. Removing is what this always did, and is better than +// leaving the proxy pointed at a Cortex the user is trying to turn off. +func TestClaudeCodeDisable_NoStateFallsBackToRemoval(t *testing.T) { + settings, cfg := fixture(t, `{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-x"}}`) + var out, errb bytes.Buffer + if code := claudeCodeEnable(settings, cfg, true, &out, &errb); code != 0 { + t.Fatalf("enable: %s", errb.String()) + } + if code := claudeCodeDisable2(settings, filepath.Join(t.TempDir(), "absent.json"), true, &out, &errb); code != 0 { + t.Fatalf("disable: %s", errb.String()) + } + env := readEnv(t, settings) + for _, k := range managedKeys { + if _, ok := env[k]; ok { + t.Errorf("%s survived disable with no state file", k) + } + } + if env["ANTHROPIC_AUTH_TOKEN"] != "sk-x" { + t.Error("unrelated entry lost") + } +} From f7378c701a3ba9ac06d1a456e638d9d4561e6e02 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 4 Sep 2026 08:58:46 -0400 Subject: [PATCH 19/19] fix: A corrupt ownership record no longer loses a setting silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readState returned nil for both an absent record and a present-but-unreadable one, and disable treats nil as "enabled by an older abctl" and falls back to deleting every managed key. So a truncated or hand-mangled claude-code-state.json re-opened exactly the data loss the record was added to prevent, and did it silently — a corrupt record was indistinguishable from no record. Reproduced: the user's own CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 gone, stderr empty. readState now returns (nil, nil) only for a genuinely absent file and an error for anything it found but could not trust. Absent stays silent, because that is the normal older-install path and a warning on every disable would train people to ignore it. A read or parse failure warns, names the file and the parse error, and says the value is not recoverable from there — then proceeds, because the user asked for this off. writeState also refuses to overwrite an unreadable record: if it can be repaired by hand it is still the only copy of what the user had. Mutation-checked the warning test by forcing the error to nil, which makes it fail as it should. That was mrsabath's suggestion on the approving review, and it was right. Their other note — that the empty-list guard on the lite tag list cannot catch a single dropped tag, since exclude_plugin_* fails open — now belongs to #861, which owns that list; worth raising there. Two threads still shown as unresolved are already closed at this HEAD, both anchored to lines that moved: esnible's `host == "::"` dead branch went away with net.SplitHostPort in 79f2f575 (the code at the cited line is now the SplitHostPort call), and the checksum fail-open was fixed there too. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/cmd_claudecode.go | 42 ++++++++++--- authbridge/cmd/abctl/cmd_claudecode_test.go | 66 ++++++++++++++++++++- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/authbridge/cmd/abctl/cmd_claudecode.go b/authbridge/cmd/abctl/cmd_claudecode.go index fdde2aaea..76cf3fa74 100644 --- a/authbridge/cmd/abctl/cmd_claudecode.go +++ b/authbridge/cmd/abctl/cmd_claudecode.go @@ -47,23 +47,43 @@ type managedState struct { Prior map[string]*string `json:"prior"` } -func readState(path string) *managedState { +// readState distinguishes "no record" from "record unreadable". +// +// Collapsing them was a silent hole: disable treats a missing record as +// "enabled by an older abctl" and falls back to deleting every managed key, so a +// truncated or hand-mangled state file re-opened exactly the data loss the record +// exists to prevent — a corrupt record looked identical to no record. A nil +// state with a nil error means genuinely absent; a non-nil error means the record +// was there and could not be trusted, which the caller must say out loud. +func readState(path string) (*managedState, error) { b, err := os.ReadFile(path) //nolint:gosec // operator-supplied path if err != nil { - return nil + if os.IsNotExist(err) { + return nil, nil + } + return nil, err } var st managedState - if err := json.Unmarshal(b, &st); err != nil || st.Prior == nil { - return nil + if uerr := json.Unmarshal(b, &st); uerr != nil { + return nil, fmt.Errorf("%s is not valid JSON: %w", path, uerr) + } + if st.Prior == nil { + return nil, fmt.Errorf("%s has no prior-value record", path) } - return &st + return &st, nil } // writeState records ownership on the FIRST enable only. A second enable must not // overwrite it with our own values, or the original would be lost exactly when it // is needed. func writeState(path string, st managedState) error { - if existing := readState(path); existing != nil && existing.Settings == st.Settings { + // An unreadable existing record is not a reason to overwrite it: if it can be + // repaired by hand it is still the only copy of what the user had. + existing, err := readState(path) + if err != nil { + return fmt.Errorf("refusing to overwrite the existing record: %w", err) + } + if existing != nil && existing.Settings == st.Settings { return nil } b, err := json.MarshalIndent(st, "", " ") @@ -313,7 +333,15 @@ func claudeCodeDisable2(settingsPath, statePath string, yes bool, stdout, stderr fmt.Fprintln(stdout, "Not changed.") return exitDeclined } - st := readState(statePath) + st, sterr := readState(statePath) + if sterr != nil { + // Proceed — the user asked for this off — but say what is about to be lost. + // Silence here would repeat the bug the record was added to fix. + fmt.Fprintf(stderr, "abctl: cannot read the record of what you had before enabling (%v).\n"+ + " Falling back to removing these keys outright. If you had set any of them\n"+ + " yourself before running enable, that value is not recoverable from here —\n"+ + " check %s afterwards.\n\n", sterr, settingsPath) + } raw := envRaw(doc) var restored []string for _, k := range present { diff --git a/authbridge/cmd/abctl/cmd_claudecode_test.go b/authbridge/cmd/abctl/cmd_claudecode_test.go index 5feb0a6cd..7bd16a651 100644 --- a/authbridge/cmd/abctl/cmd_claudecode_test.go +++ b/authbridge/cmd/abctl/cmd_claudecode_test.go @@ -554,9 +554,9 @@ func TestClaudeCodeEnable_StateRecordedOnlyOnce(t *testing.T) { t.Fatalf("enable %d: %s", i, errb.String()) } } - st := readState(state) - if st == nil { - t.Fatal("no state recorded") + st, err := readState(state) + if err != nil || st == nil { + t.Fatalf("no state recorded: %v", err) } prior, recorded := st.Prior[envNoTelem] if !recorded || prior == nil || *prior != "1" { @@ -590,3 +590,63 @@ func TestClaudeCodeDisable_NoStateFallsBackToRemoval(t *testing.T) { t.Error("unrelated entry lost") } } + +// TestClaudeCodeDisable_WarnsOnCorruptState: a truncated record looked identical +// to no record, so disable silently fell back to deleting every managed key — +// re-opening the exact data loss the record was added to prevent. It still +// proceeds (the user asked for this off) but must say what is being lost. +func TestClaudeCodeDisable_WarnsOnCorruptState(t *testing.T) { + settings, cfg := fixture(t, `{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1"}}`) + state := filepath.Join(t.TempDir(), "state.json") + + var out, errb bytes.Buffer + if code := claudeCodeEnable2(settings, cfg, state, true, &out, &errb); code != 0 { + t.Fatalf("enable: %s", errb.String()) + } + // A partial write, a disk-full, a hand edit. + if err := os.WriteFile(state, []byte(`{"settings":"`), 0o600); err != nil { + t.Fatal(err) + } + + out.Reset() + errb.Reset() + if code := claudeCodeDisable2(settings, state, true, &out, &errb); code != 0 { + t.Fatalf("disable: %s", errb.String()) + } + if !strings.Contains(errb.String(), "cannot read the record") { + t.Errorf("corrupt state produced no warning:\nstderr=%q", errb.String()) + } + if !strings.Contains(errb.String(), "not recoverable") { + t.Errorf("warning does not say what is lost:\nstderr=%q", errb.String()) + } +} + +// TestReadState_AbsentIsNotAnError: the silent fallback is correct for a machine +// that enabled with an older abctl, and must stay silent — a warning on every +// disable would be noise that trains people to ignore it. +func TestReadState_AbsentIsNotAnError(t *testing.T) { + st, err := readState(filepath.Join(t.TempDir(), "nope.json")) + if err != nil { + t.Errorf("absent state reported as an error: %v", err) + } + if st != nil { + t.Error("absent state returned a record") + } +} + +// TestWriteState_RefusesToOverwriteAnUnreadableRecord: if it can be repaired by +// hand it is still the only copy of what the user had. +func TestWriteState_RefusesToOverwriteAnUnreadableRecord(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(`{"settings":"`), 0o600); err != nil { + t.Fatal(err) + } + err := writeState(path, managedState{Settings: "/x/settings.json", Prior: map[string]*string{}}) + if err == nil { + t.Fatal("overwrote an unreadable record") + } + b, _ := os.ReadFile(path) + if string(b) != `{"settings":"` { + t.Errorf("the unreadable record was modified: %q", b) + } +}