Files
archy/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-PLAN.md
T

22 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-aiui-functional-conversational-node-control-and-content-surf 02 execute 1
core/archipelago/src/api/handler/model_proxy.rs
core/archipelago/src/api/handler/mod.rs
core/archipelago/src/api/rpc/system/handlers.rs
image-recipe/configs/nginx-archipelago.conf
scripts/deploy-to-target.sh
scripts/setup-aiui-server.sh
tests/production-quality/aiui-proxy-closed.sh
false
AIUI-04
truths artifacts key_links
Nobody without an authenticated session can reach the node's model backends or spend the owner's API budget (AI-SPEC failure mode 4)
There is exactly one Claude credential ledger on the node — `data_dir/secrets/claude-api-key`. The second one (`secrets/claude-api-proxy.env` + the systemd unit's `ANTHROPIC_API_KEY`) is gone (D-01)
A logged-in operator's currently-deployed AIUI build keeps working through the migration window — the URL path is unchanged, only its authentication and its upstream change (D-17)
`/aiui/api/openrouter/` no longer exists on a node: it held no node key, is not in D-04's backend chain, and was a plain open relay to a paid third-party API
path provides contains
core/archipelago/src/api/handler/model_proxy.rs Session-gated forwarder for /aiui/api/claude/* and /aiui/api/ollama/*, replacing claude-api-proxy.py is_authenticated
path provides contains
tests/production-quality/aiui-proxy-closed.sh S-15 deployed-surface check — the one check a green cargo test cannot make aiui/api/claude
from to via pattern
image-recipe/configs/nginx-archipelago.conf core/archipelago/src/api/handler/model_proxy.rs /aiui/api/claude/ proxy_pass re-pointed from 127.0.0.1:3142 to the Rust daemon on 127.0.0.1:5678 proxy_pass http://127.0.0.1:5678
from to via pattern
core/archipelago/src/api/handler/model_proxy.rs core/archipelago/src/api/rpc/mesh/assistant.rs reads the same data_dir/secrets/claude-api-key — one ledger, not two secrets/claude-api-key
Close a live production exposure. Verified in `image-recipe/configs/nginx-archipelago.conf` (two server blocks, lines ~49-88 and ~961-993): `/aiui/api/claude/` proxies to a standalone Python server on port 3142 holding its **own** `ANTHROPIC_API_KEY`, and `/aiui/api/openrouter/` proxies straight to openrouter.ai — both with **no session gate**. The config comment says "API key managed by proxy, no session gate needed", which confuses key *secrecy* with spend *authorization*. Anyone who can reach the node's web port can bill the owner.

This is RESEARCH Open Question 1, answered: delete-and-replace, not gate-then-deprecate. The replacement is a session-gated forwarder inside the Rust daemon that reads the node's single existing key ledger. Because the URL path does not change, every currently-deployed AIUI build keeps working for a logged-in operator — but stops working for an anonymous caller. The nginx location blocks themselves are retired in 13-09, once 13-01's assistant.chat path is the one AIUI actually uses.

Purpose: this exposure is more severe than "chat can't act on the node" and is not mentioned in CONTEXT.md. It is fixed first, in wave 1, independently of the assistant work.

Output: api/handler/model_proxy.rs, a rewritten nginx AIUI-API section, a deploy path with no Python sidecar, and tests/production-quality/aiui-proxy-closed.sh.

<flagged_assumptions> None in this plan. </flagged_assumptions>

<artifacts_this_phase_produces> Symbols created by this plan:

  • core/archipelago/src/api/handler/model_proxy.rs: handle_model_proxy, forward_claude, forward_ollama, const CLAUDE_UPSTREAM, const OLLAMA_UPSTREAM
  • core/archipelago/src/api/handler/mod.rs: mod model_proxy; plus two new path arms
  • New file tests/production-quality/aiui-proxy-closed.sh (shell, follows the existing tests/production-quality/lnd-cors-test.sh precedent)

Symbols deleted by this plan (so a later drift scan does not flag their absence):

  • the embedded claude-api-proxy.py heredoc in scripts/deploy-to-target.sh (~lines 879-955)
  • the claude-api-proxy systemd unit and its ANTHROPIC_API_KEY environment line
  • the secrets/claude-api-proxy.env write and the systemctl restart claude-api-proxy call in core/archipelago/src/api/rpc/system/handlers.rs (~lines 1052-1067)
  • the 31413142 proxy_pass sed fixups in scripts/deploy-to-target.sh (~lines 399, 779)
  • the location /aiui/api/openrouter/ blocks in both nginx server blocks </artifacts_this_phase_produces>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @CLAUDE.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-RESEARCH.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md Task 1: Session-gated model forwarder in the Rust daemon core/archipelago/src/api/handler/model_proxy.rs, core/archipelago/src/api/handler/mod.rs - A POST to `/aiui/api/claude/v1/messages` with **no** session cookie returns 401 and makes no upstream request. - A POST with an invalid/expired session cookie returns 401. - A POST with a valid session cookie forwards to `https://api.anthropic.com/v1/messages` with `x-api-key` read from `data_dir/secrets/claude-api-key`. - When `data_dir/secrets/claude-api-key` is absent, an authenticated caller gets 503 with a plain-language body naming the missing key — never a 500 and never the key path itself echoed as a filesystem hint. - A GET/POST to `/aiui/api/ollama/*` with no session returns 401; with a session it forwards to `http://127.0.0.1:11434/*`. - The API key never appears in any response body, response header, or log line at any level. - `core/archipelago/src/api/handler/mod.rs` — the WebSocket arms at lines 380-418 for the exact `if !self.is_authenticated(req.headers()).await { return Ok(Self::unauthorized()); }` idiom, the `match (method, path.as_str())` table starting ~line 435, and `use crate::session::{self, SessionStore}` at line 15. - `core/archipelago/src/api/handler/proxy.rs` lines 188-265 — the existing peer Range-streaming proxy; the in-repo pattern for building an upstream `reqwest` request and streaming its response back through hyper. Its docstring explains why base64 blobs broke seeking; reuse the streaming shape, not a buffered one. - `core/archipelago/src/api/rpc/mesh/assistant.rs` lines 27-30 — the `data_dir/secrets/claude-api-key` probe. Use this exact path. - `image-recipe/configs/nginx-archipelago.conf` lines 49-88 — what is being replaced. Create `core/archipelago/src/api/handler/model_proxy.rs` with `pub(super) async fn handle_model_proxy(&self, req, path) -> Result>` plus `forward_claude` and `forward_ollama`, and declare `mod model_proxy;` in `api/handler/mod.rs`.

Add two arms to the existing path dispatch in api/handler/mod.rs, placed alongside the WebSocket arms so the auth check is impossible to miss: a prefix match on /aiui/api/claude/ and one on /aiui/api/ollama/. Each arm calls self.is_authenticated(req.headers()).await FIRST and returns Self::unauthorized() on failure — the same primitive /ws/db already uses. Do not add these paths to any allowlist and do not touch UNAUTHENTICATED_METHODS (that is the RPC surface; this is the HTTP surface, and the Phase-10 boundary applies to both).

forward_claude strips the /aiui/api/claude/ prefix, appends the remainder to https://api.anthropic.com/, and forwards the method, body and the content-type/accept request headers only. It sets x-api-key from tokio::fs::read_to_string(self.config.data_dir.join("secrets/claude-api-key")) (trimmed) and anthropic-version: 2023-06-01. It must NOT forward an inbound x-api-key, authorization, or cookie header upstream — a caller must not be able to bill a different account or leak the node's session to Anthropic. Use a reqwest::Client built with ASSISTANT_HTTP_TIMEOUT-equivalent generosity (180s) and stream so token-by-token responses still stream.

forward_ollama does the same shape against http://127.0.0.1:11434/, with no key.

Logging: emit tracing::warn! on a 401 naming the path but not the headers, and tracing::info! on a successful forward naming only the upstream host and the status code. Never log the key, the request body, or the response body — this handler carries user chat text by definition, and AI-SPEC §7b's field policy is a security control, not a style preference.

Write the #[cfg(test)] mod tests FIRST, covering every bullet in <behavior> above, using the SessionStore::new_for_tests constructor and tempfile (both already in-tree) for the data_dir. Name them model_proxy::tests::claude_without_session_is_401, ..::ollama_without_session_is_401, ..::missing_key_is_503_not_500, ..::inbound_authorization_header_is_not_forwarded. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago model_proxy:: 2>&1 | tail -20 <acceptance_criteria>

  • grep -q "is_authenticated" core/archipelago/src/api/handler/model_proxy.rs
  • grep -q "secrets/claude-api-key" core/archipelago/src/api/handler/model_proxy.rs
  • grep -c "mod model_proxy" core/archipelago/src/api/handler/mod.rs returns 1
  • cd core && cargo test --package archipelago model_proxy:: exits 0 with claude_without_session_is_401, ollama_without_session_is_401, missing_key_is_503_not_500 and inbound_authorization_header_is_not_forwarded all passing
  • grep -rniE 'debug!|info!|warn!|error!' core/archipelago/src/api/handler/model_proxy.rs | grep -ciE 'body|api_key|x-api-key' | grep -qx 0 — no log statement in this file references a body or a key </acceptance_criteria> A forwarder is a handler; reverting restores the previous nginx target. The one-way part is the deleted second key ledger, which is a strict improvement. Unauthenticated requests to both /aiui/api/claude/ and /aiui/api/ollama/ are refused before any upstream call; authenticated ones succeed using the node's single key ledger.
Task 2: Retire the Python sidecar, its key, and the OpenRouter open relay image-recipe/configs/nginx-archipelago.conf, scripts/deploy-to-target.sh, scripts/setup-aiui-server.sh, core/archipelago/src/api/rpc/system/handlers.rs - `image-recipe/configs/nginx-archipelago.conf` lines 46-100 (first server block) **and** lines 955-995 (second server block) — both must change; a fix applied to only one leaves the exposure live on whichever block serves the request. - `scripts/deploy-to-target.sh` lines 399, 703-735, 778-780, 875-956 — the `3141`→`3142` seds, the AIUI rsync section, and the embedded `claude-api-proxy.py` heredoc plus its systemd unit. - `scripts/setup-aiui-server.sh` lines 29-47 and 115-125 — the `ANTHROPIC_API_KEY` requirement and the `patch-nginx-claude.py` step. - `core/archipelago/src/api/rpc/system/handlers.rs` lines 1015-1072 — `handle_system_settings_set`'s `claude_api_key` branch, which today writes a **second** key copy to `secrets/claude-api-proxy.env` and restarts the sidecar. - `CLAUDE.md` — "Verify on the real node .228 before any tag" and the commit/push discipline. In **both** nginx server blocks: change each `location /aiui/api/claude/` and `location /aiui/api/ollama/` `proxy_pass` target from `http://127.0.0.1:3142/` and `http://127.0.0.1:11434/` to `http://127.0.0.1:5678` (the Rust daemon), preserving the full original request URI so the daemon sees `/aiui/api/claude/...` — i.e. use a `proxy_pass` without a trailing path component. Keep the existing long `proxy_read_timeout 300s` and `proxy_buffering off` so streaming still works. Replace the comment "API key managed by proxy, no session gate needed" with one stating that the daemon enforces the session — the old comment is the reasoning error that produced the exposure and must not survive as a template for the next person.

Delete both location /aiui/api/openrouter/ blocks outright. Rationale to record in a replacement comment: the node holds no OpenRouter key, OpenRouter is not in D-04's backend chain, and an unauthenticated proxy_pass to a paid third-party API from the node's IP is a plain open relay. AIUI's standalone mode keeps its own proxy (D-17) and is unaffected.

In scripts/deploy-to-target.sh: delete the embedded claude-api-proxy.py heredoc, the claude-api-proxy.service unit creation, the systemctl enable/restart claude-api-proxy calls, the EXISTING_KEY/ANTHROPIC_API_KEY extraction, and both 31413142 sed fixups. Add a step that stops, disables and removes any pre-existing claude-api-proxy unit and deletes /opt/archipelago/claude-api-proxy.py and <data_dir>/secrets/claude-api-proxy.env on the target — deploying the fix without removing the old listener leaves the exposure running on every already-provisioned node.

In scripts/setup-aiui-server.sh: drop the hard ANTHROPIC_API_KEY requirement and the patch-nginx-claude.py invocation. The script's remaining job is the AIUI dist rsync; the key now lives only where system.settings.set claude_api_key puts it.

In core/archipelago/src/api/rpc/system/handlers.rs: in the claude_api_key branch, delete the secrets/claude-api-proxy.env write and the systemctl restart claude-api-proxy command. Keep the secrets/claude-api-key write and its 0600 permissions exactly as they are. Add a one-line comment naming that this is deliberately the only ledger.

Commit each file group as its own focused commit and push to gitea-ai main per CLAUDE.md. Stage explicitly by path — another agent may share the tree.

grep -c 'openrouter' image-recipe/configs/nginx-archipelago.conf | grep -qx 0 grep -c '3142' image-recipe/configs/nginx-archipelago.conf scripts/deploy-to-target.sh scripts/setup-aiui-server.sh | grep -vq ':[1-9]' grep -c 'claude-api-proxy' core/archipelago/src/api/rpc/system/handlers.rs | grep -qx 0 cd core && CARGO_INCREMENTAL=0 cargo build --package archipelago 2>&1 | tail -5 - `grep -c 'openrouter' image-recipe/configs/nginx-archipelago.conf` returns 0 - `grep -c '127.0.0.1:3142' image-recipe/configs/nginx-archipelago.conf` returns 0 - `grep -c 'location /aiui/api/claude/' image-recipe/configs/nginx-archipelago.conf` returns 2 — **both** server blocks were changed, not one - `grep -c 'claude-api-proxy' scripts/deploy-to-target.sh` returns a number > 0 only for lines that *remove* the unit; `grep -c 'PORT = 3142' scripts/deploy-to-target.sh` returns 0 - `grep -c 'claude-api-proxy' core/archipelago/src/api/rpc/system/handlers.rs` returns 0 - `grep -c 'secrets/claude-api-key' core/archipelago/src/api/rpc/system/handlers.rs` returns ≥ 1 — the surviving single ledger - `cd core && cargo build --package archipelago` exits 0 - `git log --oneline -3` shows focused commits pushed to `gitea-ai main` No node built or deployed from this repo starts a `claude-api-proxy` unit, holds a second `ANTHROPIC_API_KEY`, or serves an OpenRouter relay; both nginx server blocks route the AIUI model paths through the authenticated daemon. Task 3: Prove it on a real node — a green cargo test proves nothing here tests/production-quality/aiui-proxy-closed.sh - `tests/production-quality/lnd-cors-test.sh` — the existing shell-test precedent in this directory: shebang, argument handling, pass/fail output shape, exit code convention. - `.planning/phases/13-.../13-AI-SPEC.md` §5, invariant **S-15** and the sentence after the table: "S-15 is not a unit test and must not be treated as one." - `CLAUDE.md` — "Verify on the real node .228 before any tag"; reachable dev nodes and their creds are in the project memory notes. `tests/production-quality/aiui-proxy-closed.sh ` — a shell check that, with **no** session cookie, requests `/aiui/api/claude/v1/messages`, `/aiui/api/ollama/api/tags` and `/aiui/api/openrouter/` against a live node and asserts each returns 401, 403 or 404 and never 200. It also asserts over SSH that no `claude-api-proxy` systemd unit is loaded and that nothing is listening on port 3142.

Write the script (following lnd-cors-test.sh's shape), deploy the built binary and the nginx config to a dev node, then run it.

  1. Build and deploy to the dev pair per CLAUDE.md (ARCHIPELAGO_TARGET=... scripts/deploy-to-target.sh) — archi-dev-box first, per the standing "deploy to the dev pair BEFORE any OTA" rule.
  2. Run bash tests/production-quality/aiui-proxy-closed.sh <node-host> from your workstation. Expect every line to report the status code and ok.
  3. Confirm the positive case still works: log in to neode-ui on that node in a browser, open the Chat view, and confirm the embedded AIUI still answers. (The path is unchanged; only its auth and upstream moved.)
  4. On the node: systemctl status claude-api-proxy must report Unit claude-api-proxy.service could not be found, and ss -ltnp | grep 3142 must return nothing.
  5. Confirm the key ledger: sudo ls /var/lib/archipelago/secrets/ shows claude-api-key and no claude-api-proxy.env. <acceptance_criteria>
  • bash tests/production-quality/aiui-proxy-closed.sh <node> exits 0
  • curl -s -o /dev/null -w '%{http_code}' http://<node>/aiui/api/claude/v1/messages returns 401, 403 or 404 — never 200
  • curl -s -o /dev/null -w '%{http_code}' http://<node>/aiui/api/openrouter/ returns 404
  • ssh <node> 'systemctl is-active claude-api-proxy' reports inactive or unknown, and ssh <node> 'ss -ltn | grep -c :3142' returns 0
  • ssh <node> 'sudo ls /var/lib/archipelago/secrets/' lists claude-api-key and does not list claude-api-proxy.env
  • An authenticated browser session on that node still gets a chat reply in the embedded AIUI </acceptance_criteria> Type "approved" with the four status codes you observed, or describe what still answered 200.

<threat_model>

Trust Boundaries

Boundary Description
public web port → /aiui/api/* The boundary that is currently open. Today: anonymous → paid third-party API on the owner's dime
nginx → Rust daemon (127.0.0.1:5678) Loopback; the daemon re-derives auth from the forwarded cookie, it does not trust nginx
node → api.anthropic.com / 127.0.0.1:11434 Egress carrying chat text and the node's key
operator settings → key at rest system.settings.set claude_api_keysecrets/claude-api-key, 0600

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-08 Elevation of Privilege /aiui/api/claude/ (port-3142 proxy) critical mitigate Re-point to the daemon behind is_authenticated; delete the sidecar, its unit and its key. Verified on a real node by aiui-proxy-closed.sh (S-15), not by cargo test
T-13-09 Denial of Service (financial) Same — anonymous budget exhaustion critical mitigate Same fix. Budget exhaustion was reachable by anyone who could route to the node's web port
T-13-10 Elevation of Privilege /aiui/api/openrouter/ open relay high mitigate Deleted. Not in D-04's chain; the node holds no key for it; an unauthenticated relay from the node's IP is abusable independently of any node key
T-13-11 Elevation of Privilege /aiui/api/ollama/ free local compute medium mitigate Same session gate. Anonymous local-GPU/CPU inference is a resource-exhaustion vector even with no key involved
T-13-12 Information Disclosure Two key ledgers (claude-api-key + claude-api-proxy.env) high mitigate Collapse to one. secrets/claude-api-proxy.env is deleted on deploy, and system.settings.set stops writing it
T-13-13 Information Disclosure Chat bodies in the daemon's journal medium mitigate Field policy in model_proxy.rs: log path/status/upstream host only. Asserted by the no-body-in-log grep
T-13-14 Spoofing Inbound authorization/x-api-key forwarded upstream medium mitigate Request headers are allowlisted to content-type/accept; asserted by inbound_authorization_header_is_not_forwarded
T-13-15 Tampering Fix applied to only one of the two nginx server blocks high mitigate Acceptance criterion counts location /aiui/api/claude/ == 2 and openrouter == 0 across the whole file
T-13-SC Tampering npm/pip/cargo installs high mitigate This plan adds zero packages; it removes a Python one. No install task, so no legitimacy checkpoint is required
</threat_model>
- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago model_proxy::` green - `grep -c openrouter image-recipe/configs/nginx-archipelago.conf` == 0 - `bash tests/production-quality/aiui-proxy-closed.sh ` exits 0 against a real deployed node - Positive path preserved: an authenticated browser session still gets a chat reply from the embedded AIUI

<success_criteria> The live unauthenticated door into a paid API is closed on the source of truth (both nginx server blocks), on the deploy path (no sidecar is installed and any existing one is removed), and on already-provisioned nodes — and that is demonstrated with curl against a real node, not with a unit test. </success_criteria>

Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-SUMMARY.md` when done