{"id":"de3fe48c-d744-49ba-b473-9d967a08d670","slug":"ccproxy","kind":"mcp","name":"ccproxy","summary":"Single-binary Go server that wraps the Claude Code CLI behind an OpenAI-compatible API (/v1/chat/completions, /v1/models). Lets n8n, LibreChat, Open WebUI and any other OpenAI-speaking client drive Cl","owner":"GurYN","repoUrl":"https://github.com/GurYN/ccproxy","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-04-22T08:30:52.000Z","addedAt":"2026-10-21T08:45:03.656Z","logoUrl":"https://avatars.githubusercontent.com/u/577308?u=8046e07c7871f5f656fb61af3db1de39e3de54bd&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["developer-tools","communication","cloud-infra"],"tags":["claude","claude-code","go","golang","llm","openai-api","proxy","self-hosted"],"runtime":"go","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-23T18:33:17.310Z","sample":false,"url":"https://conduits.dev/tools/ccproxy","language":"Go","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# ccproxy\n\nOpenAI-compatible HTTP proxy in front of a locally-installed Claude Code CLI. Any client that speaks `POST /v1/chat/completions` (n8n, LibreChat, Open WebUI, the `openai` SDKs) can drive Claude Code over the network. Callers inherit the host's MCP servers, skills, subagents, and Bash tool access: that's the whole product.\n\n## Status\n\n**Actively in development.** ccproxy is pre-1.0: the wire surface (headers, config schema, scopes) may still shift between minor versions. Expect breaking changes; pin a specific version (`go install …@v0.x.y`) if you deploy it, and read the commit log before upgrading. Bug reports and PRs welcome.\n\n## Requirements\n\n- Go 1.26+ (only for `go install` or building from source)\n- A working `claude` binary on `$PATH` (or set `claude_binary:` in `config.yaml`)\n- macOS or Linux\n\n## Install\n\nTwo **feature profiles** are available, regardless of how you install:\n\n- **A. Server only** (no bridge): the standard install. Server runs chat against ephemeral or pre-configured workspaces. No system dependencies.\n- **B. Server + bridge**: adds the optional [Bridge](#bridge) feature, where Claude Code operates on a directory mounted from a user's laptop. Requires a system FUSE library on the **server** host (the `bridge_fuse` build tag links it in) and a separate daemon binary on the **user** side.\n\nAnd two **installation methods**:\n\n- **Install from Go package registry** (`go install`): the fastest path. Fetches a tagged release from GitHub and drops the binary into `$GOBIN` (or `$(go env GOPATH)/bin`). Make sure that directory is on your `$PATH`. Pin a specific version with `@v0.2.0`, or track main with `@main`.\n- **Install from source**: clone the repo and `go build`. Use this when you're hacking on the code or you need a build flag the package install doesn't take.\n\n### Install from Go package registry\n\n#### A. Server only\n\nSingle command:\n\n```sh\ngo install github.com/guryn/ccproxy/cmd/ccproxy@latest\nccproxy version\n```\n\nYou can run `ccproxy serve` and use every feature except Bridge.\n\n#### B. Server + bridge\n\nTwo binaries to install on two different hosts.\n\n**On the server host** (the machine that runs `ccproxy serve`):\n\n1. Install a system FUSE library:\n   - macOS: install [FUSE-T](https://www.fuse-t.org/) (recommended, no kext) or `brew install --cask macfuse`.\n   - Linux: `sudo apt install libfuse-dev` (Debian/Ubuntu) or `sudo dnf install fuse-devel` (Fedora).\n2. Install the server binary **with the `bridge_fuse` build tag** so it links FUSE:\n   ```sh\n   go install -tags bridge_fuse github.com/guryn/ccproxy/cmd/ccproxy@latest\n   ccproxy version\n   ```\n\n**On each user's laptop** (the machine whose directory you want Claude Code to edit):\n\n```sh\ngo install github.com/guryn/ccproxy/cmd/ccproxy-bridge@latest\nccproxy-bridge --help\n```\n\nThe daemon binary never links FUSE; no system dependency, no build tag.\n\nContinue with [Bridge](#bridge) for setup (token minting, daemon flags, the resolution chain).\n\n### Install from source\n\n```sh\ngit clone https://github.com/guryn/ccproxy.git\ncd ccproxy/sources            # Go module root\n```\n\n#### A. Server only\n\n```sh\ngo build -o ccproxy ./cmd/ccproxy\n./ccproxy version\n```\n\n#### B. Server + bridge\n\nInstall the FUSE library first (same packages as the `go install` path above), then:\n\n```sh\n# server with bridge support\ngo build -tags bridge_fuse -o ccproxy ./cmd/ccproxy\n\n# bridge daemon (no tag, no FUSE dependency)\ngo build -o ccproxy-bridge ./cmd/ccproxy-bridge\n```\n\n## Quick start\n\nLocal dev loop, running from a cloned source tree:\n\n```sh\n# 1. set up env (defaults are dev-friendly)\ncp .env.example .env\n\n# 2. boot: first start auto-mints a bearer and prints it in a banner.\n#    Copy the `ccp_...` value from the startup log; it is not recoverable.\nccproxy serve\n```\n\nWant a token with wider scopes (e.g. for session-persistent dev)?\nRun `ccproxy token create --name dev --scopes 'chat,session:persistent,workspace:*'`\nafter first boot and rotate/revoke the auto-minted one.\n\n`.env` is auto-loaded from CWD via `joho/godotenv`. Existing env vars always win.\n\nIf you're hacking on the code instead of running an installed binary, swap `ccproxy` for `go run ./cmd/ccproxy` in the commands above.\n\n### Running an installed binary\n\n`go install` drops `ccproxy` into `$GOBIN` but ships no config files. You pick the paths. ccproxy resolves `config.yaml` in this order: `--config <path>` → `$CCPROXY_CONFIG` → `$CCPROXY_STATE/config.yaml` → `./ccproxy.yaml`. State (`tokens.db`, `captures/`) lives in `$CCPROXY_STATE` (default `/var/lib/ccproxy`).\n\n**Personal / single-user setup.** Create a state dir, drop a minimal `config.yaml` in it, export the relevant env vars in your shell rc (`~/.zshrc`, `~/.bashrc`):\n\n```sh\nmkdir -p ~/.config/ccproxy\n\ncat > ~/.config/ccproxy/config.yaml <<'EOF'\nallow_passthrough_models: true\ndefault_claude_model: sonnet\nEOF\n\n# --- add to your shell rc ---\nexport CCPROXY_STATE=~/.config/ccproxy         # required: where tokens.db + captures/ live\n# export CCPROXY_CONFIG=~/.config/ccproxy/config.yaml   # optional: picked up from $CCPROXY_STATE by default\nexport CCPROXY_LISTEN=127.0.0.1:4141           # optional: default is :4141 (all interfaces)\n# export CCPROXY_METRICS_LISTEN=127.0.0.1:9101 # optional: enable /metrics on a separate port\n# export CCPROXY_LOG_FORMAT=text               # optional: force text logs even when piped\n# export CCPROXY_TOKEN=ccp_...                 # optional first-boot seed; leave empty to auto-mint\n\nccproxy serve                                   # banner prints the first bearer\n```\n\nThat's enough for passthrough-only use (`model: \"sonnet\"` on the wire). Add workspaces and aliases later: see [`deploy/config.yaml.example`](deploy/config.yaml.example). Full env var reference is in the [Configuration](#configuration) section below.\n\n**Server / systemd setup:** split config (declarative) from state (mutable):\n\n```sh\nsudo useradd --system --home /var/lib/ccproxy --shell /usr/sbin/nologin ccproxy\nsudo mkdir -p /etc/ccproxy /var/lib/ccproxy\nsudo chown ccproxy:ccproxy /var/lib/ccproxy\nsudo cp config.yaml /etc/ccproxy/config.yaml\n```\n\nThen point the systemd unit at both. The template in `deploy/ccproxy.service` already uses `CCPROXY_STATE=/var/lib/ccproxy` and `CCPROXY_CONFIG=/etc/ccproxy/config.yaml`, plus an optional `EnvironmentFile=-/etc/ccproxy/env` for deploy-time overrides (listen addr, metrics listener). On first `systemctl start`, grep the journal for the banner:\n\n```sh\njournalctl -u ccproxy | grep -A5 \"auto-minted\"\n```\n\n**No `.env` in production.** `.env` is a dev-loop convenience: it's only read from CWD when you run `ccproxy serve` interactively. Under systemd, use `Environment=` / `EnvironmentFile=`; under Docker, use `environment:` in compose.\n\n## Module layout\n\nEverything below lives under `sources/` in the repo (the Go module root).\n\n```\nsources/\n├── cmd/ccproxy/           # CLI entrypoint. Subcommands: serve | token | version\n├── cmd/ccproxy-bridge/    # User-side daemon that exposes a local repo over an outbound websocket\n├── internal/\n│   ├── auth/              # SQLite-backed token store, argon2id, scopes, in-memory cache\n│   ├── bridge/            # Bridge protocol, websocket transport, principal registry, FUSE mount (build tag: bridge_fuse)\n│   ├── bridgeclient/      # Daemon-side library: RPC handlers, ignore list, audit log, TTY confirm, fsnotify watcher\n│   ├── claude/            # `claude -p --output-format stream-json` subprocess wrapper\n│   ├── config/            # YAML loader (config.yaml schema + deploy-time env overrides)\n│   ├── obs/               # Prometheus metrics + instrumentation middleware\n│   ├── openai/            # OpenAI request/response/SSE types; knows nothing about Claude\n│   ├── ratelimit/         # per-token rpm bucket + persistent daily token counter\n│   ├── server/            # HTTP wiring, middleware, debug capture, graceful shutdown, /bridge route\n│   ├── session/           # persistent session registry, TTL eviction, --resume plumbing\n│   ├── translate/         # the only package that bridges OpenAI ↔ Claude. Verbosity modes live here.\n│   └── workspace/         # resolution chain + ephemeral dir lifecycle\n├── tests/integration/     # //go:build integration (real `claude` binary required)\n├── deploy/                # systemd unit, compose example, config template\n├── Dockerfile             # container image; build from this dir\n├── Makefile               # test / test-race / test-integration / build / release\n└── LICENSE\n```\n\n**Boundary rule:** `openai/` and `claude/` know nothing about each other. `translate/` is the only bridge, which keeps the door open for non-Claude backends without speculative abstraction.\n\n## Common commands\n\n```sh\nmake build                               # compile everything\nmake vet                                 # static checks\nmake test                                # unit test suite\nmake test-race                           # with race detector\nmake test-integration                    # real `claude` binary required\nmake release                             # static, stripped, trimpath release binary\n\n# or the raw equivalents:\ngo test ./internal/server/... -v         # one package, verbose\ngo run ./cmd/ccproxy version             # print build info\ngo run ./cmd/ccproxy serve --help        # serve flag list\ngo run ./cmd/ccproxy token list          # admin\n```\n\nThe release binary is a single static file; no CGO (the SQLite driver is `modernc.org/sqlite`).\n\n## CLI subcommands\n\n```\nccproxy serve [--config path] [--env-file path]\nccproxy token create --name <s> [--scopes chat,workspace:myproject] [--ttl 90d] [--rpm N] [--tpd N] [--default-verbosity verbose] [--debug-capture] [--principal alice] [--bridge]\nccproxy token list\nccproxy token revoke <id-or-name>\nccproxy token rotate <id-or-name>\nccproxy token update <id-or-name> [--debug-capture on|off]\nccproxy version\n\n# User-side bridge daemon (separate binary)\nccproxy-bridge --server wss://ccproxy.lan/bridge --root ~/code/myrepo \\\n               [--allow-write] [--allow-exec] [--watch] [--audit ~/.cache/ccproxy-bridge/audit.log] [--confirm]\n```\n\n## HTTP API\n\n| Method | Path | Auth | Notes |\n|---|---|---|---|\n| `POST` | `/v1/chat/completions` | yes | Streaming + non-streaming. OpenAI-compatible. |\n| `GET`  | `/v1/models` | yes | Lists model aliases from config. |\n| `GET`  | `/bridge` | yes (`bridge:connect` scope) | Websocket upgrade for the `ccproxy-bridge` daemon. |\n| `GET`  | `/healthz` | no | Reports cached `claude --version`. |\n| `GET`  | `/readyz` | no | 200 if `claude --version` succeeded at boot, 503 otherwise. |\n| `GET`  | `/metrics` | no | **Separate listener**, only enabled when `CCPROXY_METRICS_LISTEN` is set. Do not expose publicly. |\n\n### Request body\n\nStandard OpenAI shape, plus ccproxy extensions:\n\n```jsonc\n{\n  \"model\": \"claude-code\",                 // required (see /v1/models)\n  \"messages\": [\n    {\"role\": \"system\",    \"content\": \"be terse\"},\n    {\"role\": \"user\",      \"content\": \"say pong\"}\n  ],\n  \"stream\": true,                          // optional\n  \"session_id\": \"abc-123\",                 // optional ccproxy extension (see X-CC-Session)\n  \"reasoning_effort\": \"high\"               // optional: low | medium | high | xhigh | max\n}\n```\n\n`temperature`, `top_p`, `max_tokens`, `tools` are accepted but ignored in v1; surfaced via the `X-CC-Ignored-Params` response header.\n\n### Headers\n\n| Header | Direction | Purpose |\n|---|---|---|\n| `Authorization: Bearer <token>` | in | Required on `/v1/*`. Token format: `ccp_<8hex>_<24hex>`. |\n| `X-CC-Session: <id>` | in | Pin the request to a persistent session. Requires `session:persistent` scope. |\n| `X-CC-Workspace: <name>` | in | Target a configured workspace. Requires `workspace:<name>` scope (or `workspace:*`). |\n| `X-CC-Verbosity: text-only\\|verbose\\|narrated` | in | Stream detail level. Falls back to per-token default, then config `default_verbosity`. |\n| `X-CC-Effort: low\\|medium\\|high\\|xhigh\\|max` | in | Same as request body `reasoning_effort`; header wins. |\n| `X-CC-Claude-Model: <name>` | in | Pick the underlying Claude model (`opus\\|sonnet\\|haiku` or a full id). Overrides per-alias and config defaults. |\n| `X-CC-Require-Bridge: true` | in | Fail with `412 bridge_unavailable` if the resolution chain would fall through to ephemeral but no bridge is connected for the token's principal. |\n| `X-Request-Id` | in/out | Echoed; generated if absent. Threaded into logs. |\n| `X-CC-Ignored-Params` | out | Comma list of accepted-but-ignored OpenAI fields. |\n\n### Verbosity modes\n\n- **`text-only`** (default): only assistant text reaches the client. Tool use happens invisibly.\n- **`verbose`**: tool calls surface as `delta.tool_calls`; tool results as synthetic `role:\"tool\"` chunks; `thinking` blocks prefixed with `[thinking]`.\n- **`narrated`**: tool calls render as inline `🔧 toolname(args)` lines with `↳ result` follow-ups. Best for human-in-the-loop chat UIs.\n\n### Reasoning effort\n\nMap of OpenAI `reasoning_effort` → `claude --effort`:\n\n| `reasoning_effort` | passed to claude | meaning |\n|---|---|---|\n| `low` | `--effort low` | cheap, fast |\n| `medium` | `--effort medium` | default-ish |\n| `high` | `--effort high` | careful |\n| `xhigh` | `--effort xhigh` | extra careful (Claude extension) |\n| `max` | `--effort max` | maximum (Claude extension) |\n| (omitted) | (nothing) | leave it to claude's default |\n\nResolution order (first non-empty wins): `X-CC-Effort` header → request body `reasoning_effort` → model alias `effort:` → top-level `default_effort:` → unset.\n\n### Underlying Claude model\n\nThe `model` field accepts three kinds of value, in this lookup order:\n\n1. **A configured alias** (declared under `models:` in `config.yaml`): gets you a workspace binding, system prompt, and per-alias effort/claude_model defaults. Best for stable client config.\n2. **A bare family name**: `opus`, `sonnet`, `haiku`. Resolves through `model_versions:` in config so ops can pin which exact version each name means.\n3. **A full Claude id**: `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5`. Passed straight to `claude --model`.\n\nExample config:\n\n```yaml\nallow_passthrough_models: true   # default; set false for strict allowlist mode\ndefault_claude_model: sonnet     # used when an alias doesn't set claude_model\nmodel_versions:\n  opus:   claude-opus-4-7\n  sonnet: claude-sonnet-4-6\n  haiku:  claude-haiku-4-5\nmodels:\n  - id: claude-code              # uses default (sonnet)\n  - id: claude-code-fast\n    claude_model: haiku\n  - id: claude-code-deep\n    claude_model: opus\n```\n\nWith this config, all of these requests work:\n\n```jsonc\n{\"model\":\"claude-code\"}              // alias → sonnet (the default)\n{\"model\":\"claude-code-fast\"}         // alias → haiku\n{\"model\":\"haiku\"}                    // passthrough family → claude-haiku-4-5 (pinned)\n{\"model\":\"claude-haiku-4-5\"}         // passthrough full id → as-is\n{\"model\":\"opus\"}                     // passthrough family → claude-opus-4-7 (pinned)\n```\n\nOverride the underlying model at call time with the `X-CC-Claude-Model` header. The header always wins, even when the body sets a `model:` alias.\n\nFinal resolution order for the underlying claude model (first non-empty wins): `X-CC-Claude-Model` header → resolved alias's `claude_model:` (or for passthrough, the resolved family/full id) → top-level `default_claude_model:` → unset (claude picks).\n\n`GET /v1/models` lists the configured aliases plus the three family names (when passthrough is enabled), so OpenAI clients with model pickers see all the options. Full version ids aren't enumerated (too many, evolve too fast) but they still work in the request body.\n\n### Workspace (where Claude runs)\n\nClaude Code needs a working directory: where file reads, writes, and shell commands happen. ccproxy resolves one per request using this chain (first hit wins):\n\n1. **`X-CC-Workspace: <name>`** request header. Looked up in `config.yaml` under `workspaces:`. Requires the bearer to have `workspace:<name>` (or `workspace:*`) scope.\n2. **The alias's `workspace:` field** in `config.yaml`. Static per-alias binding.\n3. **Active bridge for the token's principal**. When the alias has no workspace binding and a `ccproxy-bridge` daemon is connected for this principal, the request mounts the daemon's `--root` as the workspace via FUSE. See [Bridge](#bridge).\n4. **Existing persistent-session workspace.** Applied by the session manager when a request reuses an `X-CC-Session` that already has a bound workspace.\n5. **Fallback: a fresh ephemeral dir** under `$CCPROXY_STATE/tmp/<uuid>/`, deleted at end-of-request (stateless) or at session TTL (persistent).\n\nClients **never** supply free-form paths, only names that resolve against server-side config. This is the product's security perimeter.\n\n**Consequence for passthrough models** (`haiku`, `sonnet`, `opus`, full Claude ids): they carry no alias-level workspace binding, so unless the client sends `X-CC-Workspace` they land on step 4, an ephemeral tmp dir. That's why asking a passthrough model to \"create a file\" appears to succeed but the file vanishes immediately. Ephemeral scratch space is the point for one-off questions; it's a surprise if you wanted real persistence.\n\n**Three ways to target a real directory:**\n\n**A. Alias with a binding.** Best for clients that can be configured once (Chatbox, TypingMind, LibreChat):\n\n```yaml\nworkspaces:\n  myproject:\n    path: /home/alice/projects/myproject\nmodels:\n  - id: myproject-code\n    workspace: myproject\n    claude_model: sonnet\n```\n\nClient then sends `{\"model\": \"myproject-code\"}`.\n\n**B. Per-request header.** Most flexible; works with any model including passthrough. Requires the client to support custom headers:\n\n```\nPOST /v1/chat/completions\nAuthorization: Bearer ccp_...\nX-CC-Workspace: myproject\nContent-Type: application/json\n\n{\"model\": \"haiku\", \"messages\": [...]}\n```\n\n**C. Persistent session.** First request's workspace (from A or B) sticks to the session id and is reused until TTL:\n\n```\nX-CC-Session: my-chat-abc\n```\n\nSubsequent requests with the same session id inherit the bound workspace without respecifying it.\n\n### Permission mode\n\nClaude Code defaults to prompting interactively before running tools that modify the filesystem or shell. ccproxy runs it under `-p` with no TTY, so any such prompt hangs the request. The `default_permission_mode:` knob in `config.yaml` controls this, with a sensible default of `bypassPermissions` (ccproxy's threat model already gates access at the **token** layer: if a bearer has `workspace:myproject` scope, the operator already decided to trust it in that directory).\n\nValues:\n\n| Mode | Meaning | Suitable for ccproxy? |\n|---|---|---|\n| `bypassPermissions` | No prompts, all tools run | yes (the default) |\n| `dontAsk` | Alias of `bypassPermissions` | yes |\n| `acceptEdits` | Auto-accept file edits, still gate Bash/other | partial (Bash calls will hang) |\n| `default` / `auto` / `plan` | Interactive modes | no (will hang) |\n\nOverride per-alias with `permission_mode:` on a `models:` entry. Leave `default_permission_mode:` empty if you actually want claude's native behavior (not recommended under this proxy).\n\n### Auth scopes\n\n| Scope | Grants |\n|---|---|\n| `chat` | `POST /v1/chat/completions`, `GET /v1/models`. Default for new tokens. |\n| `session:persistent` | Use `X-CC-Session` / `session_id` for cross-request state. |\n| `workspace:<name>` | Target a specific configured workspace. |\n| `workspace:*` | Wildcard over all workspaces. |\n| `bridge:connect` | Open a `/bridge` websocket as the daemon for this token's principal. Chat tokens do **not** need this; they pick up the bridge implicitly when they share a principal with the connected daemon. |\n| `admin` | (reserved for future admin endpoints) |\n\n## Bridge\n\nThe bridge lets Claude Code (running inside ccproxy on the server) operate\non a directory that lives **on the user's laptop**, without exposing any\ninbound port. A small daemon (`ccproxy-bridge`) opens an outbound websocket\nto the server; while it's connected, chat requests authenticated with\ntokens that share the daemon's *principal* see the daemon's `--root` as\ntheir workspace, mounted via FUSE.\n\n### Concepts\n\n- **Principal**: an opaque owner identifier set on a token at creation\n  time (`ccproxy token create --principal alice ...`). Tokens that share\n  a principal share a bridge attachment.\n- **Bridge token**: a token with `bridge:connect` scope used by the\n  daemon itself to open `/bridge`. Created with `--bridge`.\n- **Implicit binding**: chat tokens do **not** carry a \"use the bridge\"\n  flag. The resolution chain consults the registry: if a bridge is\n  connected for the principal **and** the request would otherwise fall\n  through to ephemeral, it gets mounted instead.\n\n### Quick start\n\n```sh\n# 1. on the server: mint two tokens for principal \"alice\"\nccproxy token create --name alice-bridge --principal alice --bridge --scopes bridge:connect\nccproxy token create --name alice-chat   --principal alice --scopes chat\n\n# 2. on the laptop: run the daemon\nexport CCPROXY_BRIDGE_TOKEN=ccp_...      # the bridge token from step 1\nccproxy-bridge \\\n  --server wss://ccproxy.lan/bridge \\\n  --root ~/code/myrepo \\\n  --watch \\\n  --audit ~/.cache/ccproxy-bridge/audit.log\n\n# 3. anywhere: chat with the chat token, asking for an unbound model\ncurl -H \"Authorization: Bearer $ALICE_CHAT\" \\\n     -H 'Content-Type: application/json' \\\n     -d '{\"model\":\"haiku\",\"messages\":[{\"role\":\"user\",\"content\":\"list files\"}]}' \\\n     https://ccproxy.lan/v1/chat/completions\n```\n\nThe chat request lands on the laptop's `~/code/myrepo` because: the model\nis unbound, alice has a connected bridge, the resolver picks step 3.\n\n### Daemon flags\n\n| Flag | Default | Notes |\n|---|---|---|\n| `--server` | (required) | `wss://host/bridge`. |\n| `--root` | (required) | Directory exposed to the server. Hard boundary; symlinks that escape are denied. |\n| `--token-env` | `CCPROXY_BRIDGE_TOKEN` | Env var holding the bridge bearer. |\n| `--allow-write` | `true` | Permit write/create/mkdir/remove/rename/chmod RPCs. |\n| `--allow-exec` | `false` | Permit `exec` RPCs. **Dangerous**: a chat token sharing the principal can run shell commands as the daemon's user. Default off. |\n| `--watch` | `true` | fsnotify the root and stream change events back; lets the server invalidate FUSE caches when you edit in your editor. |\n| `--audit` | unset | Append one JSON line per RPC to this file. |\n| `--confirm` | `false` | Prompt on stdin before each write/remove/exec. |\n\n### Safety surface\n\n- **Root clamp**: every relative path is `Clean()`'d, joined to `--root`,\n  and re-checked. Symlink leaves are resolved and rejected if they escape.\n- **`.ccproxyignore`**: optional file at `--root` with glob patterns\n  (gitignore-ish). Built-in deny list always applies: `.ssh`, `.aws`,\n  `.gnupg`, `id_rsa*`, `id_ed25519*`, `id_ecdsa*`, `.env`, `.env.*`.\n- **Single bridge per principal**: a second connection for the same\n  principal is rejected (registry returns `bridge: principal already\n  connected`).\n- **Operator opt-in for risk**: writes are on by default but exec is off;\n  `--confirm` adds a TTY gate.\n\n### Required-bridge mode\n\nClients that *expect* their work to land on the laptop can opt out of the\nsilent ephemeral fallthrough by sending `X-CC-Require-Bridge: true`. If\nno bridge is connected the request returns `412 bridge_unavailable`\ninstead of running against `/tmp/...`.\n\n### Metrics\n\nWhen `/metrics` is enabled:\n\n- `ccproxy_bridge_connections`: gauge of currently connected daemons.\n- `ccproxy_bridge_rpc_total{method,result}`: counter of server→daemon\n  RPCs.\n- `ccproxy_bridge_rpc_seconds{method}`: RPC latency histogram.\n\n### Limitations\n\n1. One bridge per principal at a time; a second daemon connection is rejected.\n2. Bridges live in server memory; an `ccproxy` restart drops every connection and the daemons must reconnect.\n3. macOS mounts work with both FUSE-T (recommended, no kext) and macFUSE.\n4. `git`-inside-mount works but every `.git/` access pays a round-trip; large repos feel slow.\n5. When the HTTP client disconnects mid-turn the in-flight bridge RPCs are best-effort cancelled but a write that already left the daemon may have landed.\n\n## Configuration\n\nResolution order (first match wins):\n\n1. `--config <path>`\n2. `$CCPROXY_CONFIG`\n3. `$CCPROXY_STATE/config.yaml`\n4. `./ccproxy.yaml`\nA config file is **required**: startup fails with a clear error if none is found. The minimal valid config is just `allow_passthrough_models: true` (which is the default), letting clients drive ccproxy with bare `model: \"haiku\"` requests without declaring any aliases.\n\nEnv vars are intentionally minimal; behavior lives in YAML. Full reference:\n\n| Variable | Purpose | Default |\n|---|---|---|\n| `CCPROXY_CONFIG` | Explicit path to `config.yaml`. Overrides the resolver below. | unset |\n| `CCPROXY_STATE` | State dir: SQLite token DB, ephemeral workspaces, `captures/`. | `/var/lib/ccproxy` |\n| `CCPROXY_LISTEN` | HTTP listener bind address. | `:4141` |\n| `CCPROXY_METRICS_LISTEN` | Prometheus `/metrics` listener (separate port, no auth). | unset (disabled) |\n| `CCPROXY_TOKEN` | First-boot bearer seed in `ccp_<prefix>_<secret>` format. Ignored after the token DB has at least one row. | unset (auto-mint) |\n| `CCPROXY_LOG_FORMAT` | `json` or `text`. Auto-detects from TTY when unset. | auto |\n\nReference templates: [`deploy/env.example`](deploy/env.example) for systemd `EnvironmentFile=`, [`.env.example`](.env.example) for local dev.\n\nA documented schema sample for `config.yaml` lives at [`deploy/config.yaml.example`](deploy/config.yaml.example).\n\n## Debug capture\n\nPer-token toggle that tees the full request lifecycle to disk for offline inspection. Off by default. When on, every `/v1/chat/completions` request writes a JSONL file at `$CCPROXY_STATE/captures/<request-id>.jsonl` (dir `0700`, file `0600`). Useful for reproducing bad responses, debugging a flaky MCP tool, or diffing verbosity modes against the same prompt.\n\n**Enable the flag.** At creation:\n\n```sh\nccproxy token create --name debug --scopes 'chat,workspace:*' --debug-capture\n```\n\nOr on an existing token (takes a token id from `ccproxy token list`, or a unique `--name` value):\n\n```sh\nccproxy token update dev --debug-capture on\n# turn it off again:\nccproxy token update dev --debug-capture off\n```\n\n`ccproxy token list` shows a `CAPTURE` column so you can see which tokens have it on. `revoke`, `rotate`, and `update` all accept either the token id or the unique name. Toggling invalidates the in-memory auth cache, so the change takes effect on the next request (no restart needed).\n\n**What lands in the file**, one JSONL line per entry:\n\n| `kind` | Payload |\n|---|---|\n| `request` | `{method, path, query, headers, body}`. `Authorization` is redacted. |\n| `event` | Raw `claude.Event` off the subprocess stream: system init, assistant deltas, tool use/result, result. |\n| `chunk` | The OpenAI SSE chunk ccproxy emitted (streaming mode only). |\n| `response` | The full buffered JSON response (non-streaming mode only). |\n| `done` | Terminal marker (streaming mode only). |\n\n**Caveats.** Captures never rotate or prune; add a cron/logrotate rule if you leave this on. The file is opened `O_EXCL` on the request id, so duplicate trace-ids fail to capture (they don't collide in practice since they're UUIDs). Body size inherits the 4 MiB request cap. Capture failures never fail the request (fail-open on purpose).\n\n## Testing\n\n```sh\nmake test                                # unit tests\nmake test-race                           # with race detector\nmake test-integration                    # real `claude` binary required; //go:build integration\n```\n\nThe `claude` subprocess and HTTP unit tests use a `TestHelperProcess`-style fake: the test binary re-invokes itself in helper mode (env `CCPROXY_HELPER=1` selects canned NDJSON). No real `claude` is spawned. The integration suite under `tests/integration/` exercises the server in-process via `httptest` against a real `claude` on PATH, covering streaming, non-streaming, persistent session resume, and debug capture. Tests skip cleanly when the binary is missing. Override the per-test deadline with `CCPROXY_INTEGRATION_TIMEOUT` (default 2m).\n\n## Dependencies\n\n| Package | Why |\n|---|---|\n| `github.com/google/uuid` | Trace IDs, session ids |\n| `github.com/joho/godotenv` | `.env` auto-loader |\n| `github.com/prometheus/client_golang` | `/metrics` |\n| `golang.org/x/crypto/argon2` | Token hashing |\n| `golang.org/x/time/rate` | Per-token RPM bucket |\n| `gopkg.in/yaml.v3` | Config parser |\n| `modernc.org/sqlite` | Token store + daily counter (pure Go, no CGO) |\n\nNo web framework. No ORM. Stdlib `net/http` + `database/sql`.\n\n## Where things live\n\n- **Add a new HTTP endpoint:** wire it in `server.Server.Handler()` and register a metrics-instrumented handler.\n- **Add a new request field:** add to `openai.ChatRequest`, plumb through `translate.RequestToInvocation`, then `claude.Options`.\n- **Add a new config field:** add to `config.Config`, set a default in `Defaults()`, validate in `Validate()`, plumb to whichever consumer needs it.\n- **Add a new verbosity mode:** extend `translate.Translator.EventToChunks`. Tests live in `internal/translate/verbosity_test.go`.\n- **Change auth surface:** look at `internal/auth/store.go` and `internal/server/middleware.go`.\n- **Bridge protocol changes:** add the param/result types to `internal/bridge/protocol.go`, the FUSE-side call in `internal/bridge/fs_fuse.go`, and the daemon-side handler in `internal/bridgeclient/fs_handlers.go`. Both ends share the same constants from `protocol.go`.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n"}