Files

268 lines
22 KiB
Markdown

---
phase: 13-aiui-functional-conversational-node-control-and-content-surf
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: false
requirements: [AIUI-04]
must_haves:
truths:
- "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"
artifacts:
- path: "core/archipelago/src/api/handler/model_proxy.rs"
provides: "Session-gated forwarder for /aiui/api/claude/* and /aiui/api/ollama/*, replacing claude-api-proxy.py"
contains: "is_authenticated"
- path: "tests/production-quality/aiui-proxy-closed.sh"
provides: "S-15 deployed-surface check — the one check a green cargo test cannot make"
contains: "aiui/api/claude"
key_links:
- from: "image-recipe/configs/nginx-archipelago.conf"
to: "core/archipelago/src/api/handler/model_proxy.rs"
via: "/aiui/api/claude/ proxy_pass re-pointed from 127.0.0.1:3142 to the Rust daemon on 127.0.0.1:5678"
pattern: "proxy_pass http://127\\.0\\.0\\.1:5678"
- from: "core/archipelago/src/api/handler/model_proxy.rs"
to: "core/archipelago/src/api/rpc/mesh/assistant.rs"
via: "reads the same data_dir/secrets/claude-api-key — one ledger, not two"
pattern: "secrets/claude-api-key"
---
<objective>
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`.
</objective>
<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 `3141``3142` `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>
<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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Session-gated model forwarder in the Rust daemon</name>
<files>core/archipelago/src/api/handler/model_proxy.rs, core/archipelago/src/api/handler/mod.rs</files>
<behavior>
- 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.
</behavior>
<read_first>
- `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.
</read_first>
<action>
Create `core/archipelago/src/api/handler/model_proxy.rs` with `pub(super) async fn handle_model_proxy(&self, req, path) -> Result<Response<Body>>` 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`.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago model_proxy:: 2>&amp;1 | tail -20</automated>
</verify>
<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>
<reversibility rating="reversible">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.</reversibility>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Retire the Python sidecar, its key, and the OpenRouter open relay</name>
<files>image-recipe/configs/nginx-archipelago.conf, scripts/deploy-to-target.sh, scripts/setup-aiui-server.sh, core/archipelago/src/api/rpc/system/handlers.rs</files>
<read_first>
- `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.
</read_first>
<action>
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 `3141``3142` `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.
</action>
<!-- planner-discipline-allow: openrouter -->
<verify>
<automated>grep -c 'openrouter' image-recipe/configs/nginx-archipelago.conf | grep -qx 0</automated>
<automated>grep -c '3142' image-recipe/configs/nginx-archipelago.conf scripts/deploy-to-target.sh scripts/setup-aiui-server.sh | grep -vq ':[1-9]'</automated>
<automated>grep -c 'claude-api-proxy' core/archipelago/src/api/rpc/system/handlers.rs | grep -qx 0</automated>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo build --package archipelago 2>&amp;1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Prove it on a real node — a green cargo test proves nothing here</name>
<files>tests/production-quality/aiui-proxy-closed.sh</files>
<read_first>
- `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.
</read_first>
<what-built>
`tests/production-quality/aiui-proxy-closed.sh <node-host>` — 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.
</what-built>
<how-to-verify>
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`.
</how-to-verify>
<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>
<resume-signal>Type "approved" with the four status codes you observed, or describe what still answered 200.</resume-signal>
</task>
</tasks>
<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_key``secrets/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>
<verification>
- `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 <dev-node>` exits 0 against a real deployed node
- Positive path preserved: an authenticated browser session still gets a chat reply from the embedded AIUI
</verification>
<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>
<output>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-SUMMARY.md` when done
</output>