diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f9d55baed..a3bf29fe7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -76,6 +76,19 @@ 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 + # 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 94dcbebec..dbd2fa68e 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,12 @@ 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/ + +# 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 3c2093f66..16e147074 100644 --- a/README.md +++ b/README.md @@ -10,41 +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 the demo** (macOS/Linux). Downloads two small binaries 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-demo.sh | sh + curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install.sh \ + | sh -s -- --claude-code ``` -2. **Open the live viewer** in another terminal: +2. **Open the viewer** in another terminal: ```sh - abctl --endpoint http://localhost:47601 + abctl ``` -3. **Send an agent's traffic through it** — e.g. Claude Code, from the directory where you started the demo: +3. **Run Claude Code:** ```sh - HTTPS_PROXY=http://localhost:47600 \ - NODE_EXTRA_CA_CERTS="$PWD/cortex-ca/ca.crt" \ - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ - claude + claude ``` - Its calls stream into `abctl`, decrypted and parsed. +Its calls stream into `abctl`. Cortex only reads them — nothing is rewritten. -## Cut Claude Code token cost on your laptop +Stop it with `pkill -f authbridge-proxy`. Undo step 1 with +`abctl claude-code disable`. -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. Four steps, about two minutes: -**[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**. +**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/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/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 7e366c81f..32485172e 100644 --- a/authbridge/cmd/README.md +++ b/authbridge/cmd/README.md @@ -44,13 +44,22 @@ 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. +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` / `INBOUND_TRANSPARENT_PORT`. A mismatch redirects traffic to a dead port. diff --git a/authbridge/cmd/abctl/cmd_claudecode.go b/authbridge/cmd/abctl/cmd_claudecode.go new file mode 100644 index 000000000..76cf3fa74 --- /dev/null +++ b/authbridge/cmd/abctl/cmd_claudecode.go @@ -0,0 +1,561 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net" + "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. +// 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" + 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"` +} + +// 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 { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var st managedState + 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, 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 { + // 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, "", " ") + 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. +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 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. + +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) + --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) + } + statePath := filepath.Join(home, stateRel) + + switch action { + case "enable": + return claudeCodeEnable2(*settingsPath, *cortexCfg, statePath, *yes, stdout, stderr) + case "disable": + return claudeCodeDisable2(*settingsPath, statePath, *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 + // 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{ + // 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 != "" { + ca, aerr := filepath.Abs(filepath.Join(cfg.TLSBridge.CADir, "ca.crt")) + if aerr != nil { + return nil, aerr + } + out[envCACerts] = ca + } + return out, nil +} + +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) + 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 := 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 + // 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 + } + } + + // 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] { + 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 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] + } + 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 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) + return 1 + } + env := envStrings(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 exitDeclined + } + 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 { + 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. + if len(raw) == 0 { + delete(doc, "env") + } + if err := writeSettings(settingsPath, doc); err != nil { + 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 +} + +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 := envStrings(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) + } + // 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 +} + +// 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 { + 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') + // 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 + 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 { + 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 +} + +// 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 new file mode 100644 index 000000000..7bd16a651 --- /dev/null +++ b/authbridge/cmd/abctl/cmd_claudecode_test.go @@ -0,0 +1,652 @@ +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()) + } + } +} + +// 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()) + } +} + +// 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, "{}") + + // 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) + } + + // 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() + 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 +// 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) + } +} + +// 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, 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" { + 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") + } +} + +// 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) + } +} diff --git a/authbridge/cmd/abctl/cmd_tools.go b/authbridge/cmd/abctl/cmd_tools.go index 784a92a4a..4eb31f98b 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 @@ -26,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) @@ -35,16 +43,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 +82,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,13 +92,51 @@ 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()) 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()) + // 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 new file mode 100644 index 000000000..fd64b4f8f --- /dev/null +++ b/authbridge/cmd/abctl/cmd_tools_test.go @@ -0,0 +1,236 @@ +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") + } +} + +// 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/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/abctl/local_endpoint.go b/authbridge/cmd/abctl/local_endpoint.go new file mode 100644 index 000000000..0f38c4076 --- /dev/null +++ b/authbridge/cmd/abctl/local_endpoint.go @@ -0,0 +1,85 @@ +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 "" + } + // 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", "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://" + net.JoinHostPort(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() + // 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 +// 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..e5691d893 --- /dev/null +++ b/authbridge/cmd/abctl/local_endpoint_test.go @@ -0,0 +1,142 @@ +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"}, + // 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 { + 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_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 8565e1e83..c4064a8ee 100644 --- a/authbridge/cmd/abctl/main.go +++ b/authbridge/cmd/abctl/main.go @@ -33,14 +33,16 @@ 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) } } 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() @@ -55,11 +57,34 @@ 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() + localUp := localSessionAPIUp(local) + if *endpoint == "" && localUp { + *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) } } @@ -73,7 +98,14 @@ func main() { cancel() }() + // 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/cmd/abctl/toolscan/patch.go b/authbridge/cmd/abctl/toolscan/patch.go index 7aca6e019..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 ./cortex-ca/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 --demo writes cortex-ca/demo.yaml into the directory it is started from,\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/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/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/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-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-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/demo.go deleted file mode 100644 index 3a89d1afd..000000000 --- a/authbridge/cmd/authbridge-proxy/demo.go +++ /dev/null @@ -1,101 +0,0 @@ -package main - -import ( - "errors" - "log/slog" - "os" - "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" - -// 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 -// 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-demo.sh). The -// enforce-redirect transparent listener isn't used here (no iptables) and -// main.go skips starting it under --demo. -// -// 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 -# 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 -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: "` + caDir + `" - generate_ca: true -pipeline: - outbound: - plugins: - - name: inference-parser - - name: mcp-parser - - name: a2a-parser - # tool-prune drops unused tool definitions from the outbound manifest. - # The empty remove list is the off switch: with nothing named it does - # nothing at all. Fill it in and it takes effect immediately -- - # abctl tools scan --write - # -- and the config is hot-reloaded, so no restart. - # - # Watch the Metrics section of abctl's plugin pane for what it saved. If - # you ever suspect the plugin of breaking a request, set - # on_error: observe here: it then counts what it *would* remove while - # leaving every byte on the wire untouched, which settles the question - # without unconfiguring anything. - # - # Keep it last: it rewrites the request body, and body readers must - # precede the mutator so they see the original bytes. - - name: tool-prune - on_error: enforce - config: - remove: [] -` -} - -// 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 + -// 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 -// those edits. Delete the file to regenerate the preset. -func writeDemoConfig(caDir string) (string, error) { - if err := os.MkdirAll(caDir, 0o755); err != nil { - return "", err - } - path := filepath.Join(caDir, "demo.yaml") - 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") - return path, nil - } else if !errors.Is(err, os.ErrNotExist) { - return "", err - } - if err := os.WriteFile(path, []byte(demoConfigYAML(caDir)), 0o644); err != nil { - return "", err - } - return path, nil -} diff --git a/authbridge/cmd/authbridge-proxy/local.go b/authbridge/cmd/authbridge-proxy/local.go new file mode 100644 index 000000000..9b9565747 --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/local.go @@ -0,0 +1,165 @@ +package main + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" +) + +// 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" + // 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 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 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. +// +// 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 "", 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), nil +} + +// 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. +// +// 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); --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. +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 +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 + # --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: + mode: enabled + ca_dir: "` + caDir + `" + generate_ca: true +pipeline: + outbound: + plugins: + - name: inference-parser + - name: mcp-parser + - name: a2a-parser + # tool-prune drops unused tool definitions from the outbound manifest. + # The empty remove list is the off switch: with nothing named it does + # nothing at all. Fill it in and it takes effect immediately -- + # abctl tools scan --write + # -- and the config is hot-reloaded, so no restart. + # + # Watch the Metrics section of abctl's plugin pane for what it saved. If + # you ever suspect the plugin of breaking a request, set + # on_error: observe here: it then counts what it *would* remove while + # leaving every byte on the wire untouched, which settles the question + # without unconfiguring anything. + # + # Keep it last: it rewrites the request body, and body readers must + # precede the mutator so they see the original bytes. + - name: tool-prune + on_error: enforce + config: + remove: [] +` +} + +// 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 --local start which then failed on a port clash silently destroyed +// those edits. Delete the file to regenerate the preset. +// 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 + } + // 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)", + "path", path, "hint", "delete it to regenerate the built-in preset") + return path, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + 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 84% rename from authbridge/cmd/authbridge-proxy/demo_test.go rename to authbridge/cmd/authbridge-proxy/local_test.go index 31eae9df0..17d1192eb 100644 --- a/authbridge/cmd/authbridge-proxy/demo_test.go +++ b/authbridge/cmd/authbridge-proxy/local_test.go @@ -10,19 +10,20 @@ 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 in cortexDir 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 -// 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 := writeDemoConfig(caDir) + p, err := writeBuiltinConfig(cortexDir, 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) + if filepath.Dir(p) != cortexDir { + t.Errorf("config written to %q, want inside %q", p, cortexDir) } cfg, err := config.Load(p) @@ -47,7 +48,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 +106,12 @@ 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) + 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 := writeDemoConfig(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 3788602da..cae171ffa 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 ./"+demoCADirDefault) + "CA directory for --local (auto-generated); defaults to ~/"+cortexDirName+"/"+caDirName) flag.Parse() if *showVersion { @@ -136,34 +141,59 @@ 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") + } + 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 + // 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 = demoCADirDefault // relative to cwd — no absolute path baked in + dir = filepath.Join(cortexDir, caDirName) } - abs, aerr := filepath.Abs(dir) + absCA, 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) + 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("--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", - "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 --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 @@ -402,9 +432,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) @@ -456,8 +486,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) } } @@ -498,7 +528,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) } @@ -559,10 +589,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 4013ac1bf..c89b79353 100644 --- a/authbridge/demos/session-budget/hitl-with-claude-code.md +++ b/authbridge/demos/session-budget/hitl-with-claude-code.md @@ -7,11 +7,11 @@ 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. -[qs]: ../../../README.md#quick-start-local-no-kubernetes +[qs]: ../../../README.md#quick-start--claude-code-on-your-laptop ## Prerequisites @@ -62,11 +62,13 @@ 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-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 @@ -183,7 +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. -- **`--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/`. +- **`--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/laptop-token-savings.md b/authbridge/docs/laptop-token-savings.md index b88f59a49..be198940e 100644 --- a/authbridge/docs/laptop-token-savings.md +++ b/authbridge/docs/laptop-token-savings.md @@ -1,143 +1,107 @@ # 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 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. -Four steps, about two minutes. +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. -## 1. Install the binaries +## Turn it on ```sh -curl -fsSL https://raw.githubusercontent.com/rossoctl/cortex/main/authbridge/install-demo.sh \ - | AUTHBRIDGE_INSTALL_ONLY=1 sh +abctl tools scan --write ~/.cortex/config.yaml ``` -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. +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. -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. +`--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. -## 2. Write a config +Two guards on what it proposes: -```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 -``` +- 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. -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. +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. -## 3. Fill in the prune list and start +## What to expect -```sh -authbridge-proxy --config ~/.cortex/config.yaml & -abctl tools scan --write ~/.cortex/config.yaml -``` +**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: -`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. +- **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 + 4–6%. +- **How far into the conversation you are.** The removed bytes are a fixed size, + so their share of a growing prompt falls — 13% early in that session, 4% by the + end. -**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: +A single early turn can read ~24%, which is why a figure quoted from one request is +not the number to plan with. -- **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 `~`. +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. -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. +## Reading the dollar figure -## 4. Point Claude Code at it +`$ 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. -```sh -HTTPS_PROXY=http://localhost:47600 \ - NODE_EXTRA_CA_CERTS="$HOME/.cortex/ca/ca.crt" \ - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ - claude -``` +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. -Then watch what it saved: +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: -```sh -abctl --endpoint http://localhost:47601 +```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 ``` -Plugin pane → `tool-prune` → `Metrics`. - -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: - -- **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 - 4–6%. -- **How far into the conversation you are.** The removed bytes are a fixed size, - so their share of a growing prompt falls — 13% early in that session, 4% by the - end. +Full reference, including how to measure your own from a gateway's cost headers: +[`tool-prune-plugin.md`](./tool-prune-plugin.md#costing-it). -A single early turn can read ~24%, which is why a figure quoted from one request -is not the number to plan with. +## Keeping the prune list honest -Stop it with `pkill -f 'authbridge-proxy --config'`. +**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: -## Seeing the saving in money +- **Re-run it occasionally** (monthly, or when your work changes shape): -`$ 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. + ```sh + abctl tools scan --write ~/.cortex/config.yaml + ``` -**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 proxy hot-reloads; no restart. `--days N` / `--all` set the window (see + above) and `--keep Name,Name` protects specific tools by name. -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. +- **If a tool goes missing, delete its name from `remove:`** in + `~/.cortex/config.yaml`. It comes back without a restart. -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. +- **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 `~` instead of `−`. ## What this does and does not change @@ -149,6 +113,17 @@ 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 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. +- **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 (`pkill -f authbridge-proxy`). 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. diff --git a/authbridge/docs/tool-prune-plugin.md b/authbridge/docs/tool-prune-plugin.md index 72f0fab2c..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-ca/demo.yaml +abctl tools scan --write ~/.cortex/config.yaml ``` The config is hot-reloaded, so no restart. A reload does rebuild the plugin and @@ -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 diff --git a/authbridge/install-demo.sh b/authbridge/install-demo.sh index 3c87862b5..aed290188 100755 --- a/authbridge/install-demo.sh +++ b/authbridge/install-demo.sh @@ -1,219 +1,28 @@ #!/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. -# -# 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 +# 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 -REPO="rossoctl/cortex" -BIN_DIR="${HOME}/.local/bin" - -info() { printf '%s\n' "$*"; } -warn() { printf 'warning: %s\n' "$*" >&2; } -die() { printf 'error: %s\n' "$*" >&2; exit 1; } - -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 [ "${AUTHBRIDGE_INSTALL_ONLY:-}" != "1" ]; 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." - fi - done -fi - -# --- 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 - -# --- report --- -proxy="${BIN_DIR}/authbridge-proxy" -ca_dir="$(pwd)/cortex-ca" # matches demoCADirDefault 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 [ "${AUTHBRIDGE_INSTALL_ONLY:-}" = "1" ]; then - info "" - info "Install-only mode. Start the demo with: ${proxy_cmd} --demo" - exit 0 -fi - -# --- start in the background, then wait until it's actually listening --- -info "" -info "Starting the demo in the background..." -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..d89fcc849 --- /dev/null +++ b/authbridge/install.sh @@ -0,0 +1,439 @@ +#!/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. +# +# 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 +# --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 +# 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. +# +# 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 +# failure mode. The env vars below still work. +# +# 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, 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" +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; } + +# 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 + --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 + +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 +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 ;; + -h | --help) + usage + exit 0 + ;; + *) 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. +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 +# 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. +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 + +# stop_previous_cortex stops a Cortex a previous run of this script started, if +# one is still holding the ports the next one needs. +# +# 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() { + 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 + # 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-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." + 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 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" +} + +# --- preflight: fail early (before downloading) if a listener port is taken --- +if [ "$MODE" = "local" ]; then + # 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 + 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..." +# 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. +: > "${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 --- +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}/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" ;; +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" + 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" +# 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 & +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 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. +# +# 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 [ -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 "" + 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 " 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 "" +# 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 ""