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:
dtzp555-max
2026-05-26 09:15:09 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent e69e908dae
commit 0bdecd1235
9 changed files with 1433 additions and 16 deletions
+16 -1
View File
@@ -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.
+564
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
} }
/** /**
+339
View File
@@ -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 });
}
});
});
});