saros-proxy 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +187 -29
  2. package/dist/cli/daemon.d.ts +38 -6
  3. package/dist/cli/daemon.js +524 -109
  4. package/dist/cli/daemon.js.map +1 -1
  5. package/dist/cli/dsh-config.js +34 -9
  6. package/dist/cli/dsh-config.js.map +1 -1
  7. package/dist/cli/help.js +3 -0
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/key-state.d.ts +21 -0
  10. package/dist/cli/key-state.js +57 -0
  11. package/dist/cli/key-state.js.map +1 -0
  12. package/dist/cli/keys.d.ts +28 -0
  13. package/dist/cli/keys.js +185 -0
  14. package/dist/cli/keys.js.map +1 -0
  15. package/dist/cli/opencode-config.js +3 -3
  16. package/dist/cli/opencode-config.js.map +1 -1
  17. package/dist/cli/overview.js +6 -1
  18. package/dist/cli/overview.js.map +1 -1
  19. package/dist/cli/secret.d.ts +31 -0
  20. package/dist/cli/secret.js +89 -0
  21. package/dist/cli/secret.js.map +1 -0
  22. package/dist/cli/usage.js +7 -0
  23. package/dist/cli/usage.js.map +1 -1
  24. package/dist/config.d.ts +15 -1
  25. package/dist/config.js +98 -25
  26. package/dist/config.js.map +1 -1
  27. package/dist/constants.d.ts +2 -1
  28. package/dist/constants.js +2 -160
  29. package/dist/constants.js.map +1 -1
  30. package/dist/debug-buffer.d.ts +58 -0
  31. package/dist/debug-buffer.js +42 -0
  32. package/dist/debug-buffer.js.map +1 -0
  33. package/dist/index.js +30 -4
  34. package/dist/index.js.map +1 -1
  35. package/dist/log-redact.d.ts +18 -0
  36. package/dist/log-redact.js +41 -0
  37. package/dist/log-redact.js.map +1 -0
  38. package/dist/logger.d.ts +6 -0
  39. package/dist/logger.js +30 -0
  40. package/dist/logger.js.map +1 -1
  41. package/dist/models-catalog.d.ts +20 -0
  42. package/dist/models-catalog.js +55 -0
  43. package/dist/models-catalog.js.map +1 -0
  44. package/dist/models-fetcher.d.ts +2 -2
  45. package/dist/models-fetcher.js +4 -4
  46. package/dist/models-fetcher.js.map +1 -1
  47. package/dist/models-sync.d.ts +1 -1
  48. package/dist/models-sync.js +3 -3
  49. package/dist/models-sync.js.map +1 -1
  50. package/dist/providers/infer.d.ts +11 -0
  51. package/dist/providers/infer.js.map +1 -1
  52. package/dist/proxy-logic.d.ts +74 -3
  53. package/dist/proxy-logic.js +111 -9
  54. package/dist/proxy-logic.js.map +1 -1
  55. package/dist/proxy.d.ts +33 -1
  56. package/dist/proxy.js +351 -49
  57. package/dist/proxy.js.map +1 -1
  58. package/dist/secrets/boot.d.ts +33 -0
  59. package/dist/secrets/boot.js +65 -0
  60. package/dist/secrets/boot.js.map +1 -0
  61. package/dist/secrets/envelope.d.ts +44 -0
  62. package/dist/secrets/envelope.js +132 -0
  63. package/dist/secrets/envelope.js.map +1 -0
  64. package/dist/secrets/source.d.ts +130 -0
  65. package/dist/secrets/source.js +332 -0
  66. package/dist/secrets/source.js.map +1 -0
  67. package/dist/token-usage.js +6 -3
  68. package/dist/token-usage.js.map +1 -1
  69. package/dist/validation.d.ts +3 -3
  70. package/dist/validation.js +3 -3
  71. package/dist/validation.js.map +1 -1
  72. package/models.json +1440 -0
  73. package/package.json +2 -1
package/README.md CHANGED
@@ -44,7 +44,8 @@ Manage multiple API keys behind a single endpoint — OpenCode-Go **and** Comman
44
44
  - **Graceful shutdown** — Drains active requests before exiting (no dropped connections).
45
45
  - **Security hardening** — API key masking in logs, SSRF prevention (HTTPS-only upstream), configurable CORS, host binding validation.
46
46
  - **Config validation** — CLI args, YAML file, or environment variables with sensible defaults.
47
- - **Health endpoint** — `GET /health` shows key status, active requests, and circuit-breaker state.
47
+ - **Health endpoint** — `GET /health` shows key status per key (enabled, why it is out of rotation, when it retries), active requests, and circuit-breaker state.
48
+ - **Request forensics** — `X-Proxy-Provider` / `X-Proxy-Key-Label` / `X-Proxy-Request-Id` headers on served responses, plus `GET /debug/recent` (last 200 requests in memory, no credentials).
48
49
  - **No database** — Stateless proxy, zero external dependencies beyond Node.js.
49
50
 
50
51
  ---
@@ -125,7 +126,9 @@ queryable quota API (CommandCode): their keys report no percentages, but
125
126
  #### Circuit Breaker
126
127
 
127
128
  Keys that return auth/quota errors (`401`, quota `4xx/5xx`) enter a cooldown
128
- and are lazily re-enabled after it expires. Timeouts and connection errors do
129
+ and are lazily re-enabled after it expires — or immediately by a successful
130
+ response through the same key, which happens when tier 2 shares one key between
131
+ concurrent requests and only one of them fails. Timeouts and connection errors do
129
132
  **not** disable keys — they are shared-upstream failures, so Saros keeps
130
133
  cycling instead of locking up with "all keys unavailable".
131
134
 
@@ -283,6 +286,11 @@ Test that it's alive:
283
286
  ```bash
284
287
  curl http://127.0.0.1:3000/health
285
288
 
289
+ # Why is a key out of rotation? The `keys` block says it (never the key itself):
290
+ # "keys": [{"label":"work","provider":"commandcode","enabled":false,
291
+ # "reason":"auth","lastError":{"status":401},
292
+ # "retryAfterMs":43876}]
293
+
286
294
  # Query models
287
295
  curl http://127.0.0.1:3000/zen/go/v1/models
288
296
 
@@ -335,10 +343,15 @@ keys:
335
343
  - label: cc-console-key
336
344
  key: sk-your-console-key-here... # ambiguous prefix → set provider explicitly
337
345
  provider: commandcode
346
+ - label: old-account
347
+ key: sk-you...0000
348
+ enabled: false # retired: kept in config, never used
338
349
  ```
339
350
 
340
351
  The `provider` field is optional — it wins over prefix inference and is required only when a CommandCode `sk-` console key must be distinguished from an OpenCode-Go key. When keys are encrypted at rest (prefix hidden), the provider from setup time is persisted alongside.
341
352
 
353
+ The `enabled` field is optional (default `true`). Setting `enabled: false` retires a key without deleting it: it never enters rotation, is never counted as a failure, and `/health` reports it as `disabledByConfig: true` (the 503 lists it as `disabled_by_config`). A revoked key is usually better retired than deleted — the credential stays for the record and comes back with a one-word edit.
354
+
342
355
  **Per-provider upstream override (optional):**
343
356
 
344
357
  ```yaml
@@ -357,8 +370,9 @@ OPENCODE_GO_KEYS="account1:sk-xxx,cc:user_yyy"
357
370
  - Clients always speak the canonical OpenCode-Go shapes (`/zen/go/v1/...` or the `/v1/...` alias).
358
371
  - When a request is served by a CommandCode key, Saros remaps the route to CommandCode's OpenAI-compatible surface (`/provider/v1/...`) and injects the provider's identity headers — transparent to the client.
359
372
  - Models uniquely named by a provider (e.g. `claude-*` or vendor-prefixed ids on CommandCode) are routed to a key of that provider first; other providers remain as fallback.
360
- - Synced harness configs may list provider-specific models as `model@commandcode`; the suffix selects the provider and is stripped before forwarding.
361
- - The `/health` endpoint reports per-provider key counts under `providers`.
373
+ - Synced harness configs may list provider-specific models as `model@commandcode`; the suffix selects the provider and is stripped before forwarding. The pin is authoritative: key selection is restricted to that provider, so a pin whose provider cannot serve the model still reaches that upstream and surfaces its error instead of a local 503.
374
+ - The `/health` endpoint reports per-provider key counts under `providers`, and a `keys` array with each key's state: `enabled`, `entitled`, `consecutiveFailures`, `disabledAt`, `retryAfterMs`, and `lastError` (`status` + `reason`: `auth`, `quota`, `rate_limit`, `server`). The credential is never part of the response.
375
+ - When no key can serve a request, the `503` body says which keys are out and why — `keys[]` with `reason`/`lastStatus`/`retryAfterMs`, plus a `causes[]` list (revoked key, exhausted quota, rate limit, missing plan, or a `@provider` pin with no usable key). The legacy `error` field (`All API keys are temporarily unavailable`) is still present.
362
376
  - OpenCode-Go requires a stable per-conversation session id (`x-opencode-session`, used for routing and prompt caching). Saros forwards a client-supplied value untouched and derives one from the conversation (model + first user turn) when the client sends none — harnesses that only attach the header on some request paths would otherwise get `400 MissingSessionID`.
363
377
  - Usage-based weighted rotation applies to providers with a queryable usage API (OpenCode-Go). CommandCode keys participate in rotation without usage data, but their token consumption is tracked per key and shown by `saros-proxy usage` (see [Token Accounting](#token-accounting)).
364
378
 
@@ -372,33 +386,100 @@ CC_KEY=user_your-token npx tsx scripts/live-identify-check.ts
372
386
 
373
387
  ### API Key Encryption
374
388
 
375
- Keys can be encrypted at rest using AES-256-GCM with scrypt key derivation. Encrypted keys look like `enc:scrypt:...` in `config.yaml` and get decrypted at startup using the `OPENCODE_GO_ENCRYPTION_KEY` environment variable.
389
+ Keys can be stored as `enc:v1:` envelopes instead of plaintext. An envelope is
390
+ `enc:v1:<salt>.<nonce>.<tag>.<ciphertext>`: scrypt (N=2^15, r=8, p=1, 32 B)
391
+ derives an AES-256-GCM key, and the AAD binds the envelope version and the key
392
+ label, so a ciphertext copied onto another key entry fails authentication.
393
+
394
+ **Encrypt existing plaintext keys** (the old file is copied to
395
+ `config.yaml.backup` once; a second run is a no-op; providers are inferred and
396
+ persisted so encrypted keys still route correctly):
376
397
 
377
- **Setup with encryption:**
378
398
  ```bash
379
- npm run setup
380
- # Choose to encrypt keys and enter a master key (min 16 chars)
381
- ```
399
+ saros-proxy keys encrypt
400
+ # or: saros-proxy keys encrypt --config /etc/saros/config.yaml
401
+ # or: saros-proxy keys encrypt --no-backup
402
+ ```
403
+
404
+ The backup is written **in plaintext** (mode 0600) on purpose: it is the
405
+ recovery path if the passphrase is lost. Delete it after the first successful
406
+ boot, or pass `--no-backup` to skip it. A symlinked config (dotfile setups) is
407
+ rewritten through its real target — the link survives and the target is
408
+ encrypted.
409
+
410
+ **Cost of the KDF:** the envelope uses scrypt N=2^15, r=8, p=1 (about 32 MiB
411
+ and ~70 ms per key on a current laptop). Boot derives the key once per
412
+ encrypted entry, so 10 keys add roughly 0.7 s. Raising N to 2^17 (OWASP's
413
+ recommendation) multiplies that boot cost by ~4 per key; do it only together
414
+ with a migration, and remember that passphrase entropy — not the KDF — is what
415
+ an offline attacker attacks first.
416
+
417
+ **Passphrase sources, first match wins:**
418
+
419
+ 1. `SAROS_PASSPHRASE` — for systemd, Docker and CI.
420
+ 2. The OS secret store, accessed only through the platform CLI:
421
+ - Linux: `secret-tool lookup service saros-proxy account "$USER"`
422
+ - macOS: `security find-generic-password -s saros-proxy -a "$USER" -w`
423
+ - Windows: PowerShell DPAPI (`CurrentUser`) over
424
+ `%APPDATA%\saros\secret.dpapi`
425
+ 3. A hidden TTY prompt — foreground `saros-proxy serve` only, never the daemon
426
+ child and never when stdin is not a TTY.
382
427
 
383
- **Running with encrypted keys:**
384
428
  ```bash
385
- export OPENCODE_GO_ENCRYPTION_KEY="your-master-key"
386
- npx tsx src/index.ts
429
+ saros-proxy secret set # store the passphrase through the OS CLI
430
+ saros-proxy secret status # which source would win + whether the store is populated
431
+ saros-proxy serve # foreground; may prompt if no source is available
387
432
  ```
388
433
 
389
- **Docker with encrypted keys:**
390
- ```yaml
391
- # docker-compose.yml
392
- environment:
393
- - OPENCODE_GO_ENCRYPTION_KEY=your-master-key
394
- ```
434
+ `secret set` never writes the passphrase to a file and never prints it; it
435
+ warns when the value is shorter than 12 characters (scrypt makes guessing
436
+ expensive, but passphrase entropy is what actually protects the keys). On
437
+ Linux it is stored with `secret-tool store --label="saros-proxy passphrase"
438
+ service saros-proxy account "$USER"`; on macOS with `security
439
+ add-generic-password -U -s saros-proxy -a "$USER" -w`; on Windows PowerShell
440
+ `ProtectedData.Protect(..., 'CurrentUser')` writes the blob.
441
+
442
+ `secret status` distinguishes an absent entry (`not found`) from an
443
+ unavailable keyring (`keyring locked or error`), and the same reason is carried
444
+ into the "no passphrase source" boot error — a locked keyring is not reported
445
+ as an empty store.
395
446
 
396
- **Notes:**
397
- - Encrypted keys use AES-256-GCM with scrypt KDF (N=16384, r=8, p=1)
398
- - Each key has its own random salt and IV
399
- - Plaintext keys (`sk-...`) still work — backward compatible
400
- - `OPENCODE_GO_KEYS` env var is always plaintext (for CI/CD)
401
- - If keys are encrypted but no `OPENCODE_GO_ENCRYPTION_KEY` is set, startup fails with a clear error
447
+ **systemd-creds recipe** (keeps the passphrase out of the unit file and the
448
+ environment of the shell):
449
+
450
+ ```bash
451
+ # One-time: encrypt stdin into a host+user-bound credential.
452
+ systemd-creds encrypt --name=saros-passphrase --with-key=host+user \
453
+ /dev/stdin /etc/saros/passphrase.cred
454
+ ```
455
+
456
+ ```ini
457
+ # /etc/systemd/system/saros-proxy.service
458
+ [Service]
459
+ LoadCredentialEncrypted=saros-passphrase:/etc/saros/passphrase.cred
460
+ ExecStart=/bin/sh -c 'SAROS_PASSPHRASE="$(cat "$CREDENTIALS_DIRECTORY/saros-passphrase")" exec /usr/bin/saros-proxy serve'
461
+ ```
462
+
463
+ **Behaviour and compatibility:**
464
+
465
+ - Plaintext keys keep working; startup logs a loud warning and
466
+ `keys encrypt` fixes them.
467
+ - A config with `enc:v1:` values and no available source exits `1` with a
468
+ message naming the three options. A wrong passphrase also exits `1` and
469
+ names the key label — never the key or the passphrase.
470
+ - `GET /health` reports `"encrypted": true` per key; it never returns the
471
+ ciphertext or the plaintext. The flag is derived from the value being an
472
+ envelope — an `encrypted: true` field on a plaintext entry is stripped, so
473
+ the signal cannot be spoofed from YAML.
474
+ - Legacy `enc:scrypt:` keys still decrypt with `OPENCODE_GO_ENCRYPTION_KEY`
475
+ (with a warning); `keys encrypt` leaves them untouched.
476
+ - `OPENCODE_GO_KEYS` is always plaintext (CI use) and is not covered by the
477
+ warning.
478
+ - Docker can pass `SAROS_PASSPHRASE` or mount a config whose passphrase comes
479
+ from a Docker secret; the container has no OS keyring of its own.
480
+
481
+ The honest threat model — what each option protects and what it does not — is
482
+ in [Security](#security).
402
483
 
403
484
  ### CLI Flags
404
485
 
@@ -526,9 +607,11 @@ Override with `--config`:
526
607
  saros-proxy start --port 3000 --config /path/to/config.yaml
527
608
  ```
528
609
 
529
- The PID file is stored at `~/.config/saros/daemon.pid`. The child process runs with `NODE_ENV=production` (JSON logging), and its stderr is appended to `~/.config/saros/daemon.log`.
610
+ The PID file, `daemon.log` and captures live in the same directory as `config.yaml` — `~/.config/saros/` on Linux/macOS, `%LOCALAPPDATA%\saros\` on Windows. An installation started before 0.11.0 keeps its pid under `~/.config/saros`; set `SAROS_ALLOW_LEGACY_PID=1` once to stop it, or the new layout takes over on the next start. Without the flag the pre-0.11 PID file is inert — it is never read, migrated or deleted (only `stat`ed, so `stop`/`status` can name it and point at `SAROS_ALLOW_LEGACY_PID=1`) — because that path lives in the operator's real home and an isolated run (a test with a temp config dir, a throwaway container shell) must not signal a live daemon. With the flag set, a live legacy PID is read and migrated into the new directory on first use. `saros-proxy stop` removes the PID file only once the process is confirmed gone: when the kill fails it keeps the handle and exits non-zero, so the next `stop` still finds the daemon (instead of printing "no running proxy daemon" and exiting 0) and `restart` never announces a stop it could not confirm nor start over a busy port. A failed stop also names the exact file it acted on, and removing it never touches the other layout's handle. When the state dir holds no PID but a pre-0.11 one exists, `stop` says where it is and exits non-zero if something still answers `/health` on the configured port — an upgraded install cannot be reported as stopped while it still serves. The child process runs with `NODE_ENV=production` (JSON/NDJSON logging), and **both** its stdout (pino) and stderr are appended to `daemon.log` — the same descriptor for both, with no pipe whose read end the exiting launcher could take away. The file rotates at start: past 10 MB it becomes `daemon.log.1` and up to five are kept. Every boot writes an `evt:"boot"` line — version, node, config path, port, key labels/providers, bundled model count — so an empty log now means the daemon never started, not that logging is silent. When a fresh child dies during startup, `saros-proxy start` reads the lines this boot appended to that same log and reports the reason (`EADDRINUSE`, missing API keys, or the last six lines) before exiting non-zero.
530
611
 
531
- `saros-proxy status` checks the PID file first, then falls back to an os-agnostic `GET /health` port probe — so it reports the truth even when the proxy was started outside the daemon (manually, via autostart, or in another session) and no PID file exists. It also prints the enabled harnesses (`configharness` selection). `saros-proxy start` refuses to start a second instance when the configured port is already serving.
612
+ `saros-proxy status` checks the PID file first, then falls back to an os-agnostic `GET /health` port probe — so it reports the truth even when the proxy was started outside the daemon (manually, via autostart, or in another session) and no PID file exists. It also prints the enabled harnesses (`configharness` selection) and, from the daemon's `/health` payload, one line per key with its state (`Keys: 1/2 enabled`, then `work [commandcode] — disabled: auth (401), retry in 42s`). `saros-proxy start` refuses to start a second instance when the configured port is already serving. `saros-proxy stop` exits non-zero whenever something still answers `/health` on the configured port, even when it has no PID file to remove — the process was started outside the daemon or lost its handle.
613
+
614
+ Every request writes exactly one `evt:"request.complete"` line (requestId, path, model, provider, keyLabel, status, durationMs, stream, attempt), with key selection at `debug` and upstream failures at `warn`. The full event and redaction contract is in [docs/logging.md](docs/logging.md).
532
615
 
533
616
  Bare `saros-proxy` (no subcommand) prints the same overview without starting anything: running state, harnesses, masked API keys, and config path. To run the proxy in the foreground (e.g. for development or in a container), use `saros-proxy serve [--port <port>] [--config <path>]` — the daemon child and the Docker entrypoint run `serve` under the hood.
534
617
 
@@ -639,6 +722,17 @@ The model list written to each harness comes from the live provider catalogs (op
639
722
 
640
723
  ---
641
724
 
725
+ ## What the proxy does *not* enforce
726
+
727
+ Two kinds of upstream quota are visible in the provider documentation but are **not** modelled by Saros, on purpose — the proxy rotates keys and reasons about keys, not about what a single model or plan allows:
728
+
729
+ - **Model-level free tiers (CommandCode).** `glyph-cluster:free` is capped at 100 requests/day, `beam:free` has no daily cap, and the `laguna-s-2.1-free` tiers cost no credits. Saros counts tokens per key (`token-usage.json`) and per model, but it does not refuse a request because a *model* reached its daily free quota — the upstream returns the error and the key's circuit breaker decides what happens next.
730
+ - **Plan windows (OpenCode Go).** The Go plan meters each model against `5-hour — 20% of the monthly limit`, `weekly — 50%` and `monthly — 100%`. Those windows belong to the subscription: `/zen/go/v1/usage` reports what was consumed, and Saros surfaces it, but the proxy does not predict window exhaustion or re-route on it.
731
+
732
+ Both are documented here so the behaviour is not mistaken for a bug. Requests through the alternate OpenAI/Anthropic surfaces (`/v1/responses`, `/v1/messages`) and through the decision endpoint (`/v1/systemone`) are forwarded and counted: streamed usage nested under `response.usage` and the `input_tokens`/`output_tokens` shape are booked like any other call.
733
+
734
+ ---
735
+
642
736
  ## Troubleshooting
643
737
 
644
738
  ### Check proxy health
@@ -659,21 +753,41 @@ NODE_ENV=development npx tsx src/index.ts
659
753
  NODE_ENV=production npx tsx src/index.ts
660
754
  ```
661
755
 
662
- ### See which key served a request
756
+ ### See which key and provider served a request
663
757
 
664
- Saros adds two debug headers to every response:
758
+ Responses served by an upstream key carry three debug headers:
665
759
 
666
760
  | Header | Example | What it is |
667
761
  |---|---|---|
668
762
  | `X-Proxy-Key-Label` | `primary` | Which key was used |
763
+ | `X-Proxy-Provider` | `commandcode` | Provider of that key (`opencode-go` or `commandcode`) |
669
764
  | `X-Proxy-Request-Id` | `550e8400-e29b-41d4-a716-446655440000` | Unique ID for log correlation |
670
765
 
671
766
  ```bash
672
- curl -s -D - http://127.0.0.1:3000/zen/go/v1/models | grep -i x-proxy
767
+ curl -s -D - http://127.0.0.1:3000/v1/chat/completions \
768
+ -H "Content-Type: application/json" \
769
+ -d '{"model":"gpt-4","messages":[{"role":"user","content":"Hello"}]}' | grep -i x-proxy
673
770
  # X-Proxy-Key-Label: primary
771
+ # X-Proxy-Provider: opencode-go
674
772
  # X-Proxy-Request-Id: 550e8400-e29b-41d4-a716-446655440000
675
773
  ```
676
774
 
775
+ `X-Proxy-Provider` reports the configured provider of the key that actually served the request. The credential prefix is not re-inferred at routing time: an explicit `provider:` in `config.yaml` wins, so a key declared `provider: commandcode` is sent to CommandCode and reported as `commandcode` even with an `sk-…` credential. For a pinned `model@provider` request the value equals the suffix because the selected key belongs to that provider — not because the header parses the model id.
776
+
777
+ These headers are emitted only on 2xx responses served by an upstream key. The local routes (`GET /health`, `GET /v1/models`, `GET /zen/go/v1/models`, `GET /debug/recent`) and the 413/429 rejections carry none. The proxy's own error responses — 503 no key, 502 retries exhausted, the 502 synthesized for a bodyless stream, and a forwarded 4xx it re-serializes — omit them even when a key was involved: use the body's `requestId`, and `/debug/recent` still records the `keyLabel`. `/v1/models` is answered by the catalogue handler (`models-fetcher`, no key selected, no provider attribution), which is why it is outside the header contract.
778
+
779
+ Browser clients can read the three headers: the CORS middleware lists them in `Access-Control-Expose-Headers`.
780
+
781
+ ### Inspect the last requests
782
+
783
+ ```bash
784
+ curl -s http://127.0.0.1:3000/debug/recent | jq
785
+ ```
786
+
787
+ `GET /debug/recent` returns `{capacity, count, entries}` with the last 200 proxied requests, oldest first. Each entry mirrors the `evt:"request.complete"` log line: `requestId`, `at` (ISO, when the response was produced — for a stream that is its headers, not the end of the stream), `method`, `path`, `model`, `provider`, `keyLabel`, `status`, `durationMs`, `stream`, `attempt`. It lives in memory only and never stores keys, headers or bodies — only the public key label — so it starts empty on restart.
788
+
789
+ Disable it with `SAROS_DEBUG_BUFFER=0`: the endpoint answers `404` and no entry is kept. Like `/health`, it is unauthenticated and metadata-only: `requestId`, paths, models, providers, key labels, status and timing — never keys, headers or bodies. It is designed for the loopback bind; a deployment reachable from the network (including a container/port binding beyond `127.0.0.1`) exposes those request paths, model names and key labels to anyone who can reach the port.
790
+
677
791
  ### Common issues
678
792
 
679
793
  | Symptom | Likely cause | Fix |
@@ -728,6 +842,7 @@ src/
728
842
  providers/ — Multi-provider key abstraction (KeyProvider, adapters, identifyKey)
729
843
  models-fetcher.ts — Upstream model list fetching + caching
730
844
  models-sync.ts — Auto-sync models from upstream to opencode config
845
+ models-catalog.ts — Single source of truth: loads bundled models.json
731
846
  cli/
732
847
  setup.ts — Interactive setup wizard
733
848
  daemon.ts — Background process management
@@ -759,6 +874,49 @@ test/
759
874
 
760
875
  For vulnerability reporting, see [SECURITY.md](SECURITY.md).
761
876
 
877
+ ### Threat model for API keys at rest
878
+
879
+ The envelope protects the key **file**. It does not make a credential
880
+ unreachable by code running as the same user — every option below ultimately
881
+ hands the plaintext to the proxy process, which that code can inspect.
882
+
883
+ | Scenario | Plaintext config | `enc:v1` + `SAROS_PASSPHRASE` | `enc:v1` + OS store | `enc:v1` + TTY prompt |
884
+ |---|---|---|---|---|
885
+ | Config file copied to another machine/user | No protection | Protected (passphrase not in the file) | Protected (DPAPI/Keychain is user-bound) | Protected |
886
+ | Dotfile sync / cloud backup / git accident | Leaks | Protected | Protected | Protected |
887
+ | Backup of the config directory only | Leaks | Protected only after deleting `config.yaml.backup` (the migration backup is plaintext) | Same caveat | Same caveat |
888
+ | Shoulder surfing the config file | Leaks | Protected | Protected | Protected |
889
+ | Another local process **running as the same user** | Reads the file | Can read the env (`/proc/<pid>/environ`) | Can call the same OS CLI / read the same DPAPI blob | Can read the terminal or memory |
890
+ | Malware running as the same user | Reads the file | No protection | No protection | No protection |
891
+ | Root / Administrator | Reads the file | No protection | No protection (may impersonate the user) | No protection |
892
+ | Offline brute force of a stolen config | N/A | scrypt N=2^15 slows guessing; strength = passphrase entropy | Same | Same |
893
+ | Passphrase visible in `ps`/shell history | N/A | Avoid `SAROS_PASSPHRASE=… command`; export it or use systemd-creds | `secret set` on macOS passes `-w` on the command line briefly | Typed hidden, not echoed |
894
+
895
+ Practical guidance:
896
+
897
+ - Use the OS store for an interactive workstation; it avoids the passphrase
898
+ being present in every child process environment.
899
+ - Use `SAROS_PASSPHRASE` for containers/systemd; prefer `systemd-creds` (recipe
900
+ above) over a unit-file `Environment=` line.
901
+ - The passphrase must be high entropy and independent from the API keys; the
902
+ envelope only makes an offline attack expensive, not impossible. `secret set`
903
+ warns below 12 characters; no length is enforced.
904
+ - `keys encrypt` writes `<config>.backup` in plaintext (0600) once. Delete it
905
+ after the first successful boot, or use `--no-backup`. Any directory/dotfile
906
+ backup taken before that deletion contains the original keys.
907
+ - `saros-proxy secret status` shows which source would win and never prints a
908
+ value.
909
+
910
+ ### Other controls
911
+
912
+ - HTTPS-only upstream (SSRF prevention)
913
+ - API key masking in logs, plus envelope/passphrase redaction patterns and
914
+ pino redact paths for `passphrase`, `secret`, `secretValue`, `masterKey`,
915
+ `encryptionKey` and `SAROS_PASSPHRASE`
916
+ - CORS origin validation
917
+ - Rate limiting: 100 req/min per IP (hardcoded)
918
+ - Non-root Docker container
919
+
762
920
  ---
763
921
 
764
922
  ## License
@@ -9,13 +9,42 @@
9
9
  *
10
10
  * Daemon lifecycle:
11
11
  * start → refuses if the port is already serving, then spawns detached
12
- * `node dist/index.js` process, saves PID, logs child stderr
13
- * stop → kills the process (taskkill /T /F on Windows), removes PID file
14
- * restart → kills running instance (if any), waits, then starts fresh
12
+ * `node dist/index.js` process, saves PID, appends child stdout
13
+ * and stderr to daemon.log
14
+ * stop → kills the process (taskkill /T /F on Windows) and removes the
15
+ * PID file only once the process is confirmed gone; exits 1 when
16
+ * something still serves the port
17
+ * restart → kills running instance (if any), waits for it to disappear,
18
+ * then starts fresh
15
19
  * status → checks PID file, falls back to an os-agnostic /health port
16
20
  * probe (instances started outside the daemon have no PID file),
17
21
  * and prints the enabled harnesses
18
22
  */
23
+ /**
24
+ * Open daemon.log for append (0600 — it carries upstream error bodies).
25
+ * Returns null when the file cannot be opened, so callers fall back to
26
+ * discarding output. Used for the child's stdout and stderr, which share this
27
+ * single file. Exported so the wiring can be asserted in tests.
28
+ */
29
+ export declare function openDaemonLog(): number | null;
30
+ /**
31
+ * Check if a process is alive by PID.
32
+ *
33
+ * `process.kill(pid, 0)` is the portable liveness probe: Node maps it to
34
+ * OpenProcess on Windows, so a dead PID fails with ESRCH and an EPERM/EACCES
35
+ * answer means "the PID exists, it is just not ours to signal" → alive.
36
+ *
37
+ * The previous win32 path ran `tasklist /FI "PID eq <pid>"`, which exits 0
38
+ * even when no task matches — every PID looked alive (#83).
39
+ */
40
+ export declare function isProcessAlive(pid: number): boolean;
41
+ /**
42
+ * Kill a process and all its children. Returns whether the target is gone.
43
+ *
44
+ * Output is piped (never inherited): taskkill's "not found" text used to land
45
+ * in the caller's console and PowerShell surfaced it as a NativeCommandError.
46
+ */
47
+ export declare function killProcess(pid: number): boolean;
19
48
  /**
20
49
  * Probe the proxy's /health endpoint on a port. Returns the parsed body when
21
50
  * the endpoint answers with status ok — the strongest cross-platform signal
@@ -27,11 +56,14 @@ export declare function probeProxyHealth(port: number): Promise<{
27
56
  body?: Record<string, unknown>;
28
57
  }>;
29
58
  export declare function daemonStart(port?: number, configPath?: string): Promise<void>;
30
- export declare function daemonStop(): void;
59
+ export declare function daemonStop(): Promise<void>;
31
60
  /**
32
- * Restart the proxy daemon: stop the running instance (if any), wait
33
- * briefly for the port to release, then start a fresh instance.
61
+ * Restart the proxy daemon: stop the running instance (if any), wait for it
62
+ * to actually disappear and the port to release, then start a fresh instance.
34
63
  * Reuses daemonStart which owns its own exit path via setTimeout.
64
+ *
65
+ * "Restarting: stopped daemon" used to print before the kill landed, so a
66
+ * failed stop was followed by a start that died on the busy port.
35
67
  */
36
68
  export declare function daemonRestart(port?: number, configPath?: string): void;
37
69
  export interface DaemonState {