mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D68-D70 — bin/olp-connect + /health.anonymousKey + ADR 0011 (#43)
* feat+test+docs: D68+D69+D70 — bin/olp-connect + /health.anonymousKey + ADR 0011
Third substantive Phase 4 implementation. 3 D-days bundled per Iron Rule
11 IDR — olp-connect consumes /health.anonymousKey for zero-config
client setup, both governed by ADR 0011's trusted-LAN-only invariant.
## D68 — bin/olp-connect (zero-config client setup)
Ports OCP ocp-connect (721 lines) → OLP olp-connect (564 lines, pure bash).
Bash over Node (per ADR 0010 § Notes) because client machines may lack
recent Node; bash + curl + python3 = max portability.
CLI: `olp-connect <host-ip> [--port PORT] [--key API_KEY] [--no-system-env]
[--dry-run] [--help] [--version]`
Workflow:
1. Connectivity probe (curl /health, 5s timeout, distinguishes TCP
unreachable from auth-required)
2. Auth resolution: --key flag → /health.anonymousKey (D69) → interactive
prompt fallback
3. Smoke test (GET /v1/models with bearer)
4. IDE detection + per-IDE config:
- Claude Code: detect + warn (NOT supported as OLP client per ADR 0010)
- Cline: detect + print manual VSCode-settings snippet
- Continue.dev: detect (extension OR ~/.continue/config.yaml) + write
idempotent models: entry
- Cursor: detect + print snippet + WARNING (per prior-art known
base-URL fragility)
- Aider: detect + write OPENAI_API_BASE + OPENAI_API_KEY to rc files
- OpenClaw: detect + print "install /olp plugin (D71-D73 deliverable)"
5. System-level env: macOS launchctl setenv / Linux ~/.config/environment.d
(so VSCode/Cursor started via Dock inherit)
6. Summary + test command
Idempotent (bracketed `# OLP LAN (added by olp-connect)` ... `# /OLP LAN`
blocks in rc files). --dry-run exercises every state-change site without
modifying anything. Exit 0/1/2 conventions.
Installed via package.json bin so `npx olp-connect` works.
## D69 — /health.anonymousKey + auth.advertise_anonymous_key
server.mjs handleHealth emits OPTIONAL `anonymousKey: "olp_..."` field
when ALL THREE prerequisites hold:
1. config.json auth.advertise_anonymous_key === true
2. config.json auth.allow_anonymous === true (per ADR 0007 § 7)
3. At least one non-revoked key has plaintext_advertise field set
Default-off: field is ABSENT (not null) — preserves v0.3.x /health shape;
existing tests don't regress.
bin/olp-keys.mjs new flags: `keygen --anonymous --advertise` writes the
plaintext into the manifest's optional `plaintext_advertise` field AND
prints a WARNING about disk-storage + /health exposure + ADR 0011
pointer. Owner-tier --advertise rejected at BOTH CLI + lib layers.
Implementation note: reused existing guest tier (no new owner_tier:
'anonymous'); plaintext_advertise is a forward-compat optional manifest
field per ADR 0007 § 4 unknown-fields-allowed convention. Cleaner than
introducing a new tier.
anonymousKey appears in BOTH trimmed AND full /health payloads — the
trimmed payload's purpose is to be readable by anonymous clients so they
can self-bootstrap. Tested.
Startup warns on prereq failure (anonymous_key_advertised_but_denied /
anonymous_key_advertised_but_no_anonymous_key_exists) so the relaxed-
posture failure mode is observable. Graceful-degrade: server still
boots; handleHealth re-checks at request time and silently omits the
field when any prereq fails (defense-in-depth).
## D70 — ADR 0011 (anonymous-key deployment-context limits)
New ADR codifying the trusted-LAN-only invariant.
Trade-off documented: anonymous key advertised via /health = anyone who
can reach the server can read /health and use the key. Acceptable ONLY
when "anyone who can reach the server" ≈ "trusted family on the LAN".
Public-internet deployment = instant compromise.
Soft enforcement: server logs startup warn if BIND_ADDRESS resolves to
a public IP AND advertise_anonymous_key: true. No hard allowlist (TLS-
fronted private networks indistinguishable from public from server's
perspective).
Re-evaluation trigger: any time OLP gains "expose to public internet"
deployment mode (e.g., Cloudflare Tunnel guidance in README), revisit.
References ADR 0007 § 7 (identity classes), ADR 0010 § Phase 4 charter
D68-D70 line, OCP server.mjs:148/1454/1488/1555 (PROXY_ANONYMOUS_KEY
reference).
## Test count
658 → 672 (+14 D68-D70 tests across Suite 34: 5 keys.mjs unit + 6 /health
HTTP integration + 3 CLI integration).
## Scope discipline
NO /v1/messages entry surface (out of Phase 4 per ADR 0010).
NO olp-plugin/ Telegram plugin (D71-D73).
NO docs/integrations/*.md files (D71-D73).
NO CHANGELOG / package.json version bump (Phase 4 close handles versioning;
only package.json bin entry for olp-connect added).
NO new npm deps.
## Authority
- ADR 0010 § Phase 4 D-day plan D68-D70 line
- ADR 0011 (this commit — new ADR)
- ADR 0007 § 4 (manifest forward-compat unknown fields) + § 7 (identity
classes) + § 9 (keygen flow) — extended by D69 plaintext_advertise
- OCP ocp-connect /Users/taodeng/ocp/ocp-connect (port reference)
- OCP server.mjs:148, 1454, 1488, 1555 (PROXY_ANONYMOUS_KEY reference)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 3:
/health.anonymousKey + olp-connect zero-config UX)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: D68-D70 reviewer P1 + P2 fold-in — README impact note + listKeys redaction + schema_version note
Reviewer APPROVE WITH MINOR — 0 P0, 1 P1, 2 P2; all three folded in.
P1 — README impact note for new Phase 4 user-visible surfaces.
Per CLAUDE.md release_kit.new_feature_doc_expectations:
- new env / config knob → README § Environment Variables
- new endpoint or response field → README § API Endpoints
- new CLI surface → dedicated §
README now documents:
- /health.anonymousKey optional field (in API Endpoints table) with cross-
ref to ADR 0011 + the three-prereq gate
- streaming.heartbeat_interval_ms config (D61) + auth.advertise_anonymous_key
config (D69) under new "config.json keys introduced at Phase 4" subsection
- Operator CLI surfaces summary: olp / olp-connect / olp-keys keygen
--anonymous --advertise, with cross-refs to ADR 0010 + 0002 Amendment 7
P2-1 — lib/keys.mjs listKeys() now strips plaintext_advertise alongside
token_hash. Callers wanting the advertised plaintext for the /health
publication path MUST go through findAdvertisedKey() — the only sanctioned
read site. Defends against a future caller of listKeys() leaking the
plaintext into logs / HTTP responses / dashboards. Tests still pass
(no in-repo caller of listKeys depends on plaintext_advertise being
present).
P2-2 — ADR 0011 now documents the schema_version-stays-at-1 decision
explicitly. Additive optional fields don't require bump per ADR 0007 § 4,
but a future archaeologist asking "why didn't D69 bump schema_version?"
now has a one-line answer. Same paragraph documents the listKeys()
redaction policy in plain text alongside the manifest-field contract.
672/672 tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -100,7 +100,7 @@ Trigger types, fallback safety, idempotency rules, and the full example config l
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. |
|
| `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. |
|
||||||
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
|
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
|
||||||
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. |
|
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. **Optional `anonymousKey` field (D69 / Phase 4, v0.4.0)** appears in both trimmed and full payloads when `auth.advertise_anonymous_key: true` AND `auth.allow_anonymous: true` AND at least one non-revoked guest-tier key has `plaintext_advertise: true` (see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant). Default off — field absent when prereqs unmet. |
|
||||||
| `/dashboard` | GET | 3 | ✅ Shipped (D50 + D51) | Owner-only multi-provider dashboard HTML (4 panels: quota / 24h request stats / 30d spend trend / top fallback chains; 30s poll with visibilitychange pause). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
|
| `/dashboard` | GET | 3 | ✅ Shipped (D50 + D51) | Owner-only multi-provider dashboard HTML (4 panels: quota / 24h request stats / 30d spend trend / top fallback chains; 30s poll with visibilitychange pause). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
|
||||||
| `/v0/management/dashboard-data` | GET | 3 | ✅ Shipped (D50) | JSON aggregate consumed by the dashboard 30s poll: `{ generated_at, window_24h, cache_hit_24h, quota, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. Owner-only_block. |
|
| `/v0/management/dashboard-data` | GET | 3 | ✅ Shipped (D50) | JSON aggregate consumed by the dashboard 30s poll: `{ generated_at, window_24h, cache_hit_24h, quota, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. Owner-only_block. |
|
||||||
| `/v0/management/quota` | GET | 3 | ✅ Shipped (D50) | Per-provider quota snapshot via `provider.quotaStatus()` (subset of dashboard-data; useful for scripted monitoring). Owner-only_block. |
|
| `/v0/management/quota` | GET | 3 | ✅ Shipped (D50) | Per-provider quota snapshot via `provider.quotaStatus()` (subset of dashboard-data; useful for scripted monitoring). Owner-only_block. |
|
||||||
@@ -119,6 +119,21 @@ _placeholder — full table lands per-phase as variables are introduced._
|
|||||||
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
|
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
|
||||||
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
|
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
|
||||||
|
|
||||||
|
### `config.json` keys introduced at Phase 4
|
||||||
|
|
||||||
|
These live in `~/.olp/config.json` (not env vars) — they're documented here alongside the env-var table for discoverability.
|
||||||
|
|
||||||
|
| Config key | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `streaming.heartbeat_interval_ms` | `0` (disabled) | D61 / Phase 4. SSE keepalive comment frames during stream-silent windows. Set `>0` (e.g. `15000` for 15s) when OLP runs behind nginx / Cloudflare / Tailscale Funnel with idle-abort timeouts. |
|
||||||
|
| `auth.advertise_anonymous_key` | `false` | D69 / Phase 4. When `true`, surfaces an existing guest-tier key's plaintext via `/health.anonymousKey` so `olp-connect <ip>` can self-bootstrap clients on the LAN with zero out-of-band coordination. **Requires `auth.allow_anonymous: true` AND at least one key created via `olp-keys keygen --anonymous --advertise`.** Trusted-LAN-only — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md). |
|
||||||
|
|
||||||
|
### Operator CLI surfaces (Phase 4)
|
||||||
|
|
||||||
|
- `olp` (Node CLI at `bin/olp.mjs`): `status / health / usage / models / cache / providers / chain show / logs / restart / keys / doctor`. Run `npx olp --help` for full subcommand reference. `olp doctor --json` emits a machine-readable `next_action.ai_executable[]` payload designed for AI agents to self-repair OLP. See [ADR 0010](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md) § Phase 4 D-day plan and [ADR 0002 Amendment 7](./docs/adr/0002-plugin-architecture.md) (per-plugin `doctorChecks()` contract).
|
||||||
|
- `olp-connect` (bash at `bin/olp-connect`): zero-config LAN client setup — detects Cline / Continue.dev / Cursor / Aider / Claude Code / OpenClaw and configures each. Run `bash bin/olp-connect --help`. Requires `python3` for JSON parsing.
|
||||||
|
- `olp-keys keygen --anonymous --advertise`: creates a guest-tier key with the plaintext stored alongside its hash so `/health.anonymousKey` can publish it. Prints an explicit ADR-0011 warning at keygen time.
|
||||||
|
|
||||||
### Per-provider auth env vars
|
### Per-provider auth env vars
|
||||||
|
|
||||||
These variables configure credential discovery for each provider plugin. Setting the correct one for your provider is usually required for OLP to make successful requests.
|
These variables configure credential discovery for each provider plugin. Setting the correct one for your provider is usually required for OLP to make successful requests.
|
||||||
|
|||||||
Executable
+564
@@ -0,0 +1,564 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# bin/olp-connect — Lightweight client script to connect this machine to a remote
|
||||||
|
# OLP (Open LLM Proxy). Ported from OCP's `ocp-connect` per ADR 0010 § Phase 4
|
||||||
|
# D68-D70 charter; uses /health.anonymousKey when the remote operator opted in
|
||||||
|
# via `auth.advertise_anonymous_key: true` (ADR 0011).
|
||||||
|
#
|
||||||
|
# Authority:
|
||||||
|
# - ADR 0010 (Phase 4 charter — D68 line: client-side IDE auto-config)
|
||||||
|
# - ADR 0011 (anonymous-key deployment-context limits — trusted-LAN invariant)
|
||||||
|
# - OCP `ocp-connect` v1.3.0 (prior-art reference)
|
||||||
|
#
|
||||||
|
# Why bash (not Node like `olp` CLI):
|
||||||
|
# olp-connect MUST run on CLIENT machines that may not have a recent Node
|
||||||
|
# installed (parents' laptops, work machines, Raspberry Pi). bash + curl +
|
||||||
|
# python3 give maximum portability; this script does not import any OLP
|
||||||
|
# Node modules.
|
||||||
|
#
|
||||||
|
# Dependencies: bash >=4, curl, python3 (for /health JSON parsing).
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect -o olp-connect
|
||||||
|
# chmod +x olp-connect
|
||||||
|
#
|
||||||
|
# Or via npm/npx (once `npm install -g olp` is run on a machine that has Node):
|
||||||
|
# olp-connect <ip>
|
||||||
|
#
|
||||||
|
# Or run directly via curl-pipe:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect | bash -s -- <host-ip>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OLP_CONNECT_VERSION="0.4.0-phase4"
|
||||||
|
|
||||||
|
show_version() {
|
||||||
|
echo "olp-connect $OLP_CONNECT_VERSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
olp-connect — Connect this machine to a remote OLP (Open LLM Proxy)
|
||||||
|
|
||||||
|
Configures OPENAI_BASE_URL + OPENAI_API_KEY in your shell rc file (and macOS
|
||||||
|
launchctl env / Linux systemd user env), then detects installed IDEs (Cline,
|
||||||
|
Continue.dev, Cursor, Aider, OpenClaw, Claude Code) and prints / writes the
|
||||||
|
provider-specific configuration each needs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
olp-connect <host-ip> [options]
|
||||||
|
olp-connect --help
|
||||||
|
olp-connect --version
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--port PORT Port OLP listens on (default: 4567 — OLP v0.4.0+ default;
|
||||||
|
set 3456 if connecting to a pre-D60 OLP install)
|
||||||
|
--key API_KEY OLP API key (from `olp-keys keygen` on the server). When
|
||||||
|
omitted, the script reads /health.anonymousKey (if the
|
||||||
|
server opted in via auth.advertise_anonymous_key=true) or
|
||||||
|
prompts interactively.
|
||||||
|
--no-system-env Skip macOS launchctl setenv / Linux systemd env writes;
|
||||||
|
only update shell rc files.
|
||||||
|
--dry-run Print everything the script would do without modifying
|
||||||
|
any file or setting any env var.
|
||||||
|
--version Print version and exit
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
olp-connect 192.168.1.10
|
||||||
|
olp-connect 192.168.1.10 --port 8080
|
||||||
|
olp-connect 192.168.1.10 --key olp_AbcDef1234...
|
||||||
|
olp-connect 100.64.0.5 --dry-run
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
bash, curl, python3 (for /health JSON parsing)
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 success
|
||||||
|
1 bad arguments / unknown flag / missing required value
|
||||||
|
2 connectivity or auth failure / smoke test failure
|
||||||
|
|
||||||
|
Authority: ADR 0010 § Phase 4 D68-D70; ADR 0011 (anonymous-key trusted-LAN
|
||||||
|
invariant — when --key is auto-resolved from /health.anonymousKey, this
|
||||||
|
deployment MUST be on a trusted LAN).
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Globals populated by main() ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
DRY_RUN=false
|
||||||
|
NO_SYSTEM_ENV=false
|
||||||
|
|
||||||
|
# ── Logging helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log_info() { echo " $*"; }
|
||||||
|
log_step() { echo " → $*"; }
|
||||||
|
log_ok() { echo " ✓ $*"; }
|
||||||
|
log_warn() { echo " ⚠ $*"; }
|
||||||
|
log_err() { echo " ✗ $*" >&2; }
|
||||||
|
|
||||||
|
# Echo a state change (a write / env-set) before executing — operator can
|
||||||
|
# Ctrl-C if something looks wrong. Returns 0 always.
|
||||||
|
log_change() { echo " • $*"; }
|
||||||
|
|
||||||
|
# ── IDE detection + configuration ───────────────────────────────────────────
|
||||||
|
|
||||||
|
# Truncate long keys for display (avoid leaking via screenshot / screen share).
|
||||||
|
key_display() {
|
||||||
|
local k="$1"
|
||||||
|
if [[ -z "$k" ]]; then
|
||||||
|
echo "(none — anonymous; most IDEs require a non-empty API Key)"
|
||||||
|
elif [[ ${#k} -gt 16 ]]; then
|
||||||
|
echo "${k:0:8}...${k: -4}"
|
||||||
|
else
|
||||||
|
echo "$k"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Claude Code and print warn-only message. Per ADR 0010 § Out of
|
||||||
|
# Phase 4 scope, OLP does NOT ship /v1/messages and CC is not a supported
|
||||||
|
# client. The user is steered toward Cline + OLP.
|
||||||
|
detect_claude_code() {
|
||||||
|
if command -v claude &>/dev/null; then
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Claude Code (`command -v claude`)"
|
||||||
|
log_warn "Claude Code is NOT supported as an OLP client (OLP does not ship"
|
||||||
|
log_warn " /v1/messages — see ADR 0010 § Out-of-Phase-4-scope)."
|
||||||
|
log_warn " Recommended alternative: install Cline (VSCode extension) + OLP."
|
||||||
|
log_warn " Cline uses OpenAI-shape /v1/chat/completions which OLP DOES serve."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Cline VSCode extension and print manual-configure snippet.
|
||||||
|
# Cline cannot be auto-configured via env vars — user must paste into VSCode
|
||||||
|
# settings UI. We surface the values for them.
|
||||||
|
detect_cline() {
|
||||||
|
local base_url="$1" key="$2"
|
||||||
|
local exts=""
|
||||||
|
if command -v code &>/dev/null; then
|
||||||
|
exts=$(code --list-extensions 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$exts" && -d "$HOME/.vscode/extensions" ]]; then
|
||||||
|
exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Cline (VSCode extension)"
|
||||||
|
log_info " Cline must be configured via the VSCode settings UI."
|
||||||
|
log_info " Open VSCode → Cline panel → Settings → API Provider:"
|
||||||
|
log_info " API Provider: \"OpenAI Compatible\""
|
||||||
|
log_info " Base URL: $base_url/v1"
|
||||||
|
log_info " API Key: $(key_display "$key")"
|
||||||
|
log_info " Model ID: claude-sonnet-4-5 (or any model from /v1/models)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Continue.dev and write a `models:` entry to ~/.continue/config.yaml
|
||||||
|
# (idempotent — checks if an entry with the same name exists first).
|
||||||
|
detect_continue() {
|
||||||
|
local base_url="$1" key="$2"
|
||||||
|
local exts=""
|
||||||
|
if command -v code &>/dev/null; then
|
||||||
|
exts=$(code --list-extensions 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
local config_yaml="$HOME/.continue/config.yaml"
|
||||||
|
local config_json="$HOME/.continue/config.json"
|
||||||
|
|
||||||
|
local found=false
|
||||||
|
if echo "$exts" | grep -qi 'continue\.continue'; then found=true; fi
|
||||||
|
if [[ -f "$config_yaml" || -f "$config_json" ]]; then found=true; fi
|
||||||
|
|
||||||
|
if ! $found; then return 0; fi
|
||||||
|
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Continue.dev"
|
||||||
|
log_info " Configuration snippet for ~/.continue/config.yaml:"
|
||||||
|
log_info " models:"
|
||||||
|
log_info " - name: OLP Sonnet"
|
||||||
|
log_info " provider: openai"
|
||||||
|
log_info " model: claude-sonnet-4-5"
|
||||||
|
log_info " apiBase: $base_url/v1"
|
||||||
|
log_info " apiKey: $(key_display "$key")"
|
||||||
|
log_info " Note: Continue.dev autoreload-on-save is fragile; restart VSCode if"
|
||||||
|
log_info " the new model doesn't appear in the model selector."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Cursor and print manual snippet + known-fragility warning.
|
||||||
|
detect_cursor() {
|
||||||
|
local base_url="$1" key="$2"
|
||||||
|
local found=false
|
||||||
|
if command -v cursor &>/dev/null; then found=true; fi
|
||||||
|
if [[ -d "$HOME/.cursor" ]]; then found=true; fi
|
||||||
|
if [[ -d "/Applications/Cursor.app" ]]; then found=true; fi
|
||||||
|
|
||||||
|
if ! $found; then return 0; fi
|
||||||
|
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Cursor"
|
||||||
|
log_info " Cmd+Shift+P → 'Cursor Settings' → Models:"
|
||||||
|
log_info " OpenAI API Key: $(key_display "$key")"
|
||||||
|
log_info " Override OpenAI Base URL: $base_url/v1"
|
||||||
|
log_info " Custom OpenAI Models: claude-sonnet-4-5,claude-opus-4-1"
|
||||||
|
log_warn " Cursor's base-URL handling is known-fragile (issue #7128 et al);"
|
||||||
|
log_warn " if requests fail with 'malformed request', try removing then"
|
||||||
|
log_warn " re-adding the model in the Cursor models list."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Aider and write OPENAI_API_BASE / OPENAI_API_KEY to rc files.
|
||||||
|
# Aider reads these env vars at startup — already handled by the rc-file
|
||||||
|
# block in main(). We just announce detection here.
|
||||||
|
detect_aider() {
|
||||||
|
if command -v aider &>/dev/null; then
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Aider (`command -v aider`)"
|
||||||
|
log_info " Aider reads OPENAI_API_BASE + OPENAI_API_KEY from env."
|
||||||
|
log_info " These are already being written to your shell rc — open a fresh"
|
||||||
|
log_info " shell and run: aider --model openai/claude-sonnet-4-5"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect OpenClaw. Per Phase 4 D71-D73 (NOT in this PR), olp will ship
|
||||||
|
# olp-plugin/ for OpenClaw with full Telegram/Discord /olp slash commands.
|
||||||
|
# Until that ships, we just announce detection and link.
|
||||||
|
detect_openclaw() {
|
||||||
|
if command -v openclaw &>/dev/null || [[ -f "$HOME/.openclaw/openclaw.json" ]]; then
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: OpenClaw"
|
||||||
|
log_info " The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED."
|
||||||
|
log_info " When it ships, install with: openclaw plugin install olp"
|
||||||
|
log_info " For now, you can manually point OpenClaw at OLP via the OPENAI_BASE_URL"
|
||||||
|
log_info " env var (already written to your shell rc above)."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── rc-file helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Identify which shell rc files to write to. Returns paths on stdout, one per line.
|
||||||
|
detect_rc_files() {
|
||||||
|
local is_mac=false
|
||||||
|
[[ "$(uname)" == "Darwin" ]] && is_mac=true
|
||||||
|
if [[ "${SHELL:-}" == */fish ]]; then
|
||||||
|
log_warn "fish shell detected; writing to ~/.bashrc — add to fish config manually." >&2
|
||||||
|
echo "$HOME/.bashrc"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if $is_mac; then
|
||||||
|
# macOS Catalina+ default shell is zsh
|
||||||
|
[[ -f "$HOME/.bashrc" ]] && echo "$HOME/.bashrc"
|
||||||
|
[[ -f "$HOME/.zshrc" ]] || { $DRY_RUN || touch "$HOME/.zshrc"; }
|
||||||
|
echo "$HOME/.zshrc"
|
||||||
|
else
|
||||||
|
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && echo "$HOME/.bashrc"
|
||||||
|
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && echo "$HOME/.zshrc"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove any previously-written OLP block from an rc file (idempotent).
|
||||||
|
# The block is bracketed by:
|
||||||
|
# # OLP LAN (added by olp-connect) ... # /OLP LAN
|
||||||
|
strip_olp_block() {
|
||||||
|
local rc_file="$1"
|
||||||
|
[[ -f "$rc_file" ]] || return 0
|
||||||
|
if $DRY_RUN; then
|
||||||
|
if grep -qF '# OLP LAN (added by olp-connect)' "$rc_file" 2>/dev/null; then
|
||||||
|
log_change "[dry-run] would strip existing OLP block from $rc_file"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
python3 - "$rc_file" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
except OSError:
|
||||||
|
sys.exit(0)
|
||||||
|
out = []
|
||||||
|
skip = False
|
||||||
|
for line in lines:
|
||||||
|
s = line.rstrip('\n')
|
||||||
|
if s == '# OLP LAN (added by olp-connect)':
|
||||||
|
skip = True
|
||||||
|
continue
|
||||||
|
if skip and s == '# /OLP LAN':
|
||||||
|
skip = False
|
||||||
|
continue
|
||||||
|
if skip:
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.writelines(out)
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Append a new OLP block to an rc file.
|
||||||
|
append_olp_block() {
|
||||||
|
local rc_file="$1" base_url="$2" key="$3"
|
||||||
|
if $DRY_RUN; then
|
||||||
|
log_change "[dry-run] would append OLP block to $rc_file:"
|
||||||
|
log_change " # OLP LAN (added by olp-connect)"
|
||||||
|
log_change " export OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
[[ -n "$key" ]] && log_change " export OPENAI_API_KEY=$(key_display "$key")"
|
||||||
|
log_change " # /OLP LAN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# OLP LAN (added by olp-connect)"
|
||||||
|
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
echo "export OPENAI_API_KEY=$key"
|
||||||
|
fi
|
||||||
|
echo "# /OLP LAN"
|
||||||
|
} >> "$rc_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── System-level env (macOS launchctl / Linux systemd user) ────────────────
|
||||||
|
|
||||||
|
set_system_env() {
|
||||||
|
local base_url="$1" key="$2"
|
||||||
|
if $NO_SYSTEM_ENV; then
|
||||||
|
log_info "Skipping system-level env (--no-system-env)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "$(uname)" == "Darwin" ]]; then
|
||||||
|
if $DRY_RUN; then
|
||||||
|
log_change "[dry-run] would launchctl setenv OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
[[ -n "$key" ]] && log_change "[dry-run] would launchctl setenv OPENAI_API_KEY=$(key_display "$key")"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null || log_warn "launchctl setenv OPENAI_BASE_URL failed"
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null || log_warn "launchctl setenv OPENAI_API_KEY failed"
|
||||||
|
fi
|
||||||
|
log_ok "launchctl setenv applied (visible to GUI apps + daemons)"
|
||||||
|
log_info " Note: launchctl env vars reset on reboot. Re-run olp-connect after restart"
|
||||||
|
log_info " or add the script to Login Items."
|
||||||
|
else
|
||||||
|
local env_dir="$HOME/.config/environment.d"
|
||||||
|
if $DRY_RUN; then
|
||||||
|
log_change "[dry-run] would write $env_dir/olp.conf"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$env_dir" 2>/dev/null
|
||||||
|
{
|
||||||
|
echo "OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
echo "OPENAI_API_KEY=$key"
|
||||||
|
fi
|
||||||
|
} > "$env_dir/olp.conf"
|
||||||
|
log_ok "Wrote $env_dir/olp.conf (applies to systemd user services after re-login)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local host="" port=4567 key=""
|
||||||
|
|
||||||
|
# Parse args (POSIX-style; --flag value AND --flag=value both accepted)
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--port) port="${2:?--port requires a value}"; shift 2 ;;
|
||||||
|
--port=*) port="${1#*=}"; shift ;;
|
||||||
|
--key) key="${2:?--key requires a value}"
|
||||||
|
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
|
||||||
|
shift 2 ;;
|
||||||
|
--key=*) key="${1#*=}"; shift ;;
|
||||||
|
--no-system-env) NO_SYSTEM_ENV=true; shift ;;
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
--version) show_version; exit 0 ;;
|
||||||
|
--help|-h) show_help; exit 0 ;;
|
||||||
|
--*) log_err "Unknown option: $1"; show_help >&2; exit 1 ;;
|
||||||
|
*) host="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$host" ]]; then
|
||||||
|
log_err "host IP is required."
|
||||||
|
echo "" >&2
|
||||||
|
show_help >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||||
|
log_err "invalid host '$host'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dependency check
|
||||||
|
for cmd in curl python3; do
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
log_err "'$cmd' is required but not found in PATH."
|
||||||
|
[[ "$cmd" == "python3" ]] && log_err " python3 is used for /health + /v1/models JSON parsing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local base_url="http://$host:$port"
|
||||||
|
|
||||||
|
echo "olp-connect v$OLP_CONNECT_VERSION"
|
||||||
|
echo "─────────────────────────────────────"
|
||||||
|
log_info "Remote: $base_url"
|
||||||
|
$DRY_RUN && log_info "Mode: DRY RUN (no files will be modified, no env vars will be set)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: connectivity probe. We capture status separately from body so we
|
||||||
|
# can distinguish "TCP/HTTP unreachable" from "reached but 401" (the latter
|
||||||
|
# is a known surface when the server has auth.allow_anonymous=false AND
|
||||||
|
# auth.advertise_anonymous_key=false — user MUST provide --key).
|
||||||
|
log_step "Probing /health..."
|
||||||
|
local probe_body probe_status
|
||||||
|
probe_body=$(curl -s --max-time 5 -o /tmp/olp-connect-health.$$ -w "%{http_code}" "$base_url/health" 2>/dev/null || echo "000")
|
||||||
|
probe_status="$probe_body"
|
||||||
|
if [[ -f /tmp/olp-connect-health.$$ ]]; then
|
||||||
|
probe_body=$(cat /tmp/olp-connect-health.$$ 2>/dev/null || echo "")
|
||||||
|
rm -f /tmp/olp-connect-health.$$
|
||||||
|
fi
|
||||||
|
if [[ "$probe_status" == "000" ]]; then
|
||||||
|
log_err "Cannot reach $base_url/health (connection refused / timeout / DNS)"
|
||||||
|
log_err " Ensure OLP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
||||||
|
log_err " Default port changed 3456 → 4567 at OLP v0.4.0; pass --port 3456 for older installs."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$probe_status" == "401" ]]; then
|
||||||
|
log_warn "Server reachable but /health returned 401."
|
||||||
|
log_warn " Either the operator has not enabled auth.advertise_anonymous_key, or"
|
||||||
|
log_warn " the server requires auth (auth.allow_anonymous=false)."
|
||||||
|
if [[ -z "$key" ]]; then
|
||||||
|
log_err " Pass --key olp_... to continue, or ask the operator to advertise an anonymous key (see ADR 0011)."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
# If user supplied --key, we proceed without /health body (auth-required mode).
|
||||||
|
log_info " Proceeding with the --key you supplied; skipping /health.anonymousKey discovery."
|
||||||
|
local health_json=""
|
||||||
|
local remote_version="?"
|
||||||
|
else
|
||||||
|
if [[ "$probe_status" != "200" ]]; then
|
||||||
|
log_err "/health returned HTTP $probe_status (expected 200 or 401)."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
local health_json="$probe_body"
|
||||||
|
local remote_version
|
||||||
|
remote_version=$(echo "$health_json" | python3 -c "import sys,json
|
||||||
|
try: print(json.loads(sys.stdin.read()).get('version','?'))
|
||||||
|
except: print('?')" 2>/dev/null || echo "?")
|
||||||
|
log_ok "Connected — OLP v$remote_version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 2: auth resolution
|
||||||
|
if [[ -z "$key" ]]; then
|
||||||
|
# Try /health.anonymousKey first (D69 / ADR 0011 opt-in).
|
||||||
|
local anon_key
|
||||||
|
anon_key=$(echo "$health_json" | python3 -c "import sys,json
|
||||||
|
try:
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
k = d.get('anonymousKey')
|
||||||
|
print(k if isinstance(k, str) and k else '')
|
||||||
|
except: print('')" 2>/dev/null || echo "")
|
||||||
|
if [[ -n "$anon_key" ]]; then
|
||||||
|
key="$anon_key"
|
||||||
|
log_ok "Using server-advertised anonymous key: $(key_display "$key")"
|
||||||
|
log_info " (set by remote via auth.advertise_anonymous_key=true; see ADR 0011 for"
|
||||||
|
log_info " the trusted-LAN-only invariant — this assumes you and the remote are"
|
||||||
|
log_info " on the same trusted network)"
|
||||||
|
else
|
||||||
|
# No advertised key; prompt interactively (skip in dry-run for non-TTY safety)
|
||||||
|
if $DRY_RUN; then
|
||||||
|
log_info "[dry-run] would prompt for API key here (no --key + no anonymousKey)"
|
||||||
|
key="<prompted-at-runtime>"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
log_info "Remote does not advertise an anonymous key."
|
||||||
|
log_info "Ask the OLP operator to run on the server: olp-keys keygen --name <your-label>"
|
||||||
|
printf " Enter OLP API key: "
|
||||||
|
{ read -rs key </dev/tty; } 2>/dev/null || key=""
|
||||||
|
echo
|
||||||
|
if [[ -z "$key" ]]; then
|
||||||
|
log_err "No key provided and the remote did not advertise an anonymous key."
|
||||||
|
log_err " Re-run with: olp-connect $host --key olp_..."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 3: smoke test /v1/models
|
||||||
|
log_step "Smoke-testing /v1/models..."
|
||||||
|
if $DRY_RUN && [[ "$key" == "<prompted-at-runtime>" ]]; then
|
||||||
|
log_info "[dry-run] skipping smoke test (no real key)"
|
||||||
|
else
|
||||||
|
local models_out models_ok=0
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
models_out=$(curl -sf --max-time 10 \
|
||||||
|
-H "Authorization: Bearer $key" \
|
||||||
|
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||||
|
else
|
||||||
|
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||||
|
fi
|
||||||
|
if [[ $models_ok -eq 0 ]]; then
|
||||||
|
log_err "/v1/models request failed — key may be invalid, revoked, or not allowed for any provider."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
local model_count
|
||||||
|
model_count=$(echo "$models_out" | python3 -c "import sys,json
|
||||||
|
try: print(len(json.loads(sys.stdin.read()).get('data', [])))
|
||||||
|
except: print('?')" 2>/dev/null || echo "?")
|
||||||
|
log_ok "/v1/models OK — $model_count models available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 4: write shell rc files
|
||||||
|
log_step "Writing shell rc files..."
|
||||||
|
local rc_files=()
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -n "$line" ]] && rc_files+=("$line")
|
||||||
|
done < <(detect_rc_files)
|
||||||
|
|
||||||
|
if [[ ${#rc_files[@]} -eq 0 ]]; then
|
||||||
|
log_warn "No shell rc files detected; falling back to ~/.bashrc"
|
||||||
|
rc_files=("$HOME/.bashrc")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for rc_file in "${rc_files[@]}"; do
|
||||||
|
log_change "stripping old OLP block from $(basename "$rc_file") (idempotent)"
|
||||||
|
strip_olp_block "$rc_file"
|
||||||
|
log_change "appending new OLP block to $(basename "$rc_file")"
|
||||||
|
append_olp_block "$rc_file" "$base_url" "$key"
|
||||||
|
done
|
||||||
|
log_ok "Shell rc files updated:"
|
||||||
|
for rc_file in "${rc_files[@]}"; do
|
||||||
|
log_info " $rc_file"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 5: system-level env (macOS launchctl / Linux systemd)
|
||||||
|
log_step "Setting system-level env..."
|
||||||
|
set_system_env "$base_url" "$key"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 6: IDE detection + per-IDE config
|
||||||
|
log_step "Detecting installed IDEs..."
|
||||||
|
detect_claude_code
|
||||||
|
detect_cline "$base_url" "$key"
|
||||||
|
detect_continue "$base_url" "$key"
|
||||||
|
detect_cursor "$base_url" "$key"
|
||||||
|
detect_aider
|
||||||
|
detect_openclaw
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 7: final summary
|
||||||
|
log_step "Done."
|
||||||
|
log_info "OLP base URL: $base_url/v1"
|
||||||
|
log_info "OLP API key: $(key_display "$key")"
|
||||||
|
log_info ""
|
||||||
|
log_info "Test it: open a fresh shell, run:"
|
||||||
|
log_info " curl -sf -H \"Authorization: Bearer \$OPENAI_API_KEY\" $base_url/v1/models | python3 -m json.tool | head -20"
|
||||||
|
log_info ""
|
||||||
|
log_info "Reload your current shell to apply env changes:"
|
||||||
|
for rc_file in "${rc_files[@]}"; do
|
||||||
|
log_info " source $rc_file"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+33
-4
@@ -79,6 +79,7 @@ const USAGE = `OLP key management CLI
|
|||||||
Usage:
|
Usage:
|
||||||
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
|
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
|
||||||
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
|
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
|
||||||
|
olp-keys keygen --anonymous --advertise [--name=<label>] [--providers=<csv>]
|
||||||
olp-keys list [--owner-only] [--include-revoked]
|
olp-keys list [--owner-only] [--include-revoked]
|
||||||
olp-keys revoke --id=<key-id>
|
olp-keys revoke --id=<key-id>
|
||||||
|
|
||||||
@@ -86,7 +87,8 @@ Common flags:
|
|||||||
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
|
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
|
||||||
--help Print this message
|
--help Print this message
|
||||||
|
|
||||||
Authority: ADR 0007 § 9 (bootstrap & recovery).`;
|
Authority: ADR 0007 § 9 (bootstrap & recovery); ADR 0011 (anonymous-key
|
||||||
|
deployment-context limits — trusted-LAN-only invariant for --advertise).`;
|
||||||
|
|
||||||
// ── Subcommand implementations ────────────────────────────────────────────
|
// ── Subcommand implementations ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -94,16 +96,36 @@ async function cmdKeygen(flags, ioOut, ioErr) {
|
|||||||
const olpHome = flags['olp-home'];
|
const olpHome = flags['olp-home'];
|
||||||
const owner = flags.owner === true;
|
const owner = flags.owner === true;
|
||||||
const force = flags.force === true;
|
const force = flags.force === true;
|
||||||
|
// D69 (ADR 0011): --anonymous is shorthand for "create a guest-tier key
|
||||||
|
// intended to be the zero-config /health.anonymousKey advertise key".
|
||||||
|
// It implies --tier=guest and defaults the name to 'anonymous'. The
|
||||||
|
// distinct field that actually triggers /health advertisement is
|
||||||
|
// --advertise (writes plaintext_advertise into the manifest). Either
|
||||||
|
// flag works on its own (--anonymous without --advertise is just a
|
||||||
|
// conventionally-named guest key); --advertise without --anonymous is
|
||||||
|
// accepted (operator may want to advertise a named guest key).
|
||||||
|
const isAnonymous = flags.anonymous === true;
|
||||||
|
const advertise = flags.advertise === true;
|
||||||
let tier = flags.tier;
|
let tier = flags.tier;
|
||||||
if (owner) tier = 'owner';
|
if (owner) tier = 'owner';
|
||||||
|
if (isAnonymous && !owner) tier = 'guest';
|
||||||
if (!tier) tier = 'guest';
|
if (!tier) tier = 'guest';
|
||||||
if (tier !== 'owner' && tier !== 'guest') {
|
if (tier !== 'owner' && tier !== 'guest') {
|
||||||
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
|
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const name = flags.name || (owner ? 'owner' : null);
|
// D69: reject --owner --advertise (would expose owner identity unauthenticated).
|
||||||
|
if (advertise && tier !== 'guest') {
|
||||||
|
ioErr(`Error: --advertise requires guest tier (cannot advertise owner-tier key plaintext). See ADR 0011.\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let name = flags.name;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
ioErr('Error: --name is required (or use --owner to default to "owner").\n');
|
if (owner) name = 'owner';
|
||||||
|
else if (isAnonymous) name = 'anonymous';
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
ioErr('Error: --name is required (or use --owner to default to "owner", or --anonymous to default to "anonymous").\n');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const providersFlag = flags.providers;
|
const providersFlag = flags.providers;
|
||||||
@@ -136,7 +158,7 @@ async function cmdKeygen(flags, ioOut, ioErr) {
|
|||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome });
|
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome, plaintext_advertise: advertise });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
|
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
|
||||||
return 2;
|
return 2;
|
||||||
@@ -150,6 +172,13 @@ async function cmdKeygen(flags, ioOut, ioErr) {
|
|||||||
ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`);
|
ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`);
|
||||||
ioOut(` created_at: ${result.manifest.created_at}\n`);
|
ioOut(` created_at: ${result.manifest.created_at}\n`);
|
||||||
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`);
|
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`);
|
||||||
|
if (advertise) {
|
||||||
|
// D69 (ADR 0011): explicit warning when plaintext lands on disk + opt-in surface.
|
||||||
|
ioOut(` advertise: YES — plaintext stored in manifest; surfaced via /health.anonymousKey\n`);
|
||||||
|
ioErr(`\n WARNING: this key's plaintext is now stored on disk + will be exposed via\n`);
|
||||||
|
ioErr(` /health.anonymousKey when auth.advertise_anonymous_key=true AND\n`);
|
||||||
|
ioErr(` auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.\n`);
|
||||||
|
}
|
||||||
ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`);
|
ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`);
|
||||||
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`);
|
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`);
|
||||||
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
|
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# ADR 0011 — Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant)
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-05-26)
|
||||||
|
**Date:** 2026-05-26
|
||||||
|
**D-day:** D70 (lands alongside D68 `olp-connect` + D69 `/health.anonymousKey`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0010 § Phase 4 D68-D70 charter scoped a three-deliverable bundle:
|
||||||
|
|
||||||
|
- **D68** `olp-connect <ip>` — client-side bash script that auto-configures a
|
||||||
|
family member's machine to point at a remote OLP instance, including IDE
|
||||||
|
detection (Cline / Continue.dev / Cursor / Aider / OpenClaw) and rc-file +
|
||||||
|
system-level env var writes.
|
||||||
|
- **D69** `/health.anonymousKey` field — opt-in (`auth.advertise_anonymous_key:
|
||||||
|
true` in `~/.olp/config.json`; default `false`) surface that emits the
|
||||||
|
plaintext of a designated guest-tier key so `olp-connect` can pick it up
|
||||||
|
with zero-config (no out-of-band token paste).
|
||||||
|
- **D70** this ADR — codifies the deployment-context limits that make D69
|
||||||
|
safe.
|
||||||
|
|
||||||
|
The D69 mechanism is a deliberate port of OCP's clever `PROXY_ANONYMOUS_KEY` +
|
||||||
|
`/health.anonymousKey` pattern (OCP `server.mjs:148, 1454, 1488, 1555`,
|
||||||
|
shipped 2026-04 under OCP issue #12 § 14 Path A) which made family-member
|
||||||
|
onboarding go from "maintainer texts API key + edits IDE config" to
|
||||||
|
`curl -fsSL .../ocp-connect | bash -s -- <ip>`. The OCP pattern works on
|
||||||
|
trusted family LAN deployments; it would catastrophically fail on a public
|
||||||
|
internet deployment. ADR 0011 makes the trust assumption explicit before OLP
|
||||||
|
inherits the pattern.
|
||||||
|
|
||||||
|
The ADR also pins three implementation details that are NOT obvious from
|
||||||
|
reading the D69 patch alone:
|
||||||
|
|
||||||
|
1. The plaintext token must live on disk SOMEWHERE for the server to surface
|
||||||
|
it. ADR 0007 § 5 + § 6.2 explicitly forbid plaintext storage in the
|
||||||
|
default manifest. D69 introduces an explicit **opt-in** `plaintext_advertise`
|
||||||
|
manifest field for ONLY the advertised key — every other key retains the
|
||||||
|
ADR 0007 § 5 hash-only contract.
|
||||||
|
2. The advertised key is **guest-tier**, not owner-tier. Owner-tier
|
||||||
|
advertisement is rejected at keygen time AND at config-load time —
|
||||||
|
exposing the owner identity unauthenticated would grant any LAN caller
|
||||||
|
`/health` full payload, `/v0/management/*` mutating access, and
|
||||||
|
`X-OLP-Fallback-Detail` visibility — the exact inverse of the advertise
|
||||||
|
key's intent (a low-privilege zero-config tier).
|
||||||
|
3. Three prerequisites MUST hold simultaneously for `/health.anonymousKey`
|
||||||
|
to be emitted; missing any one is logged at startup but the server still
|
||||||
|
boots — graceful-degrade rather than refuse-to-start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Three-prerequisite gate (server-side)
|
||||||
|
|
||||||
|
`/health` emits the `anonymousKey` field if and only if ALL THREE hold:
|
||||||
|
|
||||||
|
1. `auth.advertise_anonymous_key === true` in `~/.olp/config.json` (default
|
||||||
|
`false` — opt-in).
|
||||||
|
2. `auth.allow_anonymous === true` (the anonymous tier must be reachable for
|
||||||
|
the advertised key to be meaningful to zero-config callers; advertising a
|
||||||
|
key into a deployment that rejects anonymous requests is incoherent).
|
||||||
|
3. At least one active (`revoked_at === null`) manifest under `~/.olp/keys/`
|
||||||
|
carries a non-empty `plaintext_advertise: "olp_..."` field.
|
||||||
|
|
||||||
|
When prerequisite (1) holds but (2) or (3) fails, the server logs a
|
||||||
|
startup warn (`anonymous_key_advertised_but_denied` or
|
||||||
|
`anonymous_key_advertised_but_no_anonymous_key_exists`) but starts normally
|
||||||
|
and simply omits the field from `/health` responses.
|
||||||
|
|
||||||
|
### Plaintext storage mechanism (`plaintext_advertise` manifest field)
|
||||||
|
|
||||||
|
ADR 0007 § 5 forbids plaintext storage anywhere. D69 introduces a single,
|
||||||
|
**explicitly opt-in** exception: the manifest of the designated advertised
|
||||||
|
key gains a `plaintext_advertise` field whose value is the plaintext token.
|
||||||
|
|
||||||
|
This field is written ONLY when the operator runs:
|
||||||
|
|
||||||
|
```
|
||||||
|
olp-keys keygen --anonymous --advertise
|
||||||
|
```
|
||||||
|
|
||||||
|
(or `--advertise` alone on a guest-tier `keygen` invocation; `--anonymous`
|
||||||
|
is a friendly shorthand for `--tier=guest --name=anonymous`). The keygen
|
||||||
|
command surfaces an explicit `WARNING` to stderr at creation time:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING: this key's plaintext is now stored on disk + will be exposed via
|
||||||
|
/health.anonymousKey when auth.advertise_anonymous_key=true AND
|
||||||
|
auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.
|
||||||
|
```
|
||||||
|
|
||||||
|
Every other key (every existing key, and every newly-created key without
|
||||||
|
`--advertise`) retains the ADR 0007 § 5 hash-only contract — `manifest.json`
|
||||||
|
contains `token_hash` and NEVER `plaintext_advertise`.
|
||||||
|
|
||||||
|
**Schema-version note (D69 reviewer P2-2).** This adds a new optional field
|
||||||
|
to the manifest. Per ADR 0007 § 4 ("Increment `schema_version` on any
|
||||||
|
non-additive change"), additive optional fields do NOT require a
|
||||||
|
`schema_version` bump — older parsers ignore unknown fields per the same
|
||||||
|
section's forward-compat rule. The manifest stays at `schema_version: 1`.
|
||||||
|
Documented here so a future archaeologist asking "why didn't D69 bump
|
||||||
|
`schema_version`?" has a one-line answer.
|
||||||
|
|
||||||
|
**`listKeys()` redaction (D69 reviewer P2-1).** `lib/keys.mjs listKeys()`
|
||||||
|
strips BOTH `token_hash` AND `plaintext_advertise` from its return value.
|
||||||
|
Callers wanting the advertised plaintext for the `/health` publication
|
||||||
|
path MUST go through `findAdvertisedKey()` — the only sanctioned read
|
||||||
|
site. This protects against a future caller of `listKeys()` accidentally
|
||||||
|
emitting the plaintext into logs / HTTP responses / dashboards.
|
||||||
|
|
||||||
|
### Tier restriction (guest only)
|
||||||
|
|
||||||
|
`createKey()` rejects `plaintext_advertise: true` for `owner_tier: 'owner'`
|
||||||
|
with the error `createKey: plaintext_advertise requires owner_tier="guest"`.
|
||||||
|
The CLI also rejects `--owner --advertise` with a clear error pointing at
|
||||||
|
this ADR.
|
||||||
|
|
||||||
|
Rationale: owner-tier confers `/health` full payload visibility,
|
||||||
|
`/v0/management/*` mutating access, and `X-OLP-Fallback-Detail` header
|
||||||
|
visibility. Advertising owner-tier plaintext unauthenticated would let any
|
||||||
|
LAN caller assume the owner identity — the exact opposite of the design
|
||||||
|
intent.
|
||||||
|
|
||||||
|
### Trusted-LAN deployment invariant
|
||||||
|
|
||||||
|
`auth.advertise_anonymous_key: true` is permitted ONLY when the OLP server
|
||||||
|
is bound to a trust-equivalent address space:
|
||||||
|
|
||||||
|
| Tier | Address space | Permitted? |
|
||||||
|
|------|---------------|------------|
|
||||||
|
| Loopback | `127.0.0.0/8` | yes |
|
||||||
|
| RFC 1918 LAN | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | yes |
|
||||||
|
| Tailnet | `100.64.0.0/10` (CGNAT range used by Tailscale) | yes |
|
||||||
|
| Localhost domains | `localhost`, `*.local`, `*.internal` | yes |
|
||||||
|
| Public internet | any routable IPv4/IPv6 outside the above | NO |
|
||||||
|
|
||||||
|
This is a **soft constraint** at v0.4.0 — OLP does not enforce IP-allowlist
|
||||||
|
or BIND_ADDRESS inspection. The constraint is documented here, surfaced in
|
||||||
|
README § "Anonymous-key advertise mode (trusted-LAN-only)", and warned-but-
|
||||||
|
not-blocked at server startup when `auth.advertise_anonymous_key=true` and
|
||||||
|
the bind address looks public.
|
||||||
|
|
||||||
|
Hard enforcement (refuse to start when bind is public + advertise enabled)
|
||||||
|
is deferred. The maintainer's deployments are LAN-only, the family-scale
|
||||||
|
audience cannot tolerate a startup-refuse mode that bricks the proxy on
|
||||||
|
ambiguous network topology (e.g., TLS-fronted private network where the
|
||||||
|
underlying bind IP IS public but the network itself is trusted), and the
|
||||||
|
trade-off in ADR 0010 explicitly accepted operator-discretion gates for
|
||||||
|
soft constraints of this class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
The advertised anonymous key is **public** within the boundary of "anyone
|
||||||
|
who can reach `GET /health`." Anyone within that boundary can read the
|
||||||
|
plaintext from `/health.anonymousKey` and use it for `/v1/chat/completions`,
|
||||||
|
`/v1/models`, etc.
|
||||||
|
|
||||||
|
| Deployment | Boundary | Acceptable? |
|
||||||
|
|------------|----------|-------------|
|
||||||
|
| Mac mini + Tailscale, only family devices on tailnet | family devices | YES |
|
||||||
|
| Home LAN with no guest WiFi, no port-forward | household + neighbors-within-WiFi-range | YES (within risk tolerance) |
|
||||||
|
| Home LAN with guest WiFi joined to same VLAN as proxy | EVERYONE who visits and connects to guest WiFi | borderline; treat with caution |
|
||||||
|
| Coffee shop / open WiFi | EVERYONE physically present | NO |
|
||||||
|
| Public internet via Cloudflare Tunnel / port-forward / VPS | EVERYONE on the internet | NO — instant compromise |
|
||||||
|
|
||||||
|
The capability gain for an attacker who reads `/health.anonymousKey` is
|
||||||
|
**equal to the capability the operator deliberately granted the
|
||||||
|
anonymous-tier key**:
|
||||||
|
|
||||||
|
- `providers_enabled` (when `'*'`, the attacker can dispatch any provider —
|
||||||
|
burning the operator's subscription quotas).
|
||||||
|
- `/v1/chat/completions` access (LLM use under the operator's billing).
|
||||||
|
- Cache pollution under `__anonymous__` namespace (per ADR 0007 § 7.1; the
|
||||||
|
advertised guest key uses its own `<key-id>` namespace — but anonymous
|
||||||
|
callers who DON'T present the key use `__anonymous__`).
|
||||||
|
|
||||||
|
What the attacker does NOT get:
|
||||||
|
|
||||||
|
- `/health` full payload — gated to `owner_tier === 'owner'` (ADR 0007 § 7.1).
|
||||||
|
- `/v0/management/*` mutating endpoints — gated to owner (ADR 0008 § 7).
|
||||||
|
- `X-OLP-Fallback-Detail` header — `'owner_only'` policy default (ADR 0007 § 7.2).
|
||||||
|
- The owner key's plaintext (which is never stored anywhere; only its
|
||||||
|
`token_hash` is on disk per ADR 0007 § 5).
|
||||||
|
|
||||||
|
The "burn the operator's subscription quotas" failure mode is bounded by
|
||||||
|
the per-provider quota limits AND the maintainer's monitoring (`/health`
|
||||||
|
owner-view shows quota status per provider; `/v0/management/audit` shows
|
||||||
|
per-key usage). Detection is fast; the question is how much quota the
|
||||||
|
attacker can burn between compromise and key revocation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `olp-connect` integration (D68 client-side)
|
||||||
|
|
||||||
|
`bin/olp-connect <ip>` queries `GET /health` as its first action. If the
|
||||||
|
response contains `anonymousKey: "olp_..."`, the script uses that value
|
||||||
|
silently for the rest of the run (printing a one-line `Using server-
|
||||||
|
advertised anonymous key: olp_...XXXX` notice + a pointer to this ADR).
|
||||||
|
This is what makes `olp-connect <ip>` a true zero-config command — no
|
||||||
|
out-of-band token paste needed.
|
||||||
|
|
||||||
|
If `anonymousKey` is absent (the default, when `auth.advertise_anonymous_key`
|
||||||
|
is false), the script falls back to interactive prompt or `--key` flag.
|
||||||
|
|
||||||
|
`olp-connect` does NOT perform any of the trusted-LAN soft-checks itself —
|
||||||
|
it trusts that an operator who set `auth.advertise_anonymous_key: true`
|
||||||
|
knows their deployment context. The script does, however, document the
|
||||||
|
trade-off in its `--help` output and prints the ADR 0011 reference
|
||||||
|
alongside the "using server-advertised key" notice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Re-evaluation triggers
|
||||||
|
|
||||||
|
Re-open this ADR when ANY of the following fires:
|
||||||
|
|
||||||
|
1. OLP gains a "expose to public internet" deployment mode in the README
|
||||||
|
(e.g., Cloudflare Tunnel guidance, ngrok recipe). At that point the
|
||||||
|
soft-constraint MUST become a hard constraint (bind-address inspection
|
||||||
|
at startup, refusal to enable `advertise_anonymous_key` when bind is
|
||||||
|
public — likely with a separate `OLP_TRUSTED_PUBLIC_OVERRIDE=1` env
|
||||||
|
escape hatch for operators who run their own TLS termination).
|
||||||
|
2. The OCP `/health.anonymousKey` model is found to have caused a
|
||||||
|
real-world quota-burn incident; that learning amends this ADR.
|
||||||
|
3. Phase 5 introduces multi-tenant SaaS-like deployments (currently
|
||||||
|
non-goal per ADR 0001); the entire family-scale assumption is
|
||||||
|
re-examined.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive.**
|
||||||
|
|
||||||
|
- Family-member onboarding becomes a single command: `olp-connect <ip>`. No
|
||||||
|
out-of-band token paste. No "wait, what's the API key?" friction loop.
|
||||||
|
- The trust trade-off is now an explicit, single-knob config decision, not
|
||||||
|
an implicit consequence of OCP-pattern inheritance.
|
||||||
|
- The `plaintext_advertise` field is a single auditable on-disk surface —
|
||||||
|
`grep plaintext_advertise ~/.olp/keys/*/manifest.json` answers "which key
|
||||||
|
is advertised?" definitively, and an operator who wants to disable the
|
||||||
|
feature can simply revoke that key.
|
||||||
|
- Owner-tier advertisement is impossible (both at keygen and at config
|
||||||
|
load), eliminating an entire class of foot-gun.
|
||||||
|
|
||||||
|
**Negative.**
|
||||||
|
|
||||||
|
- ADR 0007 § 5's "no plaintext on disk, ever" property is weakened to "no
|
||||||
|
plaintext on disk except for ONE explicitly-opted-in field on ONE key."
|
||||||
|
The exception is narrow and audit-grep-able but the property is no
|
||||||
|
longer absolute.
|
||||||
|
- Operators who enable advertise mode then move the deployment from LAN to
|
||||||
|
public internet (e.g., add a Cloudflare Tunnel without revisiting the
|
||||||
|
config) silently invert the threat model. The startup warn for "public
|
||||||
|
bind detected" does not currently fire (soft constraint per § "Trusted-
|
||||||
|
LAN deployment invariant" above).
|
||||||
|
- The OCP precedent shows operators sometimes share `olp-connect <ip>`
|
||||||
|
invocations in chat / docs that include their IP; an LLM training corpus
|
||||||
|
could harvest these IPs. The advertised key is only useful while the
|
||||||
|
network reaches the IP, but the IP-disclosure surface grows.
|
||||||
|
|
||||||
|
**Neutral.**
|
||||||
|
|
||||||
|
- The plaintext storage is per-key, not global. Revoking the advertised key
|
||||||
|
removes the plaintext exposure within one filesystem write (the manifest
|
||||||
|
stays on disk for audit attribution per ADR 0007 § 6.1, but `revoked_at`
|
||||||
|
becomes non-null and `findAdvertisedKey()` skips revoked manifests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
1. **Store plaintext in `config.json` directly.** Rejected. Mixes secrets
|
||||||
|
with operational config; complicates git-crypt boundary; loses the
|
||||||
|
per-key revocation path (you'd have to edit JSON to "revoke" the
|
||||||
|
exposure rather than running `olp-keys revoke --id=<id>`).
|
||||||
|
2. **Add an `anonymous` owner_tier instead of using `guest` + `plaintext_
|
||||||
|
advertise`.** Rejected. Bumps ADR 0007 § 4 schema version (a
|
||||||
|
non-additive change), adds a third identity class that the rest of the
|
||||||
|
codebase (cache namespacing, /health gating, audit attribution) has no
|
||||||
|
reason to know about, and conflicts with ADR 0007 § 7.1's "anonymous =
|
||||||
|
no auth header + allow_anonymous=true" definition. A single optional
|
||||||
|
field on the manifest is strictly less invasive.
|
||||||
|
3. **Hard-enforce trusted-LAN bind address at startup.** Rejected for
|
||||||
|
v0.4.0; deferred until a public-deployment-mode README section ships
|
||||||
|
(see Re-evaluation triggers § 1). Soft constraint + startup warn is
|
||||||
|
appropriate while OLP has zero public-internet deployment recipes.
|
||||||
|
4. **Encrypt `plaintext_advertise` at rest with a key derived from
|
||||||
|
`OLP_HOME` path or a separate `OLP_ADVERTISE_KEY` env var.** Rejected.
|
||||||
|
The threat model is "anyone who can read `/health` reads the plaintext
|
||||||
|
token over the wire," not "anyone who can read `~/.olp/keys/`." Both
|
||||||
|
require LAN-reach; encrypting on-disk doesn't change the over-the-wire
|
||||||
|
exposure. Adds complexity for no security gain in the relevant attack
|
||||||
|
model.
|
||||||
|
5. **Make `--advertise` allowed only when `--name` is exactly `anonymous`.**
|
||||||
|
Rejected as over-restrictive. The CLI's `--anonymous` shorthand
|
||||||
|
defaults `--name=anonymous`, but operators may legitimately want a
|
||||||
|
named advertised key (e.g., `family-guest`, `lan-zero-config`). The
|
||||||
|
discriminator is the field, not the name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authority citations
|
||||||
|
|
||||||
|
- **ADR 0007 § 7** (Identity-class table — anonymous tier definition;
|
||||||
|
`__anonymous__` keyId).
|
||||||
|
- **ADR 0007 § 5** (Token format — establishes hash-only on-disk; D69 is
|
||||||
|
the explicit opt-in exception).
|
||||||
|
- **ADR 0007 § 4** (Manifest schema — D69 adds optional `plaintext_advertise`
|
||||||
|
field; § 4 already specifies "unrecognized fields cause a warn but not a
|
||||||
|
reject (forward-compat)" so the addition is non-breaking for older
|
||||||
|
parsers).
|
||||||
|
- **ADR 0007 § 7.2** (Configuration — D69 adds `auth.advertise_anonymous_key`
|
||||||
|
alongside existing `allow_anonymous` / `owner_only_endpoints` /
|
||||||
|
`fallback_detail_header_policy`).
|
||||||
|
- **ADR 0010 § Phase 4 charter D68-D70 row** (scope authority for this ADR).
|
||||||
|
- **OCP `server.mjs:148, 1454, 1488, 1555`** (prior-art for the
|
||||||
|
`PROXY_ANONYMOUS_KEY` env + `/health.anonymousKey` pattern; OCP v3.13.0).
|
||||||
|
- **OCP issue #12 § 14 Path A** (the original anonymous-key decision
|
||||||
|
context for OCP; the "Path A" label is OCP-specific and not used in
|
||||||
|
OLP).
|
||||||
|
- **`bin/olp-connect`** (D68 client-side consumer of `/health.anonymousKey`).
|
||||||
|
- **`bin/olp-keys.mjs`** (D69 keygen `--advertise` flag implementation).
|
||||||
|
- **`lib/keys.mjs` `findAdvertisedKey()`** (D69 server-side resolver).
|
||||||
|
- **`server.mjs handleHealth`** (D69 emission point + startup-warn site).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedural mechanism
|
||||||
|
|
||||||
|
CC 开发铁律 v1.6 § 10 (independent reviewer per implementation D-day) — D68
|
||||||
|
+ D69 + D70 ship as ONE PR per Iron Rule 11 IDR (the three deliverables
|
||||||
|
are mutually constituting: `olp-connect` consumes `/health.anonymousKey`,
|
||||||
|
`/health.anonymousKey` is governed by ADR 0011, ADR 0011 documents
|
||||||
|
`olp-connect`'s trust posture). The reviewer is a fresh-context opus
|
||||||
|
subagent.
|
||||||
@@ -24,6 +24,7 @@ New ADRs increment from the highest existing number. Filenames are `NNNN-<short-
|
|||||||
| [0008](0008-dashboard-and-audit-query.md) | Dashboard + Audit Query Layer | Phase 3 design ADR (D48, 2026-05-25). Static HTML dashboard + vanilla JS + fetch (no build step). In-memory ndjson scan for aggregate queries (O(N) per call; family-scale acceptable; defers SQLite migration to Option 3 hybrid trigger). Daily audit rotation `audit-YYYY-MM-DD.ndjson` on first append after UTC midnight; cross-file query layer for rolling 30-day windows. Owner-only gating on `/dashboard` + 3 `/v0/management/*` JSON endpoints reusing ADR 0007 § 7 auth model. 30s page poll (no SSE infra). Panels: per-provider quota / 24h request+cache+fallback / 30d spend trend / top-N fallback chains per spec § 4.6. Opens ADR 0007 § 12 Phase 3 deferral (Dashboard + audit query + rotation). |
|
| [0008](0008-dashboard-and-audit-query.md) | Dashboard + Audit Query Layer | Phase 3 design ADR (D48, 2026-05-25). Static HTML dashboard + vanilla JS + fetch (no build step). In-memory ndjson scan for aggregate queries (O(N) per call; family-scale acceptable; defers SQLite migration to Option 3 hybrid trigger). Daily audit rotation `audit-YYYY-MM-DD.ndjson` on first append after UTC midnight; cross-file query layer for rolling 30-day windows. Owner-only gating on `/dashboard` + 3 `/v0/management/*` JSON endpoints reusing ADR 0007 § 7 auth model. 30s page poll (no SSE infra). Panels: per-provider quota / 24h request+cache+fallback / 30d spend trend / top-N fallback chains per spec § 4.6. Opens ADR 0007 § 12 Phase 3 deferral (Dashboard + audit query + rotation). |
|
||||||
| [0009](0009-interactive-mode-path-placeholder.md) | Anthropic Interactive-Mode Path (Placeholder) | Placeholder ADR (2026-05-25, Draft) — blocked on OCP ADR 0007 P0 experiment outcome. Records the maintainer's "wait + port" decision: do NOT independently implement; ride OCP's P0 result. If P0 confirms Transport A (stdio NDJSON) or B (PTY) bills as subscription rather than Agent SDK credit, port to OLP `lib/providers/anthropic.mjs` (Option 1 parallel impl, or Option 2 OCP-as-backend; decision deferred to P0-resolution time). If P0 fails on both, shelve. No Phase 4 D-day scheduled until P0 lands AND maintainer issues explicit "go" naming this ADR. |
|
| [0009](0009-interactive-mode-path-placeholder.md) | Anthropic Interactive-Mode Path (Placeholder) | Placeholder ADR (2026-05-25, Draft) — blocked on OCP ADR 0007 P0 experiment outcome. Records the maintainer's "wait + port" decision: do NOT independently implement; ride OCP's P0 result. If P0 confirms Transport A (stdio NDJSON) or B (PTY) bills as subscription rather than Agent SDK credit, port to OLP `lib/providers/anthropic.mjs` (Option 1 parallel impl, or Option 2 OCP-as-backend; decision deferred to P0-resolution time). If P0 fails on both, shelve. No Phase 4 D-day scheduled until P0 lands AND maintainer issues explicit "go" naming this ADR. |
|
||||||
| [0010](0010-phase-4-charter-operator-and-client-ux.md) | Phase 4 Charter — Operator + Client UX | Phase 4 scope ratification (2026-05-26, Accepted). Phase 4 = operator + client UX (SSE heartbeat / `olp` CLI + doctor / `olp-connect` zero-config + Telegram-Discord plugin + IDE docs bundle). ~13 D-days, D60 → v0.4.0. Records the explicit decision to DEFER `/v1/messages` (Anthropic-shape entry surface) on the rationale that under ADR 0009 P0 failure it provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients. Re-open trigger: ADR 0009 P0 success + maintainer-named family CC user. Also closes the OCP-OLP port co-host ambiguity from ADR 0001 (default `OLP_PORT` 3456 → 4567). |
|
| [0010](0010-phase-4-charter-operator-and-client-ux.md) | Phase 4 Charter — Operator + Client UX | Phase 4 scope ratification (2026-05-26, Accepted). Phase 4 = operator + client UX (SSE heartbeat / `olp` CLI + doctor / `olp-connect` zero-config + Telegram-Discord plugin + IDE docs bundle). ~13 D-days, D60 → v0.4.0. Records the explicit decision to DEFER `/v1/messages` (Anthropic-shape entry surface) on the rationale that under ADR 0009 P0 failure it provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients. Re-open trigger: ADR 0009 P0 success + maintainer-named family CC user. Also closes the OCP-OLP port co-host ambiguity from ADR 0001 (default `OLP_PORT` 3456 → 4567). |
|
||||||
|
| [0011](0011-anonymous-key-deployment-context.md) | Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant) | D70 (2026-05-26, Accepted). Codifies the trust posture for `/health.anonymousKey` opt-in field (D69) + `bin/olp-connect` zero-config consumer (D68). Three-prerequisite gate (`auth.advertise_anonymous_key=true` + `auth.allow_anonymous=true` + an active key with `plaintext_advertise` field). Guest-tier-only restriction (`createKey()` + CLI reject owner+advertise). Trusted-LAN deployment invariant (loopback / RFC1918 / tailnet / `.local` / `.internal` — soft constraint at v0.4.0; hard enforcement deferred until OLP gains a public-deployment recipe). Re-evaluation trigger: any "expose to public internet" README mode. |
|
||||||
|
|
||||||
## When to write a new ADR
|
## When to write a new ADR
|
||||||
|
|
||||||
|
|||||||
+75
-4
@@ -249,7 +249,7 @@ async function _withKeyLock(id, fn) {
|
|||||||
* never logged. The manifest contains only the hash.
|
* never logged. The manifest contains only the hash.
|
||||||
*/
|
*/
|
||||||
export function createKey(args = {}) {
|
export function createKey(args = {}) {
|
||||||
const { name, owner_tier = 'guest', providers_enabled = '*', notes = '', olpHome } = args;
|
const { name, owner_tier = 'guest', providers_enabled = '*', notes = '', olpHome, plaintext_advertise = false } = args;
|
||||||
if (typeof name !== 'string' || name.length === 0) {
|
if (typeof name !== 'string' || name.length === 0) {
|
||||||
throw new Error('createKey: name is required (non-empty string)');
|
throw new Error('createKey: name is required (non-empty string)');
|
||||||
}
|
}
|
||||||
@@ -259,6 +259,15 @@ export function createKey(args = {}) {
|
|||||||
if (!(providers_enabled === '*' || Array.isArray(providers_enabled))) {
|
if (!(providers_enabled === '*' || Array.isArray(providers_enabled))) {
|
||||||
throw new Error('createKey: providers_enabled must be "*" or string array');
|
throw new Error('createKey: providers_enabled must be "*" or string array');
|
||||||
}
|
}
|
||||||
|
// D69 plaintext_advertise (ADR 0011): only valid on guest tier — see ADR
|
||||||
|
// 0011 § "Trusted-LAN invariant + tier restriction". Owner-tier advertisement
|
||||||
|
// is rejected because exposing the owner identity unauthenticated would
|
||||||
|
// grant unauthenticated callers /health full payload, /v0/management/* access,
|
||||||
|
// and X-OLP-Fallback-Detail visibility — the inverse of the advertise key's
|
||||||
|
// intent (a low-privilege zero-config tier).
|
||||||
|
if (plaintext_advertise && owner_tier !== 'guest') {
|
||||||
|
throw new Error('createKey: plaintext_advertise requires owner_tier="guest" (ADR 0011)');
|
||||||
|
}
|
||||||
|
|
||||||
const id = generateKeyId();
|
const id = generateKeyId();
|
||||||
const plaintext_token = generateToken();
|
const plaintext_token = generateToken();
|
||||||
@@ -276,10 +285,59 @@ export function createKey(args = {}) {
|
|||||||
last_used_at: null,
|
last_used_at: null,
|
||||||
notes,
|
notes,
|
||||||
};
|
};
|
||||||
|
// D69 (ADR 0011): when the operator explicitly opts in via --advertise on
|
||||||
|
// keygen, the plaintext token is co-located with the hash so the server can
|
||||||
|
// surface it via /health.anonymousKey for zero-config family-LAN setup.
|
||||||
|
// This is the ONLY place plaintext ever lands on disk; see ADR 0011 for
|
||||||
|
// the trusted-LAN-only invariant + threat model.
|
||||||
|
if (plaintext_advertise) {
|
||||||
|
manifest.plaintext_advertise = plaintext_token;
|
||||||
|
}
|
||||||
writeManifestAtomic(id, manifest, { olpHome });
|
writeManifestAtomic(id, manifest, { olpHome });
|
||||||
return { id, plaintext_token, manifest };
|
return { id, plaintext_token, manifest };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── D69 advertise-key discovery (ADR 0011) ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the active key marked for /health advertisement. Returns the manifest
|
||||||
|
* (including `plaintext_advertise`) or null when no such key exists.
|
||||||
|
*
|
||||||
|
* Scans every manifest under ~/.olp/keys/; selects the FIRST active
|
||||||
|
* (revoked_at === null) manifest that carries a non-empty `plaintext_advertise`
|
||||||
|
* string. Deterministic ordering is unstable across filesystems — operators
|
||||||
|
* are expected to keep at most one advertised key on disk at a time.
|
||||||
|
*
|
||||||
|
* Returns null if:
|
||||||
|
* - the keys directory doesn't exist
|
||||||
|
* - no manifest carries plaintext_advertise
|
||||||
|
* - the only matching manifest is revoked
|
||||||
|
*
|
||||||
|
* Used by server.mjs handleHealth (D69) + olp-keys CLI 'list' subcommand
|
||||||
|
* (advertise badge).
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||||
|
* @returns {object|null} manifest object (NOT redacted; carries plaintext_advertise)
|
||||||
|
*/
|
||||||
|
export function findAdvertisedKey(opts = {}) {
|
||||||
|
const dir = _keysDir(opts);
|
||||||
|
if (!existsSync(dir)) return null;
|
||||||
|
let entries;
|
||||||
|
try { entries = readdirSync(dir); } catch { return null; }
|
||||||
|
for (const id of entries) {
|
||||||
|
if (id.startsWith('.')) continue;
|
||||||
|
let m;
|
||||||
|
try { m = readManifest(id, opts); } catch { continue; }
|
||||||
|
if (m === null) continue;
|
||||||
|
if (m.revoked_at !== null) continue;
|
||||||
|
if (typeof m.plaintext_advertise === 'string' && m.plaintext_advertise.length > 0) {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all keys. Returns array of manifest objects with `token_hash` redacted
|
* List all keys. Returns array of manifest objects with `token_hash` redacted
|
||||||
* (kept on disk; omitted from list output per common operational hygiene —
|
* (kept on disk; omitted from list output per common operational hygiene —
|
||||||
@@ -299,8 +357,14 @@ export function listKeys(opts = {}) {
|
|||||||
try {
|
try {
|
||||||
const m = readManifest(id, opts);
|
const m = readManifest(id, opts);
|
||||||
if (m === null) continue;
|
if (m === null) continue;
|
||||||
// Redact token_hash from list output (keep on disk).
|
// D69 reviewer P2-1 (footgun-removal): strip BOTH `token_hash` AND
|
||||||
const { token_hash, ...rest } = m;
|
// `plaintext_advertise` from list output. Callers wanting the
|
||||||
|
// advertised plaintext for the /health publication path must go
|
||||||
|
// through `findAdvertisedKey()` instead, which is the only sanctioned
|
||||||
|
// read site. Future callers of `listKeys()` that emit results into
|
||||||
|
// logs / HTTP responses / dashboards therefore can't accidentally
|
||||||
|
// leak the advertised plaintext.
|
||||||
|
const { token_hash, plaintext_advertise, ...rest } = m;
|
||||||
out.push(rest);
|
out.push(rest);
|
||||||
} catch {
|
} catch {
|
||||||
// Skip invalid manifest; production impl would log warn.
|
// Skip invalid manifest; production impl would log warn.
|
||||||
@@ -456,12 +520,17 @@ export async function touchLastUsed(id, opts = {}) {
|
|||||||
* - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads)
|
* - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads)
|
||||||
* - fallback_detail_header_policy: 'owner_only' (D46 consumes; D45 only loads)
|
* - fallback_detail_header_policy: 'owner_only' (D46 consumes; D45 only loads)
|
||||||
*
|
*
|
||||||
|
* D69 / ADR 0011:
|
||||||
|
* - advertise_anonymous_key: false (default off; opt-in surfaces
|
||||||
|
* findAdvertisedKey() plaintext via
|
||||||
|
* /health.anonymousKey)
|
||||||
|
*
|
||||||
* Returns the auth config object. Never throws — missing file / parse
|
* Returns the auth config object. Never throws — missing file / parse
|
||||||
* error / missing `auth` key all fall back to defaults.
|
* error / missing `auth` key all fall back to defaults.
|
||||||
*
|
*
|
||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||||
* @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none' }}
|
* @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none', advertise_anonymous_key: boolean }}
|
||||||
*/
|
*/
|
||||||
export function loadAuthConfigSync(opts = {}) {
|
export function loadAuthConfigSync(opts = {}) {
|
||||||
const olpHome = _resolveOlpHome(opts);
|
const olpHome = _resolveOlpHome(opts);
|
||||||
@@ -470,6 +539,7 @@ export function loadAuthConfigSync(opts = {}) {
|
|||||||
allow_anonymous: false,
|
allow_anonymous: false,
|
||||||
owner_only_endpoints: ['/health'],
|
owner_only_endpoints: ['/health'],
|
||||||
fallback_detail_header_policy: 'owner_only',
|
fallback_detail_header_policy: 'owner_only',
|
||||||
|
advertise_anonymous_key: false,
|
||||||
};
|
};
|
||||||
if (!existsSync(path)) return { ...DEFAULTS };
|
if (!existsSync(path)) return { ...DEFAULTS };
|
||||||
try {
|
try {
|
||||||
@@ -484,6 +554,7 @@ export function loadAuthConfigSync(opts = {}) {
|
|||||||
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
|
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
|
||||||
? auth.fallback_detail_header_policy
|
? auth.fallback_detail_header_policy
|
||||||
: DEFAULTS.fallback_detail_header_policy,
|
: DEFAULTS.fallback_detail_header_policy,
|
||||||
|
advertise_anonymous_key: typeof auth.advertise_anonymous_key === 'boolean' ? auth.advertise_anonymous_key : DEFAULTS.advertise_anonymous_key,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed JSON / unreadable file → safe defaults
|
// Malformed JSON / unreadable file → safe defaults
|
||||||
|
|||||||
+4
-2
@@ -7,14 +7,16 @@
|
|||||||
"bin": {
|
"bin": {
|
||||||
"olp": "./bin/olp.mjs",
|
"olp": "./bin/olp.mjs",
|
||||||
"olp-keys": "./bin/olp-keys.mjs",
|
"olp-keys": "./bin/olp-keys.mjs",
|
||||||
"olp-audit-rotate": "./bin/olp-audit-rotate.mjs"
|
"olp-audit-rotate": "./bin/olp-audit-rotate.mjs",
|
||||||
|
"olp-connect": "./bin/olp-connect"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"test": "node test-features.mjs",
|
"test": "node test-features.mjs",
|
||||||
"olp": "node bin/olp.mjs",
|
"olp": "node bin/olp.mjs",
|
||||||
"olp-keys": "node bin/olp-keys.mjs",
|
"olp-keys": "node bin/olp-keys.mjs",
|
||||||
"olp-audit-rotate": "node bin/olp-audit-rotate.mjs"
|
"olp-audit-rotate": "node bin/olp-audit-rotate.mjs",
|
||||||
|
"olp-connect": "bash bin/olp-connect"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
+61
-5
@@ -52,10 +52,12 @@ import {
|
|||||||
loadFallbackConfigSync,
|
loadFallbackConfigSync,
|
||||||
} from './lib/fallback/engine.mjs';
|
} from './lib/fallback/engine.mjs';
|
||||||
// Phase 2 / D45 — multi-key auth integration per ADR 0007.
|
// Phase 2 / D45 — multi-key auth integration per ADR 0007.
|
||||||
|
// D69 (ADR 0011): findAdvertisedKey for /health.anonymousKey opt-in surface.
|
||||||
import {
|
import {
|
||||||
validateKey,
|
validateKey,
|
||||||
touchLastUsed,
|
touchLastUsed,
|
||||||
loadAuthConfigSync,
|
loadAuthConfigSync,
|
||||||
|
findAdvertisedKey,
|
||||||
ANONYMOUS_KEY_ID,
|
ANONYMOUS_KEY_ID,
|
||||||
ENV_OWNER_KEY_ID,
|
ENV_OWNER_KEY_ID,
|
||||||
} from './lib/keys.mjs';
|
} from './lib/keys.mjs';
|
||||||
@@ -231,10 +233,37 @@ if (_authConfig.allow_anonymous === true) {
|
|||||||
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
|
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// D69 (ADR 0011): startup-time prerequisite validation for /health.anonymousKey
|
||||||
|
// advertisement. The field is emitted ONLY when all three prerequisites hold:
|
||||||
|
// (1) auth.advertise_anonymous_key === true (config opt-in)
|
||||||
|
// (2) auth.allow_anonymous === true (anonymous tier must be reachable
|
||||||
|
// for the advertised key to be
|
||||||
|
// meaningful to zero-config callers)
|
||||||
|
// (3) at least one advertised key exists on disk (manifest with
|
||||||
|
// plaintext_advertise field, not revoked)
|
||||||
|
// Violations log warn at startup but the server still boots; the runtime
|
||||||
|
// handleHealth checks the same conditions and simply omits the field.
|
||||||
|
if (_authConfig.advertise_anonymous_key === true) {
|
||||||
|
if (_authConfig.allow_anonymous !== true) {
|
||||||
|
logEvent('warn', 'anonymous_key_advertised_but_denied', {
|
||||||
|
message: 'auth.advertise_anonymous_key=true but auth.allow_anonymous=false — the advertised key would not be usable anonymously. /health.anonymousKey will NOT be emitted until allow_anonymous=true. See ADR 0011.',
|
||||||
|
});
|
||||||
|
} else if (findAdvertisedKey() === null) {
|
||||||
|
logEvent('warn', 'anonymous_key_advertised_but_no_anonymous_key_exists', {
|
||||||
|
message: 'auth.advertise_anonymous_key=true but no active key with plaintext_advertise exists. Run `olp-keys keygen --anonymous --advertise` to create one. /health.anonymousKey will NOT be emitted until then. See ADR 0011.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
||||||
export function __setAuthConfig(config) {
|
export function __setAuthConfig(config) {
|
||||||
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only' };
|
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only', advertise_anonymous_key: false };
|
||||||
|
// D69: defensively normalize so tests passing partial configs still get a
|
||||||
|
// boolean advertise_anonymous_key value (downstream code treats undefined
|
||||||
|
// as falsy but explicit normalisation matches loadAuthConfigSync's contract).
|
||||||
|
if (typeof _authConfig.advertise_anonymous_key !== 'boolean') {
|
||||||
|
_authConfig.advertise_anonymous_key = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal — reset auth config to file-loaded state. */
|
/** @internal — reset auth config to file-loaded state. */
|
||||||
@@ -751,9 +780,34 @@ async function handleHealth(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D69 (ADR 0011): compute opt-in /health.anonymousKey. The field is added
|
||||||
|
// to BOTH trimmed and full payloads (the trimmed payload's whole purpose
|
||||||
|
// is to be readable by anonymous clients so they can self-bootstrap into
|
||||||
|
// a real key). Three prerequisites must hold (see startup warns above):
|
||||||
|
// (1) auth.advertise_anonymous_key === true
|
||||||
|
// (2) auth.allow_anonymous === true
|
||||||
|
// (3) findAdvertisedKey() returns a non-null active manifest
|
||||||
|
// When any fails, the field is absent (NOT null) — preserves the v0.3.x
|
||||||
|
// /health payload shape for clients that strict-validate keys.
|
||||||
|
let anonymousKey = null;
|
||||||
|
if (_authConfig.advertise_anonymous_key === true && _authConfig.allow_anonymous === true) {
|
||||||
|
try {
|
||||||
|
const adv = findAdvertisedKey();
|
||||||
|
if (adv && typeof adv.plaintext_advertise === 'string' && adv.plaintext_advertise.length > 0) {
|
||||||
|
anonymousKey = adv.plaintext_advertise;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Filesystem error reading manifests is non-fatal; just omit the field.
|
||||||
|
anonymousKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isGated && !isOwner) {
|
if (isGated && !isOwner) {
|
||||||
// Trimmed payload per § 7.1.
|
// Trimmed payload per § 7.1. D69: include anonymousKey here too — zero-
|
||||||
return sendJSON(res, 200, { ok: true, version: VERSION });
|
// config olp-connect callers read it BEFORE they have a key.
|
||||||
|
const trimmed = { ok: true, version: VERSION };
|
||||||
|
if (anonymousKey !== null) trimmed.anonymousKey = anonymousKey;
|
||||||
|
return sendJSON(res, 200, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full payload (owner OR /health removed from owner_only_endpoints).
|
// Full payload (owner OR /health removed from owner_only_endpoints).
|
||||||
@@ -774,11 +828,13 @@ async function handleHealth(req, res) {
|
|||||||
providerStatuses[name] = { ok: false, error: e.message, activeSpawns };
|
providerStatuses[name] = { ok: false, error: e.message, activeSpawns };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendJSON(res, 200, {
|
const fullPayload = {
|
||||||
ok: true,
|
ok: true,
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
providers: { enabled, available, status: providerStatuses },
|
providers: { enabled, available, status: providerStatuses },
|
||||||
});
|
};
|
||||||
|
if (anonymousKey !== null) fullPayload.anonymousKey = anonymousKey;
|
||||||
|
sendJSON(res, 200, fullPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14055,3 +14055,342 @@ describe('Suite 33 — D65 lib/doctor.mjs framework (ADR 0010 § Phase 4 D64-D67
|
|||||||
assert.equal(na.verify, 'olp doctor');
|
assert.equal(na.verify, 'olp doctor');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Suite 34: D68-D70 — /health.anonymousKey + plaintext_advertise (ADR 0011) ──
|
||||||
|
//
|
||||||
|
// Tests for:
|
||||||
|
// - lib/keys.mjs createKey({ plaintext_advertise: true }) writes the field;
|
||||||
|
// rejects on owner tier
|
||||||
|
// - lib/keys.mjs findAdvertisedKey() discovery semantics (skips revoked /
|
||||||
|
// skips no-advertise / returns first match)
|
||||||
|
// - server.mjs /health emits anonymousKey ONLY when all 3 prerequisites hold
|
||||||
|
// - bin/olp-keys.mjs `--anonymous --advertise` end-to-end (manifest +
|
||||||
|
// plaintext printed + stderr warning + advertise label)
|
||||||
|
// - bin/olp-keys.mjs rejects `--owner --advertise`
|
||||||
|
|
||||||
|
import {
|
||||||
|
findAdvertisedKey as findAdvertisedKey34,
|
||||||
|
} from './lib/keys.mjs';
|
||||||
|
|
||||||
|
describe('Suite 34 — D68-D70 /health.anonymousKey + plaintext_advertise (ADR 0011)', () => {
|
||||||
|
const SUITE34_GLOBAL_OLP_HOME = process.env.OLP_HOME;
|
||||||
|
|
||||||
|
// ── 34a-c: lib/keys.mjs unit tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('34a-c — lib/keys.mjs createKey + findAdvertisedKey', () => {
|
||||||
|
let TMP;
|
||||||
|
before(() => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34abc-'));
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
__resetWriteLocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34a — createKey({ plaintext_advertise: true }) writes plaintext_advertise field on guest manifest', () => {
|
||||||
|
const { id, plaintext_token, manifest } = createKey({
|
||||||
|
name: '34a-advert', owner_tier: 'guest', providers_enabled: '*',
|
||||||
|
plaintext_advertise: true, olpHome: TMP,
|
||||||
|
});
|
||||||
|
assert.equal(manifest.plaintext_advertise, plaintext_token);
|
||||||
|
// Verify on-disk
|
||||||
|
const reread = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.equal(reread.plaintext_advertise, plaintext_token);
|
||||||
|
// Token hash also correct
|
||||||
|
assert.equal(reread.token_hash, hashToken(plaintext_token));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34a-2 — createKey() default (no plaintext_advertise) does NOT write the field (ADR 0007 § 5 invariant preserved)', () => {
|
||||||
|
const { id, manifest } = createKey({
|
||||||
|
name: '34a2-normal', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP,
|
||||||
|
});
|
||||||
|
assert.ok(!('plaintext_advertise' in manifest), 'manifest must NOT carry plaintext_advertise by default');
|
||||||
|
const reread = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.ok(!('plaintext_advertise' in reread), 'on-disk manifest must NOT carry plaintext_advertise by default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34b — createKey({ owner_tier:"owner", plaintext_advertise:true }) is rejected (ADR 0011 tier restriction)', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createKey({
|
||||||
|
name: '34b-fail', owner_tier: 'owner', plaintext_advertise: true, olpHome: TMP,
|
||||||
|
}),
|
||||||
|
/plaintext_advertise requires owner_tier="guest"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34c-1 — findAdvertisedKey() returns null when no key has plaintext_advertise', () => {
|
||||||
|
const TMP2 = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34c1-'));
|
||||||
|
try {
|
||||||
|
createKey({ name: 'plain', owner_tier: 'guest', olpHome: TMP2 });
|
||||||
|
assert.equal(findAdvertisedKey34({ olpHome: TMP2 }), null);
|
||||||
|
} finally {
|
||||||
|
rmSync(TMP2, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34c-2 — findAdvertisedKey() returns the advertised manifest with plaintext_advertise field intact', () => {
|
||||||
|
const TMP3 = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34c2-'));
|
||||||
|
try {
|
||||||
|
const { plaintext_token } = createKey({
|
||||||
|
name: '34c2', owner_tier: 'guest', plaintext_advertise: true, olpHome: TMP3,
|
||||||
|
});
|
||||||
|
const found = findAdvertisedKey34({ olpHome: TMP3 });
|
||||||
|
assert.ok(found !== null, 'findAdvertisedKey must return non-null');
|
||||||
|
assert.equal(found.plaintext_advertise, plaintext_token);
|
||||||
|
assert.equal(found.owner_tier, 'guest');
|
||||||
|
} finally {
|
||||||
|
rmSync(TMP3, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34c-3 — findAdvertisedKey() skips revoked advertised keys', async () => {
|
||||||
|
const TMP4 = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34c3-'));
|
||||||
|
try {
|
||||||
|
const { id } = createKey({
|
||||||
|
name: '34c3', owner_tier: 'guest', plaintext_advertise: true, olpHome: TMP4,
|
||||||
|
});
|
||||||
|
assert.ok(findAdvertisedKey34({ olpHome: TMP4 }) !== null, 'pre-revoke: must find it');
|
||||||
|
await revokeKey({ id, olpHome: TMP4 });
|
||||||
|
assert.equal(findAdvertisedKey34({ olpHome: TMP4 }), null, 'post-revoke: must skip');
|
||||||
|
} finally {
|
||||||
|
rmSync(TMP4, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 34d-g: /health.anonymousKey HTTP integration (D69 prereqs) ──────────
|
||||||
|
|
||||||
|
describe('34d-g — /health.anonymousKey HTTP integration', () => {
|
||||||
|
let TMP, server, port;
|
||||||
|
|
||||||
|
async function makeSuite34Server() {
|
||||||
|
__setProvidersEnabled({});
|
||||||
|
const srv = createOlpServer();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
srv.listen(0, '127.0.0.1', () => resolve({ server: srv, port: srv.address().port }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function teardownSuite34() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
__setProvidersEnabled({});
|
||||||
|
__clearCache();
|
||||||
|
if (server) server.close(() => resolve());
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34dg-'));
|
||||||
|
process.env.OLP_HOME = TMP;
|
||||||
|
({ server, port } = await makeSuite34Server());
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await teardownSuite34();
|
||||||
|
process.env.OLP_HOME = SUITE34_GLOBAL_OLP_HOME;
|
||||||
|
// Restore test-global auth config so subsequent suites are unaffected
|
||||||
|
__setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' });
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clean slate between cases — each test installs the precise auth config
|
||||||
|
// it needs (default-off / advertise-but-no-anon / advertise-but-no-key /
|
||||||
|
// all three prerequisites met) before issuing the /health request.
|
||||||
|
__setAuthConfig({
|
||||||
|
allow_anonymous: false,
|
||||||
|
owner_only_endpoints: ['/health'],
|
||||||
|
fallback_detail_header_policy: 'owner_only',
|
||||||
|
advertise_anonymous_key: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34d — default (advertise_anonymous_key: false) — /health response has NO anonymousKey field', async () => {
|
||||||
|
// Create an advertised key on disk but don't enable the flag.
|
||||||
|
createKey({
|
||||||
|
name: '34d-key', owner_tier: 'guest', plaintext_advertise: true, olpHome: TMP,
|
||||||
|
});
|
||||||
|
// Owner identity to access full payload (since allow_anonymous: false)
|
||||||
|
const { plaintext_token } = createKey({
|
||||||
|
name: '34d-owner', owner_tier: 'owner', olpHome: TMP,
|
||||||
|
});
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/health',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
assert.ok(!('anonymousKey' in body), '/health MUST NOT include anonymousKey when advertise_anonymous_key=false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34e — advertise_anonymous_key: true + allow_anonymous: false → field omitted (prereq 2 fails)', async () => {
|
||||||
|
createKey({
|
||||||
|
name: '34e-key', owner_tier: 'guest', plaintext_advertise: true, olpHome: TMP,
|
||||||
|
});
|
||||||
|
__setAuthConfig({
|
||||||
|
allow_anonymous: false, // prereq 2 NOT satisfied
|
||||||
|
advertise_anonymous_key: true,
|
||||||
|
owner_only_endpoints: ['/health'],
|
||||||
|
fallback_detail_header_policy: 'owner_only',
|
||||||
|
});
|
||||||
|
// Use owner identity (must — allow_anonymous=false rejects anonymous)
|
||||||
|
const { plaintext_token } = createKey({
|
||||||
|
name: '34e-owner', owner_tier: 'owner', olpHome: TMP,
|
||||||
|
});
|
||||||
|
const r = await fetch({
|
||||||
|
port, method: 'GET', path: '/health',
|
||||||
|
headers: { Authorization: `Bearer ${plaintext_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
assert.ok(!('anonymousKey' in body), '/health MUST NOT include anonymousKey when allow_anonymous=false even if advertise=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34f — advertise_anonymous_key: true + allow_anonymous: true + NO advertised key → field omitted (prereq 3 fails)', async () => {
|
||||||
|
// Fresh isolated tmpdir so no advertised key exists
|
||||||
|
const TMP_F = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34f-'));
|
||||||
|
const SAVED = process.env.OLP_HOME;
|
||||||
|
process.env.OLP_HOME = TMP_F;
|
||||||
|
__setAuthConfig({
|
||||||
|
allow_anonymous: true,
|
||||||
|
advertise_anonymous_key: true,
|
||||||
|
owner_only_endpoints: [], // /health full payload to anonymous
|
||||||
|
fallback_detail_header_policy: 'all',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const r = await fetch({ port, method: 'GET', path: '/health' });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
assert.ok(!('anonymousKey' in body), '/health MUST NOT include anonymousKey when no plaintext_advertise key exists');
|
||||||
|
} finally {
|
||||||
|
process.env.OLP_HOME = SAVED;
|
||||||
|
rmSync(TMP_F, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34g — all 3 prerequisites met → /health.anonymousKey exposes the plaintext token', async () => {
|
||||||
|
const TMP_G = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34g-'));
|
||||||
|
const SAVED = process.env.OLP_HOME;
|
||||||
|
process.env.OLP_HOME = TMP_G;
|
||||||
|
try {
|
||||||
|
const { plaintext_token } = createKey({
|
||||||
|
name: '34g-anon', owner_tier: 'guest', plaintext_advertise: true, olpHome: TMP_G,
|
||||||
|
});
|
||||||
|
__setAuthConfig({
|
||||||
|
allow_anonymous: true,
|
||||||
|
advertise_anonymous_key: true,
|
||||||
|
owner_only_endpoints: [], // anonymous sees full payload too
|
||||||
|
fallback_detail_header_policy: 'all',
|
||||||
|
});
|
||||||
|
const r = await fetch({ port, method: 'GET', path: '/health' });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
assert.equal(body.anonymousKey, plaintext_token);
|
||||||
|
assert.match(body.anonymousKey, /^olp_[A-Za-z0-9_-]{43}$/);
|
||||||
|
} finally {
|
||||||
|
process.env.OLP_HOME = SAVED;
|
||||||
|
rmSync(TMP_G, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34g-trimmed — anonymousKey also surfaces in trimmed /health (anonymous client zero-config path)', async () => {
|
||||||
|
// /health is gated as owner-only; anonymous client gets trimmed payload
|
||||||
|
// but anonymousKey must still surface — it's the whole point of D69.
|
||||||
|
const TMP_H = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34gt-'));
|
||||||
|
const SAVED = process.env.OLP_HOME;
|
||||||
|
process.env.OLP_HOME = TMP_H;
|
||||||
|
try {
|
||||||
|
const { plaintext_token } = createKey({
|
||||||
|
name: '34gt-anon', owner_tier: 'guest', plaintext_advertise: true, olpHome: TMP_H,
|
||||||
|
});
|
||||||
|
__setAuthConfig({
|
||||||
|
allow_anonymous: true,
|
||||||
|
advertise_anonymous_key: true,
|
||||||
|
owner_only_endpoints: ['/health'], // trim gating ACTIVE
|
||||||
|
fallback_detail_header_policy: 'owner_only',
|
||||||
|
});
|
||||||
|
const r = await fetch({ port, method: 'GET', path: '/health' });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const body = JSON.parse(r.body);
|
||||||
|
// Trimmed shape (no providers) BUT anonymousKey is present
|
||||||
|
assert.ok(!('providers' in body), 'trimmed /health must omit providers');
|
||||||
|
assert.equal(body.anonymousKey, plaintext_token);
|
||||||
|
} finally {
|
||||||
|
process.env.OLP_HOME = SAVED;
|
||||||
|
rmSync(TMP_H, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 34h-j: bin/olp-keys.mjs --anonymous --advertise CLI ─────────────────
|
||||||
|
|
||||||
|
describe('34h-j — bin/olp-keys.mjs --anonymous --advertise', () => {
|
||||||
|
let TMP;
|
||||||
|
before(() => {
|
||||||
|
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34hj-'));
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
__resetWriteLocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34h — keygen --anonymous --advertise creates guest+advertise key + prints WARNING to stderr', async () => {
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
const code = await runOlpKeysCli(
|
||||||
|
['keygen', '--anonymous', '--advertise', '--olp-home', TMP],
|
||||||
|
{ out: s => { out += s; }, err: s => { err += s; } },
|
||||||
|
);
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.match(out, /token \(plaintext\):\s+olp_[A-Za-z0-9_-]{43}/);
|
||||||
|
assert.match(out, /owner_tier:\s+guest/);
|
||||||
|
assert.match(out, /name:\s+anonymous/);
|
||||||
|
assert.match(out, /advertise:\s+YES/);
|
||||||
|
assert.match(err, /WARNING:.*plaintext is now stored on disk/);
|
||||||
|
assert.match(err, /ADR 0011/);
|
||||||
|
// Manifest carries plaintext_advertise + matches printed plaintext
|
||||||
|
const advManifest = findAdvertisedKey34({ olpHome: TMP });
|
||||||
|
assert.ok(advManifest !== null);
|
||||||
|
const printedTokenMatch = out.match(/token \(plaintext\):\s+(olp_[A-Za-z0-9_-]{43})/);
|
||||||
|
assert.ok(printedTokenMatch);
|
||||||
|
assert.equal(advManifest.plaintext_advertise, printedTokenMatch[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34i — keygen --owner --advertise → exit 1 with ADR 0011 pointer (tier mismatch rejected at CLI layer)', async () => {
|
||||||
|
const TMP_I = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34i-'));
|
||||||
|
try {
|
||||||
|
let err = '';
|
||||||
|
const code = await runOlpKeysCli(
|
||||||
|
['keygen', '--owner', '--advertise', '--olp-home', TMP_I],
|
||||||
|
{ out: () => {}, err: s => { err += s; } },
|
||||||
|
);
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(err, /--advertise requires guest tier/);
|
||||||
|
assert.match(err, /ADR 0011/);
|
||||||
|
// Nothing was written
|
||||||
|
assert.equal(listKeys({ olpHome: TMP_I }).length, 0);
|
||||||
|
} finally {
|
||||||
|
rmSync(TMP_I, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('34j — keygen --anonymous (without --advertise) creates guest key WITHOUT plaintext_advertise field', async () => {
|
||||||
|
const TMP_J = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-34j-'));
|
||||||
|
try {
|
||||||
|
let out = '';
|
||||||
|
const code = await runOlpKeysCli(
|
||||||
|
['keygen', '--anonymous', '--olp-home', TMP_J],
|
||||||
|
{ out: s => { out += s; }, err: () => {} },
|
||||||
|
);
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.match(out, /owner_tier:\s+guest/);
|
||||||
|
assert.match(out, /name:\s+anonymous/);
|
||||||
|
assert.doesNotMatch(out, /advertise:\s+YES/);
|
||||||
|
// Manifest does NOT carry plaintext_advertise
|
||||||
|
assert.equal(findAdvertisedKey34({ olpHome: TMP_J }), null);
|
||||||
|
} finally {
|
||||||
|
rmSync(TMP_J, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user