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 |
|
false |
|
|
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_UPSTREAMcore/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 existingtests/production-quality/lnd-cors-test.shprecedent)
Symbols deleted by this plan (so a later drift scan does not flag their absence):
- the embedded
claude-api-proxy.pyheredoc inscripts/deploy-to-target.sh(~lines 879-955) - the
claude-api-proxysystemd unit and itsANTHROPIC_API_KEYenvironment line - the
secrets/claude-api-proxy.envwrite and thesystemctl restart claude-api-proxycall incore/archipelago/src/api/rpc/system/handlers.rs(~lines 1052-1067) - the
3141→3142proxy_passsed fixups inscripts/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.rsgrep -q "secrets/claude-api-key" core/archipelago/src/api/handler/model_proxy.rsgrep -c "mod model_proxy" core/archipelago/src/api/handler/mod.rsreturns 1cd core && cargo test --package archipelago model_proxy::exits 0 withclaude_without_session_is_401,ollama_without_session_is_401,missing_key_is_503_not_500andinbound_authorization_header_is_not_forwardedall passinggrep -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.
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.
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.
- 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. - Run
bash tests/production-quality/aiui-proxy-closed.sh <node-host>from your workstation. Expect every line to report the status code andok. - 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.)
- On the node:
systemctl status claude-api-proxymust reportUnit claude-api-proxy.service could not be found, andss -ltnp | grep 3142must return nothing. - Confirm the key ledger:
sudo ls /var/lib/archipelago/secrets/showsclaude-api-keyand noclaude-api-proxy.env. <acceptance_criteria>
bash tests/production-quality/aiui-proxy-closed.sh <node>exits 0curl -s -o /dev/null -w '%{http_code}' http://<node>/aiui/api/claude/v1/messagesreturns 401, 403 or 404 — never 200curl -s -o /dev/null -w '%{http_code}' http://<node>/aiui/api/openrouter/returns 404ssh <node> 'systemctl is-active claude-api-proxy'reportsinactiveorunknown, andssh <node> 'ss -ltn | grep -c :3142'returns 0ssh <node> 'sudo ls /var/lib/archipelago/secrets/'listsclaude-api-keyand does not listclaude-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_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> |
<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>