From 203966030b8ead4cc14ae0cee44baf52e125d26e Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 3 Aug 2026 10:42:25 -0400 Subject: [PATCH] =?UTF-8?q?docs(13):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=2015=20plans=20in=208=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIUI conversational node control & content surfaces, decomposed tracer-first: 13-01 leads with one end-to-end read-only tool proving the whole spine (AIUI chat -> postMessage -> authenticated RPC -> Rust agent loop -> real node data), then expands. Waves 1-8 across three tracks that stay independent per D-13: - control/assistant: 13-01, 13-05, 13-08, 13-10, 13-12, 13-13, 13-14 - content: 13-06 - music library: 13-04, 13-07, 13-11 (no control/content plan depends on it) - security & delivery: 13-02, 13-03, 13-09 - on-device sign-off: 13-15 Notable decisions recorded in the plans: - Open Q1: delete-and-replace the live unauthenticated port-3142 Claude proxy with a session-gated Rust forwarder; the OpenRouter open relay is removed. - Open Q2: /aiui/-scoped CSP connect-src plus a per-session rate limit; the iframe sandbox attribute is explicitly rejected with reasons. - Open Q3: a live Routstr spike (13-03) gates the Routstr backend (13-13). - Open Q4: one "assistant." dispatcher prefix arm, so the existing session/CSRF/RBAC gate applies unchanged before dispatch. - Promote (not add-alongside) CallerScope as the primary caller/permission noun; the mesh-specific controls become one variant's resolution inputs. - schemars rejected as an unaudited crate; JSON Schema is hand-written. - AI-SPEC's `cargo test --test assistant_evals` corrected to an in-crate module: core/archipelago is a binary-only crate with no lib target. Also adds COVERAGE.md (Routstr capability matrix, every opt-out reasoned). Co-Authored-By: Claude Opus 5 (1M context) --- .planning/ROADMAP.md | 44 ++- .../13-01-PLAN.md | 341 ++++++++++++++++++ .../13-02-PLAN.md | 267 ++++++++++++++ .../13-03-PLAN.md | 185 ++++++++++ .../13-04-PLAN.md | 274 ++++++++++++++ .../13-05-PLAN.md | 303 ++++++++++++++++ .../13-06-PLAN.md | 285 +++++++++++++++ .../13-07-PLAN.md | 234 ++++++++++++ .../13-08-PLAN.md | 317 ++++++++++++++++ .../13-09-PLAN.md | 299 +++++++++++++++ .../13-10-PLAN.md | 249 +++++++++++++ .../13-11-PLAN.md | 252 +++++++++++++ .../13-12-PLAN.md | 294 +++++++++++++++ .../13-13-PLAN.md | 298 +++++++++++++++ .../13-14-PLAN.md | 286 +++++++++++++++ .../13-15-PLAN.md | 246 +++++++++++++ .../COVERAGE.md | 50 +++ 17 files changed, 4222 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-03-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-07-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-08-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-10-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-11-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-12-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-13-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-14-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-15-PLAN.md create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/COVERAGE.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d5b424ef..99edec19 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -342,8 +342,48 @@ Plans: **Depends on:** Independent of Phases 1–12 for its UI and content work. Its security model must not contradict Phase 10 (Key-Material Hardening) — coordinate rather than widen. AIUI's own source lives in a **separate repository** (`git.tx1138.com/lfg2025/AIUI`, branch `development`, cloned at `~/Projects/AIUI`), so this phase spans two repos and needs push access to both. -**Plans:** 0 plans +**Plans:** 15 plans in 8 waves Plans: -- [ ] TBD (run /gsd-plan-phase 13 to break down) +**Wave 1** *(tracer + the two independent security/spike tracks)* + +- [ ] 13-01-PLAN.md — TRACER: typed AIUI chat reaches a real node tool and returns a real result (AIUI-01) +- [ ] 13-02-PLAN.md — Close the live unauthenticated model proxies: session-gated Rust forwarder, port-3142 sidecar deleted (AIUI-04) +- [ ] 13-03-PLAN.md — Routstr protocol spike + capability coverage matrix (AIUI-01) + +**Wave 2** + +- [ ] 13-04-PLAN.md — Music library: one-way entity-model decision + lofty legitimacy gate + tag extraction (AIUI-03) +- [ ] 13-05-PLAN.md — Curated tool registry, D-09 authority ceiling, D-16 default-closed grants, conversational settings (AIUI-01/02) +- [ ] 13-06-PLAN.md — Content surfaces: ContentItem → Film/Song/Podcast adapter, AIUI grids fed from Archy (AIUI-03) + +**Wave 3** + +- [ ] 13-07-PLAN.md — Music index + music.* RPCs + freshness (AIUI-03) +- [ ] 13-08-PLAN.md — D-11 confirm gate: node-authored, nonce-bound, rendered in trusted chrome (AIUI-01/04) + +**Wave 4** + +- [ ] 13-09-PLAN.md — AIUI delivery: enforced build, pinned commit, live-asset verify, iframe sandbox mechanism (AIUI-04/05) +- [ ] 13-10-PLAN.md — D-04 chain: Ollama tool-calling + D-08 node-side history (AIUI-01) +- [ ] 13-11-PLAN.md — SongGrid lit from the real library + the m4a/aac/opus/wma share-mime fix (AIUI-03) + +**Wave 5** + +- [ ] 13-12-PLAN.md — D-10 untrusted-content boundary + cloud-egress guardrails + rate limiting (AIUI-04) + +**Wave 6** + +- [ ] 13-13-PLAN.md — Routstr backend + D-05 hard budget ceiling (AIUI-01) + +**Wave 7** + +- [ ] 13-14-PLAN.md — Eval harness: ScriptedBackend suite, EV-01..EV-18, cross-backend parity (AIUI-01/04) + +**Wave 8** + +- [ ] 13-15-PLAN.md — On-device sign-off: archi-dev-box, embedded iframe, desktop + mobile (AIUI-06) + +**Track note (D-13):** the music-library track (13-04 → 13-07 → 13-11) is independent — no plan +on the control or content track depends on any music plan. diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-PLAN.md new file mode 100644 index 00000000..8ae61a40 --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-PLAN.md @@ -0,0 +1,341 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - core/archipelago/src/assistant/mod.rs + - core/archipelago/src/assistant/tools.rs + - core/archipelago/src/assistant/loop_.rs + - core/archipelago/src/assistant/backends/mod.rs + - core/archipelago/src/assistant/backends/claude.rs + - core/archipelago/src/assistant/backends/scripted.rs + - core/archipelago/src/api/rpc/assistant_chat.rs + - core/archipelago/src/api/rpc/dispatcher.rs + - core/archipelago/src/main.rs + - neode-ui/src/types/aiui-protocol.ts + - neode-ui/src/services/contextBroker.ts + - /home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts + - /home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts +autonomous: true +requirements: [AIUI-01] + +must_haves: + truths: + - "An operator types a plain-language question in the embedded AIUI chat and gets an answer computed from real node state (D-01)" + - "The Claude API key never leaves the node — no model key is present in any bundle neode-ui or AIUI ships to the browser (D-01)" + - "assistant.chat is unreachable without an authenticated session; UNAUTHENTICATED_METHODS is not widened (Phase-10 hard constraint)" + - "A tool the model names but which is not in the curated registry returns a `no such tool` error turn, never an execution (D-06)" + - "AIUI still runs standalone with its own dev proxy when `embedded` is false (D-17)" + - "A pending confirmation is in-memory only: a daemon restart mid-wait resolves it as declined and never executes it, and a second user message while one is pending neither clears nor auto-approves it (edge: AIUI-01 concurrency)" + - statement: "With two browser tabs open on the same node, the first valid confirmation nonce wins and the second is refused as a nonce mismatch rather than executing twice" + verification: backstop + artifacts: + - path: "core/archipelago/src/assistant/mod.rs" + provides: "The D-02 shared assistant service root: CallerScope, PermissionCategory, chat() entry" + contains: "pub enum CallerScope" + - path: "core/archipelago/src/assistant/tools.rs" + provides: "D-06 curated tool registry — ToolDef and the first read-only tool" + contains: "pub struct ToolDef" + - path: "core/archipelago/src/assistant/loop_.rs" + provides: "run_loop + execute_tool — the single choke point every tool call passes through" + contains: "async fn execute_tool" + - path: "core/archipelago/src/assistant/backends/mod.rs" + provides: "Backend trait + BackendTurn — the wire-format-agnostic seam" + contains: "pub trait Backend" + - path: "core/archipelago/src/api/rpc/assistant_chat.rs" + provides: "assistant.* RPC sub-dispatcher and handle_assistant_chat" + contains: "handle_assistant" + - path: "neode-ui/src/services/contextBroker.ts" + provides: "chat:request / chat:response transport over the existing origin-checked postMessage channel" + contains: "chat:request" + key_links: + - from: "/home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts" + to: "neode-ui/src/services/contextBroker.ts" + via: "archyBridge.sendChat() postMessage when embedded — replaces the direct api/claude fetch" + pattern: "sendChat" + - from: "neode-ui/src/services/contextBroker.ts" + to: "core/archipelago/src/api/rpc/assistant_chat.rs" + via: "rpcClient.call({ method: 'assistant.chat' }) on the page's own session cookie + CSRF token" + pattern: "assistant\\.chat" + - from: "core/archipelago/src/assistant/loop_.rs" + to: "core/archipelago/src/api/rpc/dispatcher.rs" + via: "execute_tool dispatches to handle_system_disk_status — the same handler every authenticated caller uses" + pattern: "handle_system_disk_status" +--- + + +Prove the whole spine end-to-end with one read-only tool: a typed question in the embedded +AIUI chat travels over the existing origin-checked postMessage channel to neode-ui's broker, +onto the node over the page's authenticated RPC session, into a Rust agent loop that calls a +model, executes exactly one curated tool against the node's real `system.disk-status` handler, +feeds the result back to the model, and returns a real answer that renders in the chat. + +This is the tracer slice for Phase 13 (D-01, D-02, D-06). It is production-quality, not a +prototype — every later plan expands out from it: more tools (13-05), the confirm gate (13-08), +more backends (13-10, 13-13), content grids (13-06). Nothing in it is a stub that would need an +architectural change to fill. + +Purpose: catch an architectural dead-end after one commit instead of after ten. The four layers +this phase spans (Rust agent service, RPC dispatch, neode-ui broker, AIUI client) have never +been wired together; if the shape is wrong, it is wrong here. + +Output: `core/archipelago/src/assistant/`, the `assistant.*` RPC surface, the `chat:*` +postMessage message types, and AIUI's embedded-mode chat branch. + + + +**Noun that is now primary:** a **caller scope** — a caller identity carrying the permission +scope its tool calls resolve authority through. "A mesh peer" and "the local operator in AIUI" +are two variants of it; Pine voice will be a third. + +**Decision: `promote`.** + +Rationale: D-02's stated intent is "callers distinguished by permission scope", and today's +`mesh/listener/assist.rs` shapes its peer-facing controls (`trusted_only`, `allowed_contacts`, +`denied_askers`) around the single mesh caller. Adding AIUI's permissions *alongside* a +still-mesh-shaped model would recreate exactly the two divergent security models D-02 exists to +prevent — the seam where they diverge is the seam where a future tool gets the wrong authority. + +Concretely: `assistant/mod.rs` defines `CallerScope` as the primary representation, with +variants `Mesh { peer_id }` and `LocalOperator { session_id }` (and a documented, not-yet-built +`Voice` slot). `CallerScope::granted_categories()` is the **only** source of authority +`execute_tool` reads. The mesh controls are demoted to inputs that the `Mesh` variant resolves +its granted set from — they keep working unchanged for mesh/LoRa callers, they just stop being +the shape everything else is bolted onto. + +**Suggested (not required) invariant test:** `assistant::tests::every_caller_variant_resolves_authority_through_caller_scope` +— iterate every `CallerScope` variant, assert each one's tool authority comes from +`granted_categories()` and that no `execute_tool` branch reads a mesh-specific field directly. +Goes red if a future phase reintroduces the mesh-only assumption. + + + +None in this plan. (The four `unclassified` edge-probe rows are surfaced in 13-05, 13-09 and +13-15, where their requirements live.) + + + +Symbols created by **this plan** (excluded from drift verification — they do not exist yet): + +**Rust — `core/archipelago/src/assistant/`** +- `mod.rs`: `CallerScope` (enum: `Mesh`, `LocalOperator`), `PermissionCategory` (enum, D-16's + 10 categories), `ToolExecCtx` (struct), `pub async fn chat(...)`, `AssistantError` +- `tools.rs`: `ToolDef` (struct), `ToolRegistry` (struct), `ToolCall`, `ToolResult`, + `ChatMessage`, `Role`, `fn registry()`, `fn system_disk_status_tool()`, + `struct SystemDiskStatusArgs`, `ToolDef::validate` +- `loop_.rs`: `pub async fn run_loop`, `async fn execute_tool`, `const MAX_TURNS` +- `backends/mod.rs`: `pub trait Backend`, `enum BackendTurn`, `fn select_backend` +- `backends/claude.rs`: `struct ClaudeBackend`, `const CLAUDE_MODEL`, `const ASSISTANT_HTTP_TIMEOUT`, + `const ASSISTANT_MAX_TOKENS` +- `backends/scripted.rs`: `struct ScriptedBackend` (`#[cfg(test)]` only) + +**Rust — RPC** +- `core/archipelago/src/api/rpc/assistant_chat.rs`: `handle_assistant` (prefix sub-dispatcher), + `handle_assistant_chat` +- New RPC method names: `assistant.chat` +- `core/archipelago/src/main.rs`: `mod assistant;` + +**TypeScript — neode-ui** +- `types/aiui-protocol.ts`: `AIUIChatRequest`, `ArchyChatResponse` (added to the `AIUIRequest` / + `ArchyResponse` unions) +- `services/contextBroker.ts`: `handleChatRequest` (private method) + +**TypeScript — AIUI (`/home/archipelago/Projects/AIUI`, branch `development`)** +- `services/archyBridge.ts`: `sendChat(text, onToken)` exported on `archyBridge` +- `composables/useAI.ts`: `streamViaArchy` (embedded-mode branch) + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@CLAUDE.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-AI-SPEC.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-PATTERNS.md + + + + + + Task 1: End-to-end "how much space is left" — the Rust spine, one tool, one backend + +core/archipelago/src/assistant/mod.rs, +core/archipelago/src/assistant/tools.rs, +core/archipelago/src/assistant/loop_.rs, +core/archipelago/src/assistant/backends/mod.rs, +core/archipelago/src/assistant/backends/claude.rs, +core/archipelago/src/assistant/backends/scripted.rs, +core/archipelago/src/api/rpc/assistant_chat.rs, +core/archipelago/src/api/rpc/dispatcher.rs, +core/archipelago/src/main.rs + + +- `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-AI-SPEC.md` §3 (the `run_loop` sketch, the `Backend` trait, `ScriptedBackend`) and §4 (the `execute_tool` sketch). **This file IS the pattern source — 13-PATTERNS.md records "no analog exists in this codebase" for `loop_.rs` and `tools.rs`.** +- `core/archipelago/src/mesh/listener/assist.rs` — the analog for `backends/claude.rs`'s HTTP client construction (`call_claude`), and for the "spawned off the loop so it never blocks" concurrency discipline. Read `call_ollama`/`call_claude`/`run_assist`/`is_sender_allowed` in full. +- `core/archipelago/src/api/rpc/mesh/assistant.rs` — the exact analog for `assistant_chat.rs`'s handler shape (`impl RpcHandler` + `pub(in crate::api::rpc) async fn handle_* -> Result`) and for the `data_dir/secrets/claude-api-key` availability probe at lines 27-30. +- `core/archipelago/src/api/rpc/dispatcher.rs` lines 440-480 — the registration block; note `"mesh.assistant-status"` at 445 and `"system.disk-status" => self.handle_system_disk_status()` at 470. +- `core/archipelago/src/api/rpc/mod.rs` lines 264-330 — the session-cookie + CSRF + `role.can_access(&method)` gate every dispatched method already passes through. +- `core/archipelago/src/api/rpc/middleware.rs` — `UNAUTHENTICATED_METHODS`. Read it to confirm you are not adding to it. +- `core/archipelago/src/swarm/payment.rs` — read the `#[tokio::test]` module at the bottom for this codebase's async unit-test convention. + + +Create the `assistant` module — the D-02 shared service — following AI-SPEC §3's structure exactly, and register `mod assistant;` in `core/archipelago/src/main.rs` (this is a binary-only crate; there is no `lib.rs`, so all tests are in-crate `#[cfg(test)] mod tests`). + +`mod.rs` defines the promoted primary noun per the `` block above: `pub enum CallerScope { Mesh { peer_id: String }, LocalOperator { session_id: String } }` with `fn granted_categories(&self) -> BTreeSet`, plus `pub enum PermissionCategory` carrying D-16's ten variants (`Apps`, `System`, `Network`, `Wallet`, `Files`, `Media`, `Search`, `AiLocal`, `Notes`, `Bitcoin`), a `ToolExecCtx { registry, caller: CallerScope, handler: Arc }`, and the public `pub async fn chat(ctx, user_text) -> Result` entry. For this tracer `LocalOperator::granted_categories` returns `{System}` sourced from a hardcoded default set — 13-05 replaces that source with the persisted D-16 default-closed grants store, which is a data-source change, not an architectural one. `Mesh::granted_categories` resolves from the existing `trusted_only`/`allowed_contacts`/`denied_askers` inputs so mesh callers behave exactly as today. + +`tools.rs` defines `ToolDef { name: &'static str, description: &'static str, parameters: serde_json::Value, category: PermissionCategory, destructive: bool }`, the normalized `ChatMessage`/`Role`/`ToolCall { id, name, arguments: Value }`/`ToolResult { call_id, content, is_error }` types from AI-SPEC §3, a `ToolRegistry` wrapping a `&'static [ToolDef]`-backed lookup by name, and exactly ONE tool: `system_disk_status` — category `System`, `destructive: false`, description naming that it reports free and total disk space on this node. **Do NOT add the `schemars` crate** — it is not in `Cargo.toml` and is not covered by 13-RESEARCH.md's Package Legitimacy Audit, so adding it would bypass the package-legitimacy gate. Instead hand-write `parameters` as a `serde_json::json!` JSON Schema object literal adjacent to a `#[derive(Deserialize)] struct SystemDiskStatusArgs` (empty for this tool), and add a unit test that round-trips the schema's declared `required` keys through `serde_json::from_value::` so the schema and the deserialization target cannot drift apart silently. `ToolDef::validate(&self, raw: &Value)` deserializes-and-refuses per AI-SPEC §4b.1 — never coerce, never guess, never panic. + +`backends/mod.rs` defines `#[async_trait] pub trait Backend { async fn send(&self, system: &str, tools: &[ToolDef], history: &[ChatMessage]) -> Result }` and `pub enum BackendTurn { Text(String), ToolCalls(Vec) }`, plus `select_backend()` returning the first available backend in D-04's order. For this tracer only the Claude leg is implemented; `select_backend` must be written so `backends/ollama.rs` (13-10) and `backends/routstr.rs` (13-13) slot in ahead of and behind it without changing the trait — that is the architectural commitment this tracer is proving. + +`backends/claude.rs` implements `Backend` against the Anthropic Messages API: key read from `self.config.data_dir.join("secrets/claude-api-key")` (the SAME path `mesh/rpc/mesh/assistant.rs` probes — do not introduce a second key location), model `claude-haiku-4-5-20251001`, `max_tokens: 2048`, `tools` mapped from `ToolDef.parameters` into Anthropic's `input_schema` field, `tool_choice: {"type":"auto","disable_parallel_tool_use":true}` per AI-SPEC §3 Pitfall 5, `stream: false`. Parse `content` blocks of `type: "tool_use"` into `BackendTurn::ToolCalls` echoing `tool_use.id` into `ToolCall.id`; parse `type: "text"` into `BackendTurn::Text`. Define NEW module-scoped constants `ASSISTANT_HTTP_TIMEOUT` (180s) and `ASSISTANT_MAX_TOKENS` (2048) — do NOT import `OLLAMA_TIMEOUT`/`MAX_REPLY_CHARS`/`CHUNK_CHARS` from `assist.rs`, which are LoRa-airtime-tuned (AI-SPEC §3 Pitfall 6). + +`backends/scripted.rs` is `#[cfg(test)]`-gated and implements `Backend` by replaying a canned `Vec`, per AI-SPEC §5. It must never compile into the shipped binary. + +`loop_.rs` implements `run_loop` and `execute_tool` per AI-SPEC §3/§4 with `const MAX_TURNS: usize = 8`. `execute_tool` is the single choke point and, in this tracer, already enforces: unknown-tool refusal (returns an error turn naming the missing tool, never silently ignores), the `ctx.caller.granted_categories()` check, and `ToolDef::validate` before execution. The `destructive` branch is present and returns a not-yet-implemented error for any destructive tool — there are none in the registry yet, and 13-08 fills that branch with the real confirm gate. A tool's `execute` dispatches to the SAME `RpcHandler` method every other authenticated caller uses (`handle_system_disk_status`) — never a parallel AI-only code path. + +`api/rpc/assistant_chat.rs` adds `handle_assistant(&self, method: &str, params) -> Result` as a prefix sub-dispatcher plus `handle_assistant_chat`. Register in `dispatcher.rs` as a SINGLE guarded arm `m if m.starts_with("assistant.") => self.handle_assistant(m, params).await` placed adjacent to the `"mesh.assistant-*"` block at ~445, so every later `assistant.*` method (13-05's `list-tools`/`grants-*`, 13-08's `confirm-tool`, 13-10's `history`) is added inside `assistant_chat.rs` and `dispatcher.rs` is touched exactly once in this phase. This also settles RESEARCH Open Question 4: the `role.can_access(&rpc_req.method)` RBAC check runs upstream in `api/rpc/mod.rs` on the full method string BEFORE dispatch, so `assistant.*` inherits it unchanged with no bespoke auth — assert this rather than assume it, with the test named below. + +Add `#[cfg(test)] mod tests` in `loop_.rs` (or `mod.rs`) with: `disk_status_tool_executes` (a `ScriptedBackend` emitting one `ToolCalls` turn then one `Text` turn; asserts the tool ran and the real disk figures reached the final answer), `unknown_tool_is_refused_not_ignored`, and `assistant_methods_require_session` (asserts no string starting with `assistant.` appears in `UNAUTHENTICATED_METHODS`). + + + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -20 + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant_methods_require_session + grep -c 'schemars' core/archipelago/Cargo.toml | grep -qx 0 + + +- `core/archipelago/src/assistant/mod.rs` contains `pub enum CallerScope` with both a `Mesh` and a `LocalOperator` variant, and `fn granted_categories` +- `core/archipelago/src/assistant/tools.rs` contains `pub struct ToolDef` with fields `category` and `destructive` +- `core/archipelago/src/assistant/backends/mod.rs` contains `pub trait Backend` and `pub enum BackendTurn` +- `core/archipelago/src/assistant/loop_.rs` contains `async fn execute_tool` and `const MAX_TURNS: usize = 8` +- `core/archipelago/src/assistant/backends/scripted.rs` opens with a `#![cfg(test)]` or is declared behind `#[cfg(test)] mod scripted;` in `backends/mod.rs` — `grep -n 'cfg(test)' core/archipelago/src/assistant/backends/mod.rs` returns a match +- `cd core && cargo test --package archipelago assistant::` exits 0 with `disk_status_tool_executes`, `unknown_tool_is_refused_not_ignored` and `assistant_methods_require_session` all listed as passing +- `grep -n 'assistant\.' core/archipelago/src/api/rpc/middleware.rs` returns no match (UNAUTHENTICATED_METHODS not widened) +- `grep -c 'starts_with("assistant.")' core/archipelago/src/api/rpc/dispatcher.rs` returns 1 — exactly one dispatcher arm for the whole `assistant.*` surface +- `grep -n 'secrets/claude-api-key' core/archipelago/src/assistant/backends/claude.rs` returns a match, and `grep -rn 'ANTHROPIC_API_KEY' core/archipelago/src/assistant/` returns no match (one key ledger, D-01) +- `grep -rnE 'OLLAMA_TIMEOUT|MAX_REPLY_CHARS|CHUNK_CHARS' core/archipelago/src/assistant/` returns no match (AI-SPEC §3 Pitfall 6) +- `grep -c 'schemars' core/archipelago/Cargo.toml` returns 0 — no unaudited crate added + + D-01 makes the `assistant.*` RPC surface a contract AIUI and later the voice pipeline are written against; re-homing the loop browser-side afterwards means re-implementing every tool in TypeScript and moving key handling. Flagged per CONTEXT.md's own rating, not gated. + A `ScriptedBackend` turn naming `system_disk_status` causes the real `handle_system_disk_status` to run and its real figures to appear in the loop's final answer; an unknown tool name returns an error turn; no `assistant.*` method is reachable unauthenticated. + + + + Task 2: neode-ui carries chat over the existing origin-checked bridge + neode-ui/src/types/aiui-protocol.ts, neode-ui/src/services/contextBroker.ts + +- `neode-ui/src/types/aiui-protocol.ts` (full file, 98 lines) — `AIContextCategory`, `AIActionType`, the `AIUIRequest`/`ArchyResponse` unions, `AIUI_PROTOCOL_VERSION`, `AIUI_MESSAGE_PREFIX`. +- `neode-ui/src/services/contextBroker.ts` (full file) — the constructor's `allowedOrigin` derivation (lines 26-33), the `event.origin !== this.allowedOrigin` guard at line 65, the `handleMessage` switch at lines 71-84, the `install-app` confirm block at 140-196, and `postToIframe` at 620. +- `neode-ui/src/services/__tests__/contextBroker.test.ts` — the existing suite this change must keep green. +- `neode-ui/src/api/rpc-client.ts` — the `rpcClient.call({ method, params })` signature used throughout the broker. + + +Extend the protocol and the broker with a chat transport. This is D-03's split made concrete: the browser keeps only what only it can do; everything that reads or changes the node goes over the node-side registry. + +In `aiui-protocol.ts` add `export interface AIUIChatRequest { type: 'chat:request'; id: string; text: string }` and `export interface ArchyChatResponse { type: 'chat:response'; id: string; success: boolean; text?: string; error?: string }`, and add each to the `AIUIRequest` and `ArchyResponse` unions respectively. Do NOT add a `tool-call` member to `AIActionType` — tool selection is node-side by D-01/D-03 and must never be expressible as an AIUI-originated action. + +In `contextBroker.ts` add `case 'chat:request': this.handleChatRequest(msg.id, msg.text); break;` to the existing `handleMessage` switch, and a private `async handleChatRequest(id, text)` that calls `rpcClient.call<{ text: string }>({ method: 'assistant.chat', params: { text } })` and posts the result back through the existing `postToIframe` helper as a `chat:response`. Use the existing `this.allowedOrigin` transport primitive — do NOT add a second postMessage channel and do NOT relax the origin check. On RPC failure post `{ success: false, error }` with the error message, never the raw exception object. + +The broker must NOT pass a permission category through for chat: authority is resolved node-side from `CallerScope` (Task 1), and duplicating a browser-side gate here would create the second security model D-02 exists to prevent. Add a comment at the handler naming that reason so a future reader does not "helpfully" add one back. + + + cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts + cd neode-ui && npx vue-tsc --noEmit + + +- `grep -q "chat:request" neode-ui/src/types/aiui-protocol.ts` and `grep -q "ArchyChatResponse" neode-ui/src/types/aiui-protocol.ts` +- `grep -q "assistant.chat" neode-ui/src/services/contextBroker.ts` +- `grep -c "tool-call" neode-ui/src/types/aiui-protocol.ts` returns 0 — `AIActionType` was not widened +- `cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts` exits 0 (existing suite still green) +- `cd neode-ui && npx vue-tsc --noEmit` exits 0 +- `grep -c "allowedOrigin" neode-ui/src/services/contextBroker.ts` is unchanged or higher — the origin guard was not removed or loosened + + A `chat:request` postMessage from the allowed origin produces an `assistant.chat` RPC on the page's own session and a `chat:response` back to the iframe; a message from any other origin is still dropped. + + + + Task 3: AIUI delegates the loop to the node when embedded, keeps its own when not + /home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts, /home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts + +- `/home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts` (full file) — `postToParent`, the `allowedOrigin` validation at line 52, `deriveParentOrigin()` at lines 95-115, and the `archyBridge` export object at line 117. +- `/home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts` — `BASE`/`CLAUDE_PATH`/`OPENROUTER_PATH` at lines 16-18, `streamClaude` at 261, `streamOpenRouter` at 326, and the three call sites at 564/566, 647/649, 759/761. +- `/home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts` — the `__AIUI_EMBEDDED__` detection at lines 80-81 and the existing `archyBridge.requestContext` usage at 134. **Mirror this postMessage convention; do not invent a third transport.** +- `.planning/phases/13-.../13-CONTEXT.md` D-17 — embedded delegates to the node, standalone keeps its own proxy and its own fast dev loop. + + +Work in `/home/archipelago/Projects/AIUI` on branch `development` (push access is confirmed — D-18 is satisfied, do not re-verify). + +Add `sendChat(text: string): Promise<{ text: string }>` to the `archyBridge` export object in `archyBridge.ts`, built on the existing `postToParent` + origin-validated listener pattern already used by `requestContext` — same request-id correlation, same `allowedOrigin` check, a 180s timeout matching the node's `ASSISTANT_HTTP_TIMEOUT`. Reject with a plain `Error` on timeout or on `success: false`. + +In `useAI.ts` add `streamViaArchy(history, onToken, onError, signal)` that calls `archyBridge.sendChat` with the latest user turn and emits the returned text through `onToken`. Branch each of the three existing send sites (lines ~564, ~647, ~759) on the same `__AIUI_EMBEDDED__` signal `useArchy.ts` already reads: when embedded, call `streamViaArchy`; otherwise keep `streamClaude`/`streamOpenRouter` exactly as they are. `streamClaude` and `streamOpenRouter` are NOT deleted — D-17 keeps standalone mode working with AIUI's own proxy for development and for anyone running AIUI outside a node. + +Do not remove `CLAUDE_PATH`/`OPENROUTER_PATH`; plan 13-02 changes what those paths resolve to on a node (a session-gated Rust forwarder) and 13-09 retires them, in that order. + +Commit and push on `development` with a message naming the Archy phase, per CLAUDE.md's commit-and-push-every-unit-of-work rule. Stage explicitly by path. + + + cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run + cd /home/archipelago/Projects/AIUI/packages/app && npx vue-tsc --noEmit + cd /home/archipelago/Projects/AIUI && git log --oneline -1 && git status --porcelain | grep -c . | grep -qx 0 + + +- `grep -q "sendChat" /home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts` +- `grep -q "streamViaArchy" /home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts` +- `grep -c "streamClaude" /home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts` is ≥ 1 — standalone mode was not deleted (D-17) +- `cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run` exits 0 — AIUI's own test command is `vitest run` (**confirmed at plan time**, resolving 13-VALIDATION.md's Wave 0 "AIUI test command UNCONFIRMED" item) +- `cd /home/archipelago/Projects/AIUI/packages/app && npx vue-tsc --noEmit` exits 0 +- `cd /home/archipelago/Projects/AIUI && git status --porcelain` is empty and `git log --oneline -1` shows the new commit on `development` + + An embedded AIUI chat send produces a `chat:request` postMessage instead of a direct `api/claude` fetch; a standalone AIUI chat send still uses `streamClaude`; both test suites are green and the AIUI commit is pushed. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| AIUI iframe → neode-ui page | Untrusted-by-design content crosses via postMessage; origin-checked, but same-origin today (no browser-enforced sandbox — see 13-09) | +| neode-ui page → node `/rpc` | Authenticated: session cookie + CSRF + `role.can_access()` (`api/rpc/mod.rs:264-330`) | +| model output → `execute_tool` | The model's output is an **input** to the check, never the check. This is the phase's load-bearing boundary | +| node → api.anthropic.com | The only egress in this plan; carries the system prompt, tool schemas and the turn | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-01 | Elevation of Privilege | `assistant.chat` RPC | high | mitigate | Registered in the normal `dispatcher.rs` table so the existing session + CSRF + RBAC gate runs before dispatch; asserted by `assistant_methods_require_session`. `UNAUTHENTICATED_METHODS` untouched (Phase-10 hard constraint) | +| T-13-02 | Elevation of Privilege | `execute_tool` unknown-tool path | high | mitigate | D-06 curated allowlist; an unregistered name returns an error turn, never a dispatch. Asserted by `unknown_tool_is_refused_not_ignored` | +| T-13-03 | Information Disclosure | Claude API key | critical | mitigate | Key read server-side from `data_dir/secrets/claude-api-key` inside `backends/claude.rs`; never serialized into any RPC response and never present in a browser bundle. Asserted by the no-`ANTHROPIC_API_KEY`-in-`assistant/` grep | +| T-13-04 | Tampering | AIUI forging a `chat:request` from another origin | medium | mitigate | The broker's existing `event.origin !== this.allowedOrigin` guard is reused unchanged; no second postMessage channel is added | +| T-13-05 | Denial of Service | Model loops without terminating | medium | mitigate | `MAX_TURNS = 8` hard stop in `run_loop`; the loop bails with a user-facing error rather than spinning | +| T-13-06 | Spoofing | Model claims a tool ran that did not | medium | accept | Not structurally preventable — no gate constrains prose. Measured behaviourally as E-01's integrity half in 13-14; recorded as prohibition P-1 there | +| T-13-07 | Elevation of Privilege | Two live Claude credential paths (`secrets/claude-api-key` vs the port-3142 proxy's `ANTHROPIC_API_KEY`) | high | mitigate | Out of this plan's scope by sequencing: 13-02 collapses them to one ledger in the same wave. This plan is forbidden from creating a third — asserted by the grep above | +| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan adds **zero** new packages. `schemars` was explicitly rejected because it is absent from 13-RESEARCH.md's Package Legitimacy Audit; JSON Schema is hand-written instead. Asserted by the `schemars` count-0 gate | + + + +- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::` green +- `cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts` green (both pre-existing suites) +- `cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run` green +- On a running node with a valid session cookie and CSRF token, `assistant.chat` with `{"text":"how much space is left"}` returns a body containing the node's real free-space figure — the same number `system.disk-status` returns directly + + + +The spine is proven: typed chat in the embedded AIUI reaches a curated node tool and a real +answer comes back, over authenticated transport, with the model key never leaving the node — +and every later plan in this phase can be built as an expansion of this slice rather than a +parallel mechanism. + + + +Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md` when done + diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-PLAN.md new file mode 100644 index 00000000..6cf298ea --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-PLAN.md @@ -0,0 +1,267 @@ +--- +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" +--- + + +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`. + + + +None in this plan. + + + +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 + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 `` 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 + + +- `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 + + 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 `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 `/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 ` 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`. + + +- `bash tests/production-quality/aiui-proxy-closed.sh ` exits 0 +- `curl -s -o /dev/null -w '%{http_code}' http:///aiui/api/claude/v1/messages` returns 401, 403 or 404 — never 200 +- `curl -s -o /dev/null -w '%{http_code}' http:///aiui/api/openrouter/` returns 404 +- `ssh 'systemctl is-active claude-api-proxy'` reports `inactive` or `unknown`, and `ssh 'ss -ltn | grep -c :3142'` returns 0 +- `ssh '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 + + Type "approved" with the four status codes you observed, or describe what still answered 200. + + + + + +## 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 | + + + +- `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 + + + +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. + + + +Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-SUMMARY.md` when done + diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-03-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-03-PLAN.md new file mode 100644 index 00000000..899848ed --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-03-PLAN.md @@ -0,0 +1,185 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - core/archipelago/examples/routstr_probe.rs + - .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/COVERAGE.md + - .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-ROUTSTR-FINDINGS.md +autonomous: true +requirements: [AIUI-01] + +must_haves: + truths: + - "Routstr's wire contract is recorded from a live observation or its unavailability is recorded — the Routstr client in 13-13 is never written against docs alone (RESEARCH Open Question 3)" + - "COVERAGE.md's three `INTEGRATE — UNCONFIRMED` rows are either confirmed against a live provider or explicitly downgraded with a reason" + - "The probe is an `examples/` binary, not a shipped code path — nothing in this plan changes the archipelago daemon" + artifacts: + - path: "core/archipelago/examples/routstr_probe.rs" + provides: "Live Nostr kind-38421 subscribe + provider capability probe, run by hand" + contains: "38421" + - path: ".planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-ROUTSTR-FINDINGS.md" + provides: "Observed event shape, header spelling, arguments encoding, price/model fields — or a recorded no-provider-found" + key_links: + - from: "core/archipelago/examples/routstr_probe.rs" + to: "core/archipelago/src/nostr_discovery.rs" + via: "reuses the Tor-proxy-aware build_nostr_client pattern rather than constructing a second client" + pattern: "build_nostr_client|Client::new" +--- + + +Answer RESEARCH Open Question 3 before it becomes a rewrite. `13-RESEARCH.md` rates the Routstr +protocol **MEDIUM** confidence — every claim about kind `38421`, the `Authorization: Bearer +cashuA…` vs `X-Cashu:` header spelling, and OpenAI-compat's JSON-string-encoded +`tool_calls[].function.arguments` is cited from `docs.routstr.com` and has never been run +against a live provider. `13-PATTERNS.md` records "no analog — first OpenAI-compatible client +in this codebase." + +Writing `backends/routstr.rs` (13-13) against docs alone is how a young, actively-developed +external project turns into a debugging session inside a security-sensitive agent loop. + +Purpose: a cheap, early, throwaway-safe probe that either confirms the contract or records +honestly that no live provider was reachable — so 13-13 starts from a fact, and COVERAGE.md +stops carrying three unconfirmed rows. + +Output: an `examples/` probe binary, `13-ROUTSTR-FINDINGS.md`, and a rewritten COVERAGE.md. + + + +None in this plan. + + + +Symbols created by **this plan**: +- `core/archipelago/examples/routstr_probe.rs`: `fn main`, `async fn discover_providers`, + `async fn probe_capabilities`, `const ROUTSTR_KIND: u16 = 38421`, `const DEFAULT_RELAYS` +- New file `.planning/phases/13-.../13-ROUTSTR-FINDINGS.md` + +No daemon source file, no `Cargo.toml` dependency, and no RPC method is added by this plan. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.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/COVERAGE.md + + + + + + Task 1: Probe a live Routstr provider over Nostr and HTTP + core/archipelago/examples/routstr_probe.rs + +- `core/archipelago/src/nostr_discovery.rs` — `build_nostr_client` and how this codebase subscribes with a filter through the Tor proxy. **Reuse this shape; do not construct a second, un-Tor-aware nostr-sdk client.** +- `core/archipelago/Cargo.toml` lines 83-90 — `reqwest` 0.11 (`json`,`socks`,`rustls-tls`,`stream`) and `nostr-sdk` 0.44 (`nip04`,`nip44`) are already present. An `examples/` target links the package's dependencies, so **no `Cargo.toml` change is needed and none may be made.** +- `.planning/phases/13-.../13-RESEARCH.md` "Routstr chat-completions call shape" and the "Sources / Secondary (MEDIUM confidence)" block — the exact claims under test. +- `.planning/phases/13-.../COVERAGE.md` — the three rows marked `INTEGRATE — UNCONFIRMED` are this probe's checklist. + + +Create `core/archipelago/examples/routstr_probe.rs` — a standalone throwaway probe, run by hand with `cd core && cargo run --example routstr_probe`. It is an example, not a test and not a daemon path: nothing it does is shipped. + +`discover_providers` subscribes to the relays cited in RESEARCH (`wss://relay.damus.io`, `wss://relay.nostr.band`, `wss://nos.lol`) with a filter on kind `38421`, waits up to 30 seconds, and prints every matching event verbatim: full tag list, full content, pubkey, created_at. Do not parse into a typed struct — the whole point is to see what is actually published rather than what a struct expects. Also run a second subscription with **no** kind filter but a `#d` tag filter on `routstr-provider`, in case the kind number in the docs has drifted; print anything it finds. + +`probe_capabilities` takes the first discovered provider endpoint (or a `--endpoint` argv override so the probe is still useful when discovery finds nothing) and issues three unauthenticated `GET`s — `/v1/models`, `/`, and the provider's advertised info path if one appears in the event — printing status code and body for each. It must NOT send a Cashu token: this probe spends no money. If a `402` or a `401` body describes the expected payment header, print that body verbatim — that response is the single most valuable artifact this probe can capture, because it is the provider naming its own header spelling. + +Print a final summary block answering exactly five questions in plain text: (1) was a live kind-38421 event observed? (2) what are its tag names and content keys? (3) what field carries the model list and what field carries the price? (4) what payment header does the provider name in a 401/402 body? (5) does `/v1/models` respond, and does its shape match OpenAI's? + +Handle "no provider found" as a first-class outcome, not an error: print `NO LIVE PROVIDER OBSERVED` and exit 0. A probe that panics when the ecosystem is quiet teaches nothing. + + + cd core && CARGO_INCREMENTAL=0 cargo build --example routstr_probe 2>&1 | tail -5 + cd core && timeout 180 cargo run --example routstr_probe 2>&1 | tail -40 + cd core && git diff --exit-code -- archipelago/Cargo.toml + + +- `cd core && cargo build --example routstr_probe` exits 0 +- `grep -q "38421" core/archipelago/examples/routstr_probe.rs` +- `grep -c "cashu" core/archipelago/examples/routstr_probe.rs` may be > 0 only in printed/parsing code — `grep -ci 'build_payment_token\|auto_pay_token' core/archipelago/examples/routstr_probe.rs` returns 0 (the probe spends nothing) +- `cd core && git diff --exit-code -- archipelago/Cargo.toml` exits 0 — no dependency was added +- `cargo run --example routstr_probe` exits 0 and its output ends with a summary block that either answers all five questions or states `NO LIVE PROVIDER OBSERVED` + + An `examples/` file is deletable at any time and links no shipped code. + The probe builds, runs to completion, spends nothing, and prints either a live event's real shape or an explicit no-provider-observed result. + + + + Task 2: Record the findings and rewrite the coverage matrix from them + .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-ROUTSTR-FINDINGS.md, .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/COVERAGE.md + +- `.planning/phases/13-.../COVERAGE.md` (full file) — specifically the three `INTEGRATE — UNCONFIRMED` rows and the closing `## Gate` section, which names this task as the gate 13-13 waits on. +- The raw probe output from Task 1. +- `.planning/phases/13-.../13-RESEARCH.md` Assumptions Log entry **A2**, which is the assumption this task retires or upholds. + + +Write `13-ROUTSTR-FINDINGS.md` containing the probe's verbatim output (trimmed to the relevant events and bodies), the date and the relay set used, and a short table with one row per RESEARCH claim under test: the claim as cited, the observed value, and a verdict of `CONFIRMED`, `DIFFERS` (with the real value) or `NOT OBSERVED`. Cover at minimum: event kind number, `d` tag value, the content keys carrying `endpoints`/`models`/`pricing`, the payment header spelling, and whether `tool_calls[].function.arguments` arrives as a JSON-encoded string. + +Then rewrite `COVERAGE.md`'s matrix from those findings, not from the docs: +- Every row that the probe confirmed loses its `— UNCONFIRMED` suffix. +- Every row the probe found to differ is corrected to the observed reality. +- Every row the probe could not observe is downgraded to `OPT-OUT` with the one-line reason `not observable — no live provider reachable on `, or kept as `INTEGRATE` **only** if 13-13's first task is changed to a `checkpoint:decision`. Say which, explicitly, in the `## Gate` section. +- Do not leave a row marked `INTEGRATE` on confidence this plan did not obtain. An opt-out without a reason, or an integrate without evidence, is exactly the un-decided hole the coverage gate exists to close. + +Update RESEARCH assumption **A2**'s risk line in `13-ROUTSTR-FINDINGS.md` (not by editing RESEARCH.md) to state whether A2 held. + +Commit both files with `docs(13): routstr protocol findings + coverage matrix from live probe` and push per CLAUDE.md. + + + + test -f .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-ROUTSTR-FINDINGS.md + grep -c 'UNCONFIRMED' .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/COVERAGE.md | grep -qx 0 + awk -F'|' '/OPT-OUT/ {if (length($4) < 12) {print "MISSING REASON:" $0; exit 1}}' .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/COVERAGE.md + + +- `13-ROUTSTR-FINDINGS.md` exists and contains a verdict table where every row's verdict is one of `CONFIRMED`, `DIFFERS` or `NOT OBSERVED` +- `grep -c 'UNCONFIRMED' COVERAGE.md` returns 0 — every row now carries either evidence or an explicit downgrade +- Every `OPT-OUT` row in COVERAGE.md has a non-empty reason cell (the `awk` gate above exits 0) +- COVERAGE.md's `## Gate` section states in one sentence whether 13-13 may proceed directly or must open with a `checkpoint:decision` +- Both files are committed and pushed + + COVERAGE.md contains zero unconfirmed integrations and zero reasonless opt-outs, and 13-13's entry condition is stated as a fact rather than a hope. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| dev workstation → public Nostr relays | Outbound WebSocket; relay operators see the subscription | +| dev workstation → an unknown third-party Routstr endpoint | Outbound HTTP to an endpoint discovered from an untrusted, self-published Nostr event | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-16 | Spoofing | A hostile actor publishes a fake kind-38421 event advertising a malicious endpoint | medium | mitigate | The probe treats every discovered endpoint as untrusted data: it only issues unauthenticated GETs, sends no token, no key and no node identity, and prints rather than parses. Provider *trust* selection is 13-13's problem, gated by D-05's budget cap | +| T-13-17 | Denial of Service (financial) | Probe accidentally spends ecash | low | mitigate | The probe never calls `auto_pay_token`/`build_payment_token`; asserted by an acceptance grep. No wallet code is linked into the example's call graph | +| T-13-18 | Information Disclosure | Probe leaks node identity to relays or providers | low | mitigate | Run from a dev workstation, not a node; the probe generates an ephemeral key for the subscription and sends no node-identifying header | +| T-13-19 | Tampering | Findings recorded from docs rather than observation, defeating the plan's purpose | medium | mitigate | `13-ROUTSTR-FINDINGS.md` must carry verbatim probe output; the verdict vocabulary forces `NOT OBSERVED` rather than an optimistic `CONFIRMED` | +| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added — asserted by `git diff --exit-code -- archipelago/Cargo.toml`. No install task, so no legitimacy checkpoint is required | + + + +- `cd core && cargo build --example routstr_probe` exits 0 +- `cd core && git diff --exit-code -- archipelago/Cargo.toml` exits 0 +- `grep -c UNCONFIRMED COVERAGE.md` == 0 +- `13-ROUTSTR-FINDINGS.md` exists with a verdict per RESEARCH claim + + + +13-13 can be executed against an observed protocol or an explicitly recorded absence, and +COVERAGE.md is a subtraction record backed by evidence rather than by documentation. + + + +Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-03-SUMMARY.md` when done + diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-PLAN.md new file mode 100644 index 00000000..99ce1698 --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-PLAN.md @@ -0,0 +1,274 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 04 +type: execute +wave: 2 +depends_on: ["13-01"] +files_modified: + - core/archipelago/Cargo.toml + - core/archipelago/src/music/mod.rs + - core/archipelago/src/music/tags.rs + - core/archipelago/src/main.rs + - .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-MUSIC-MODEL.md +autonomous: false +requirements: [AIUI-03] + +must_haves: + truths: + - "The album/artist/track entity model and its on-disk index format are decided by the developer and written down before any node indexes a library (D-13, one-way)" + - "`lofty` is added only after a human has confirmed its registry legitimacy — 13-RESEARCH.md marks it [ASSUMED] because the automated package-legitimacy seam was unavailable" + - "Tag extraction returns a typed record for MP3, FLAC, M4A and OGG, and returns a filename-derived fallback record rather than an error for a file with no readable tags" + - "Nothing in this plan reads or writes outside the node's own media roots" + artifacts: + - path: ".planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-MUSIC-MODEL.md" + provides: "The recorded one-way decision: entity model, index location, index format, reindex path" + - path: "core/archipelago/src/music/tags.rs" + provides: "lofty-based extraction of title/artist/album/albumartist/track/disc/year/duration" + contains: "pub fn extract_tags" + - path: "core/archipelago/src/music/mod.rs" + provides: "Music domain root: Track, Album, Artist entity types as decided" + contains: "pub struct Track" + key_links: + - from: "core/archipelago/src/main.rs" + to: "core/archipelago/src/music/mod.rs" + via: "mod music; declaration — the crate is binary-only, there is no lib.rs" + pattern: "^mod music;" +--- + + +Land the **one-way** half of D-13 deliberately. CONTEXT.md rates the music library's +album/artist/track schema and its on-disk index as **one-way**: "a persisted data model with a +migration cost once nodes have indexed libraries; changing the entity model afterwards needs a +reindex path, not just a code change." So the entity model is decided at a checkpoint by the +developer, written down, and only then implemented. + +This plan also clears the two gates that stand in front of any music code: the recorded decision +(REVERSIBILITY_GATES) and `lofty`'s package legitimacy (13-RESEARCH.md marks it `[ASSUMED]` +because the automated `package-legitimacy check` seam was unavailable in the research session, +and its own fallback rule says an `[ASSUMED]` package's `cargo add` must be gated behind a +`checkpoint:human-verify`). + +**Wave note (D-13 independence).** D-13 requires the music library to land as its own wave +"not blocking the rest" — and it does: **no plan on the control or content track depends on any +plan in the music track.** The edge here points the other way, and it is a file-serialization +fact rather than a logical coupling: `core/archipelago/src/main.rs` is the binary crate's only +module-declaration site, so `mod assistant;` (13-01) and `mod music;` (this plan) cannot be +written in the same wave. Nothing in this plan uses anything 13-01 produces. + +Purpose: get the irreversible decision made while it is still cheap, and get the dependency +audited before it is in the tree. + +Output: `13-MUSIC-MODEL.md`, `lofty` in `Cargo.toml`, and `core/archipelago/src/music/`. + + + +None in this plan. + + + +Symbols created by **this plan**: +- `core/archipelago/src/music/mod.rs`: `pub struct Track`, `pub struct Album`, `pub struct Artist`, + `pub struct TrackId`/`AlbumId`/`ArtistId` (or the identity scheme chosen at Task 1), + `pub enum MusicSource`, `const MUSIC_SCHEMA_VERSION` +- `core/archipelago/src/music/tags.rs`: `pub fn extract_tags`, `pub struct RawTags`, + `fn fallback_from_filename` +- `core/archipelago/src/main.rs`: `mod music;` +- `core/archipelago/Cargo.toml`: `lofty` dependency +- New file `.planning/phases/13-.../13-MUSIC-MODEL.md` + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@CLAUDE.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-RESEARCH.md + + + + + + Task 1: Decide the music entity model — one-way + +The album / artist / track entity model, the identity scheme that survives a file move or a +retag, where the index lives on disk, and what a reindex path looks like when the schema +changes. Also, within CONTEXT.md's "Claude's Discretion": whether the library indexes the +node's own FileBrowser `Music` folder, peer audio, or both. + + +D-13 rates this **one-way**: once nodes have indexed libraries, changing the entity model needs +a reindex path, not just a code change. The three sub-decisions that are genuinely hard to walk +back are (a) what a *track's stable identity* is, (b) whether an album is a first-class stored +entity or derived at read time, and (c) the on-disk index format. + +Grounding for the developer: +- There is **no music library domain in this codebase today** — CONTEXT.md is explicit that the + user chose "build a real library" over the narrower MIME-filtered-files option after being + told this. There is nothing to migrate *from*, which is exactly why now is the cheap moment. +- `content_server.rs::load_catalog` is the in-repo precedent for a `data_dir`-scoped catalog + that is scanned and persisted; `13-PATTERNS.md` assigns it as the analog for `music/index.rs`. +- A relevant landmine: `ShareModal.vue`'s mime map omits `m4a`/`aac`/`opus`/`wma`, so those + files today share as `application/octet-stream`, never reach the audio player, and are + auto-filed to `Documents` instead of `Music`. Whatever "the Music folder" means to the index + must survive that (13-11 fixes the mime map). + + + + + + + + + + +- `.planning/phases/13-.../13-MUSIC-MODEL.md` exists and states, each in one paragraph: the track identity scheme; whether albums and artists are stored or derived; the on-disk index path under `data_dir` and its format; the sources indexed (own `Music` folder, peer audio, or both); and the reindex path when `MUSIC_SCHEMA_VERSION` bumps +- The file names a `MUSIC_SCHEMA_VERSION` starting value and states what a node does on encountering an index written by a *newer* version +- The file explicitly records which option ids above were chosen and one sentence on why the rejected ones were rejected + + Select one identity option, one album option and one index-format option (e.g. "hybrid-identity, derived-albums, index-format-json"), or describe a different model. + + + + Task 2: Verify lofty's registry legitimacy before it enters the tree + +Nothing yet — this gate runs **before** `cargo add`. `13-RESEARCH.md`'s Package Legitimacy +Audit marks `lofty` `[ASSUMED]`: the automated `gsd-tools query package-legitimacy check` seam +was unavailable in the research session, so legitimacy was assessed by manual crates.io +inspection only (808,246 downloads, repo `github.com/Serial-ATA/lofty-rs`, active). The audit's +own fallback rule requires an `[ASSUMED]` package's install to be gated behind a human check. +This is that check. It is not auto-approvable regardless of `workflow.auto_advance`. + + +1. Open `https://crates.io/crates/lofty` and confirm: the crate has a substantial download + history (not a recent spike), a listed repository, and a version history spanning more than + a few weeks. +2. Open the linked repository `https://github.com/Serial-ATA/lofty-rs` and confirm it is a real + project with commit history and issues, and that the repo link on crates.io points at it + (not at an unrelated or newly-created org). +3. Confirm the version being added matches what RESEARCH observed: `0.24.x`. +4. Sanity-check the dependency tree before committing to it: + `cd core && cargo add --dry-run lofty --package archipelago` and read what it would pull in. + A tag-reading crate pulling in a network or process-spawning dependency is a red flag. + + +- The developer states the observed download count, the repo URL and the version +- `cd core && cargo add lofty --package archipelago` has been run and `grep -c '^lofty' core/archipelago/Cargo.toml` returns 1 +- `cd core && CARGO_INCREMENTAL=0 cargo build --package archipelago` exits 0 +- `cd core && cargo tree --package archipelago -i lofty` output is reviewed and contains no networking crate + + Type "approved" with the download count and repo URL you saw, or "rejected" with what looked wrong. + + + + Task 3: Tag extraction across the four formats that actually matter + core/archipelago/src/music/mod.rs, core/archipelago/src/music/tags.rs, core/archipelago/src/main.rs + + - An MP3 with ID3v2.4 tags yields title, artist, album, album artist, track number, disc number, year and duration. + - A FLAC with Vorbis comments yields the same fields. + - An M4A/AAC file yields the same fields (this is the format `ShareModal.vue` currently mis-types — it must not be second-class here). + - An OGG file yields the same fields. + - A file with **no** readable tags yields a record whose title is derived from the filename stem and whose artist/album are `None` — an error is not returned, because an untagged file must still appear in the library. + - A file that is not audio at all (a `.txt` renamed to `.mp3`) returns `Err`, and the caller can distinguish it from the untagged case. + - A path outside the configured media roots is refused before any file is opened. + + +- `.planning/phases/13-.../13-MUSIC-MODEL.md` — Task 1's decision. **The entity types in `mod.rs` are written to match it exactly; do not re-derive a model here.** +- `core/archipelago/src/content_server.rs` — `ContentItem`, `AccessControl` and `load_catalog`. `13-PATTERNS.md` assigns this as the role-match analog for the music domain's load/scan/persist shape. Read `load_catalog` in full. +- `core/archipelago/src/content_hash.rs` — the in-tree hashing primitive, if Task 1 chose a content-hash or hybrid identity. +- `core/archipelago/src/swarm/payment.rs` — the `#[cfg(test)]`/`#[tokio::test]` convention and `tempfile` usage for fixture directories. +- `lofty` docs for the 0.24 API surface: prefer `lofty::read_from_path` plus the `TaggedFileExt`/`Accessor`/`AudioFile` traits over per-format parsers. + + +Create `core/archipelago/src/music/mod.rs` and `core/archipelago/src/music/tags.rs`, and add `mod music;` to `core/archipelago/src/main.rs` in the existing alphabetical block (between `mod monitoring;` and `mod names;`). This crate is binary-only — there is no `lib.rs` — so all tests are in-crate `#[cfg(test)] mod tests`. + +`mod.rs` declares the entity types exactly as decided in `13-MUSIC-MODEL.md`: `Track`, `Album`, `Artist` (stored or derived per the decision), the identity newtypes, `pub enum MusicSource { OwnLibrary, Peer { onion: String } }` restricted to whatever Task 1 chose to index, and `pub const MUSIC_SCHEMA_VERSION: u32` at the decided starting value. Every struct derives `Serialize`/`Deserialize` — the index is persisted, so these types are the migration surface and must be written once, carefully. + +`tags.rs` exposes `pub fn extract_tags(path: &Path, media_roots: &[PathBuf]) -> Result`. It first canonicalizes `path` and refuses with a distinct error if the result is not under one of `media_roots` — an indexer that can be pointed at `data_dir/secrets` is a secret-exfiltration primitive, and this check runs before the file is opened, not after. It then uses `lofty::read_from_path` and the `Accessor` trait to pull title, artist, album, album artist, track, disc, year, and `AudioFile::properties().duration()`. `RawTags` carries `Option`/`Option` fields plus a `has_tags: bool`. On a readable audio file with no tag block, populate `title` from the file stem via `fallback_from_filename` and set `has_tags: false`. On a file `lofty` cannot identify as audio, return `Err` with a variant the caller can distinguish from the untagged case. + +Write the tests FIRST, one per bullet in ``. Generate the fixture files programmatically into a `tempfile::tempdir()` using `lofty`'s own writing API where it supports the format, rather than committing binary fixtures — a repo full of committed sample audio is a licensing problem and a review burden. For the not-audio case, write a text file with an `.mp3` extension. For the path-traversal case, point at a temp path outside the roots. Name them `music::tags::tests::mp3_id3v24_yields_full_record`, `..::flac_vorbis_yields_full_record`, `..::m4a_yields_full_record`, `..::ogg_yields_full_record`, `..::untagged_file_falls_back_to_filename_stem`, `..::non_audio_returns_err_distinct_from_untagged`, `..::path_outside_media_roots_is_refused`. + + + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago music:: 2>&1 | tail -20 + grep -q '^mod music;' core/archipelago/src/main.rs + + +- `grep -q '^mod music;' core/archipelago/src/main.rs` +- `grep -q 'pub struct Track' core/archipelago/src/music/mod.rs` and `grep -q 'MUSIC_SCHEMA_VERSION' core/archipelago/src/music/mod.rs` +- `grep -q 'pub fn extract_tags' core/archipelago/src/music/tags.rs` +- `cd core && cargo test --package archipelago music::` exits 0 with all seven named tests passing +- `grep -q 'media_roots' core/archipelago/src/music/tags.rs` — the root confinement is a parameter, not a constant a caller can bypass +- `git ls-files core/archipelago | grep -ciE '\.(mp3|flac|m4a|ogg)$'` returns 0 — no binary audio fixtures were committed +- The entity fields in `mod.rs` match `13-MUSIC-MODEL.md`'s decision (spot-check each name) + + The entity model and index format become a persisted data model once nodes index libraries; changing them afterwards needs a reindex path, not just a code change. Gated by Task 1's `checkpoint:decision`, per CONTEXT.md D-13's own rating. + Four real audio formats round-trip into a typed record, an untagged file still becomes a library entry, a non-audio file is a distinguishable error, and a path outside the media roots never gets opened. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| filesystem → indexer | Media files are attacker-influenceable (a peer chooses the filename and the tag contents of anything shared) | +| tag text → downstream context | Tag strings are peer-supplied text and will eventually reach the model context and the UI — D-10 territory | +| crates.io → the tree | A new third-party parser handling untrusted binary input | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-20 | Information Disclosure | Indexer pointed at `data_dir/secrets` or another sensitive path | high | mitigate | `extract_tags` canonicalizes and confines to `media_roots` **before opening the file**; asserted by `path_outside_media_roots_is_refused`. The roots are a parameter, not a constant | +| T-13-21 | Denial of Service | Malformed/hostile audio file crashes or hangs the parser | medium | mitigate | `lofty` errors are returned as `Err`, never `unwrap`ped; a non-audio file is a normal error path, asserted by `non_audio_returns_err_distinct_from_untagged`. No panic path is introduced | +| T-13-22 | Tampering | Peer-authored tag text treated as trusted once it is "structured data" | high | mitigate | Deferred by design to 13-12's `wrap_untrusted` boundary: `RawTags` fields are plain `Option` carrying no trust, and nothing in this plan puts them in a model context. Recorded here so the assumption is explicit rather than implied | +| T-13-23 | Elevation of Privilege | Music entity model later needs a field that only exists on a stored album, forcing an on-disk migration | medium | mitigate | This is the one-way cost D-13 names. Mitigated by making it a `checkpoint:decision` and by `MUSIC_SCHEMA_VERSION` + a written reindex path, not by trying to guess right | +| T-13-SC | Tampering | npm/pip/cargo installs | **high** | mitigate | `lofty` is `[ASSUMED]` in 13-RESEARCH.md's Package Legitimacy Audit. Task 2 is a `checkpoint:human-verify` with `gate="blocking-human"` **before** `cargo add`, per the audit's own fallback rule. Not auto-approvable. `cargo tree -i lofty` is reviewed for unexpected transitive networking deps | + + + +- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago music::` green (7 tests) +- `cd core && CARGO_INCREMENTAL=0 cargo build --package archipelago` exits 0 +- `13-MUSIC-MODEL.md` exists and its decided field names match `music/mod.rs` +- `grep -c '^lofty' core/archipelago/Cargo.toml` == 1 + + + +The irreversible half of D-13 is a written, developer-made decision rather than an emergent +property of the first implementation; `lofty` entered the tree through a human legitimacy gate; +and tag extraction handles the four formats a real library contains, including the M4A/AAC +family the current share path mis-handles. + + + +Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-SUMMARY.md` when done + diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-PLAN.md new file mode 100644 index 00000000..eb2ec6ee --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-PLAN.md @@ -0,0 +1,303 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 05 +type: execute +wave: 2 +depends_on: ["13-01"] +files_modified: + - core/archipelago/src/assistant/tools.rs + - core/archipelago/src/assistant/grants.rs + - core/archipelago/src/assistant/mod.rs + - core/archipelago/src/api/rpc/assistant_chat.rs +autonomous: true +requirements: [AIUI-01, AIUI-02] + +must_haves: + truths: + - "An operator can change system settings by conversation, and only within the permission categories they granted (AIUI-02, D-09, D-16)" + - "No ToolDef exists anywhere in the registry whose effect touches keys, seeds, wallet spends, federation trust or factory reset — the D-09 ceiling is the absence of a tool, not a runtime filter (S-04)" + - "A fresh node grants nothing: all ten permission categories are closed until the operator opens them (D-16, S-06)" + - "An ungranted category is refused at execute_tool even when the tool was somehow proposed — the system prompt omitting it is defense in depth, not the gate (S-05)" + - "A read tool never raises a confirmation dialog (S-07)" + - "The model never sees a tool it cannot use: the system prompt lists only currently-granted-category tools (D-16)" + artifacts: + - path: "core/archipelago/src/assistant/tools.rs" + provides: "The full D-06 curated allowlist with per-tool JSON Schema, category and destructive flag" + contains: "fn registry()" + - path: "core/archipelago/src/assistant/grants.rs" + provides: "D-16 default-closed category grants, persisted under data_dir" + contains: "default_closed" + key_links: + - from: "core/archipelago/src/assistant/tools.rs" + to: "core/archipelago/src/api/rpc/dispatcher.rs" + via: "each ToolDef's execute dispatches to an existing authenticated RPC handler — never a parallel AI-only path" + pattern: "handle_(container|system|bitcoin|network|content)_" + - from: "core/archipelago/src/assistant/grants.rs" + to: "core/archipelago/src/assistant/mod.rs" + via: "CallerScope::granted_categories reads the persisted grants store instead of 13-01's hardcoded default" + pattern: "granted_categories" +--- + + +Expand the tracer's one-tool registry into the full curated allowlist, and make D-09's authority +ceiling and D-16's default-closed grants real and asserted. + +D-06 is explicit: tools are **hand-written**, never auto-generated from `dispatcher.rs`. That is +the only way "the model never sees the full RPC surface" stays true rather than becoming an +implementation detail nobody re-checks. D-09's ceiling — reads within granted categories, app +lifecycle (start/stop/restart), settings writes; keys, seeds, wallet spends, federation trust +and factory reset permanently excluded — is enforced by **not writing those ToolDefs**, and by a +test that asserts over the whole registry so adding an out-of-bounds tool later fails CI rather +than review. + +This plan also delivers AIUI-02. A finding worth stating plainly: `system.settings.set` today +accepts exactly one key, `claude_api_key` (verified, `api/rpc/system/handlers.rs:1026-1071`) — +and that key is *excluded* from chat reach by D-09. So conversational settings are built from a +hand-picked allowlist of setting keys drawn from the surfaces that actually exist +(`network.set-visibility`, `system.kiosk-display.set`, `network.set-wifi-radio`, +`bitcoin.relay-update-settings`), with `claude_api_key` explicitly and permanently absent. + +Purpose: make the sandbox claim checkable. After this plan, "what can the chat reach" is a +grep over one file and a passing test, not an argument. + +Output: the curated registry, the grants store, and `assistant.list-tools` / `assistant.grants-get` / `assistant.grants-set`. + + + +**FLAGGED — unresolved edge probe, AIUI-02, category `unclassified`.** The deterministic edge +probe returned `unclassified — review manually` for AIUI-02 and it is NOT auto-resolved and NOT +auto-backstopped. Surfaced here for a human read during execution: the requirement text +("system settings reachable by conversation, scoped to what the user granted") does not say what +happens when the operator asks to change a setting that *exists in neode-ui* but is deliberately +absent from the tool allowlist — refuse plainly, refuse and name the UI path, or silently omit. +Task 1 chooses "refuse plainly **and** name the real UI path", which is the E-03 rubric's PASS +behaviour, but the requirement itself does not mandate it. Raise it if that reading is wrong. + + + +Symbols created by **this plan**: +- `core/archipelago/src/assistant/tools.rs`: `fn registry()` expanded; per-tool constructors + `apps_list_tool`, `app_logs_tool`, `app_start_tool`, `app_stop_tool`, `app_restart_tool`, + `bitcoin_status_tool`, `network_status_tool`, `mesh_status_tool`, `content_list_tool`, + `settings_get_tool`, `settings_set_tool`; their args structs `AppIdArgs`, `AppLogsArgs`, + `SettingsGetArgs`, `SettingsSetArgs`; `const SETTABLE_KEYS`, `const EXCLUDED_AUTHORITY_TERMS` +- `core/archipelago/src/assistant/grants.rs`: `pub struct Grants`, `pub fn default_closed`, + `Grants::load`, `Grants::save`, `Grants::allows`, `Grants::set` +- `core/archipelago/src/api/rpc/assistant_chat.rs`: `handle_assistant_list_tools`, + `handle_assistant_grants_get`, `handle_assistant_grants_set` +- New RPC method names: `assistant.list-tools`, `assistant.grants-get`, `assistant.grants-set` + (all routed through 13-01's single `assistant.` dispatcher arm — `dispatcher.rs` is not + touched again) + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@CLAUDE.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-AI-SPEC.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md + + + + + + Task 1: The curated allowlist — every tool a decision someone made + core/archipelago/src/assistant/tools.rs + + - `registry()` returns exactly the hand-written tools listed in the action below, and no others. + - Each tool's `parameters` is a JSON Schema object whose `required` keys all deserialize into its args struct — schema and deserialization target cannot drift. + - `settings_set` refuses any key not in `SETTABLE_KEYS`, with an error naming which keys are settable. + - `settings_set` refuses `claude_api_key` specifically, and the refusal names the neode-ui Settings path as the real way to do it. + - `app_restart` refuses an `app_id` that is not an exact installed app id — no fuzzy match, no nearest-neighbour. + - Every tool whose effect changes node state has `destructive: true`; every read tool has `destructive: false`. + + +- `core/archipelago/src/assistant/tools.rs` — the tracer's `ToolDef`, `ToolRegistry`, `ToolDef::validate` and the single `system_disk_status` tool. **Extend this file's existing conventions; do not restructure them.** +- `core/archipelago/src/api/rpc/dispatcher.rs` lines 42-50, 107-119, 205-241, 394, 465-477 — the verified handler names each tool dispatches to: `container-list`, `container-start`, `container-stop`, `container-restart`, `container-logs`, `bitcoin.getinfo`, `network.get-visibility`, `network.diagnostics`, `mesh.status`, `content.list-mine`, `system.disk-status`, `system.stats`, `system.settings.get`, `system.settings.set`, `system.kiosk-display.get`, `system.kiosk-display.set`, `network.set-visibility`, `network.set-wifi-radio`, `bitcoin.relay-update-settings`. +- `core/archipelago/src/api/rpc/system/handlers.rs` lines 994-1072 — `handle_system_settings_get`/`_set`. **Confirm for yourself that `_set`'s `match key` accepts only `claude_api_key` today**; that fact drives the `SETTABLE_KEYS` design below. +- `.planning/phases/13-.../13-CONTEXT.md` D-06, D-07, D-09, D-16. +- `.planning/phases/13-.../13-AI-SPEC.md` §4 "Tool Use" and §4b.1 (validate-then-refuse, never coerce, ≤ 2 consecutive validation failures per tool name). + + +Expand `registry()` to the curated allowlist. Every entry is hand-written with its own description, its own JSON Schema literal built with `serde_json::json!`, its own `PermissionCategory`, and its own `destructive` flag. **Do not derive anything from `dispatcher.rs`'s method table** — D-06 rejects that outright, and it is the single change that would make the sandbox claim untrue. + +Read tools (`destructive: false`): +`system_disk_status` (System, already exists), `system_stats` (System), `apps_list` (Apps → `container-list`), `app_logs` (Apps → `container-logs`, args `app_id` + `lines` capped at 200), `bitcoin_status` (Bitcoin → `bitcoin.getinfo`), `network_status` (Network → `network.get-visibility` + `network.diagnostics`), `mesh_status` (Network → `mesh.status`), `content_list` (Media → `content.list-mine`), `settings_get` (System → `system.settings.get`, `network.get-visibility`, `system.kiosk-display.get` behind a hand-picked key allowlist). + +Write tools (`destructive: true`): +`app_start`, `app_stop`, `app_restart` (Apps → `container-start`/`-stop`/`-restart`) and +`settings_set` (System → the setting-specific handler for the requested key). + +D-09's ceiling is enforced by **absence**: there is no `wallet_send`, no `seed_reveal`, no +`federation_trust`, no `factory_reset`, no `system_reboot`, no `container_install`, no +`container_remove` ToolDef, and none may be added. Record the excluded set as +`const EXCLUDED_AUTHORITY_TERMS: &[&str]` so Task 3's registry-wide assertion has something +concrete to assert over. + +`settings_set` is the AIUI-02 surface and needs care. Define `const SETTABLE_KEYS: &[&str]` +containing only setting keys that (a) have a real handler today and (b) are not key material: +network visibility, kiosk display preset, wifi radio on/off, and the bitcoin relay settings. +`claude_api_key` is **excluded** — it is key material, D-09 puts keys permanently outside chat +reach, and the fact that it is the *only* key `system.settings.set` accepts today is not a +reason to include it. On a request for an unlisted key, return an `is_error: true` ToolResult +whose text names the settable keys and points at the neode-ui Settings screen as the real path +(this is the E-03 PASS behaviour: refuse plainly, do not fabricate, redirect to the real UI). + +`app_start`/`app_stop`/`app_restart` take an exact installed `app_id`. Their descriptions must +state that the id is exact and never fuzzy-matched, and include one inline example call (AI-SPEC +§4b.3: few-shot inline, not retrieved). Validation resolves the id against `container-list` and +refuses an unknown id with an error listing installed ids — EV-08's "restart the node" case must +ask which app rather than guessing. + +Per AI-SPEC §4b.1: `validate` deserializes and refuses; never coerce, never guess, never panic. +Add the ≤ 2-consecutive-validation-failures-per-tool-name counter to the ToolExecCtx so a model +looping on malformed args aborts the turn with an apology rather than spinning. + +Write the tests FIRST, one per `` bullet, using the tracer's `ScriptedBackend`. + + + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::tools:: 2>&1 | tail -20 + + +- `cd core && cargo test --package archipelago assistant::tools::` exits 0 +- `grep -q 'SETTABLE_KEYS' core/archipelago/src/assistant/tools.rs` and `grep -q 'EXCLUDED_AUTHORITY_TERMS' core/archipelago/src/assistant/tools.rs` +- `grep -vE '^\s*//' core/archipelago/src/assistant/tools.rs | grep -ciE 'wallet_send|seed_reveal|factory_reset|system_reboot|container_install|container_remove'` returns 0 — the excluded authority has no ToolDef in non-comment source +- `grep -vE '^\s*//' core/archipelago/src/assistant/tools.rs | grep -c '"claude_api_key"'` returns 0 outside the `SETTABLE_KEYS` refusal message path — verify by reading, then assert `grep -c 'SETTABLE_KEYS' core/archipelago/src/assistant/tools.rs` ≥ 1 and that `claude_api_key` is not one of its elements +- `grep -c 'destructive: true' core/archipelago/src/assistant/tools.rs` returns 4 — `app_start`, `app_stop`, `app_restart`, `settings_set` and nothing else +- Every `ToolDef` literal in the file has an explicit `category:` and `destructive:` field (no `..Default::default()`) +- `grep -ci 'dispatcher' core/archipelago/src/assistant/tools.rs` returns 0 — nothing is generated from the method table + + D-09's first-cut authority is rated costly in CONTEXT.md: widening later is safe, but any capability shipped and then withdrawn breaks a behaviour users will have learned. Flagged, not gated — the ceiling here is deliberately conservative. + The registry is a readable list of hand-written decisions; a settings key outside the allowlist and an app id that does not exist are both refused with a message that names the real path. + + + + Task 2: Default-closed grants, and a system prompt that only shows what is granted + core/archipelago/src/assistant/grants.rs, core/archipelago/src/assistant/mod.rs, core/archipelago/src/api/rpc/assistant_chat.rs + + - A fresh node with no grants file returns an empty granted set for every caller variant. + - `assistant.grants-set` opens a named category; `assistant.grants-get` reflects it; the change survives a daemon restart. + - The system prompt built for a caller lists only tools whose category is currently granted — an ungranted tool's name does not appear in the prompt string at all. + - `assistant.list-tools` returns only granted-category tools, with each tool's category and destructive flag, so neode-ui can render an honest capability list. + - `execute_tool` still refuses an ungranted category even when the tool was proposed anyway — the prompt filter is defense in depth, not the gate. + - Revoking a category takes effect on the next turn, not only on the next session. + + +- `core/archipelago/src/assistant/mod.rs` — the tracer's `CallerScope::granted_categories`, which currently returns a hardcoded `{System}` for `LocalOperator`. **This task replaces the source of that set, not its shape** — the `` promote in 13-01 is what makes that a data change rather than an architectural one. +- `neode-ui/src/stores/aiPermissions.ts` — the ten user-toggled categories and their labels, already shipped in the browser. The node-side names must match these exactly or the two consent surfaces will disagree. +- `core/archipelago/src/streaming/session.rs` — `13-PATTERNS.md`'s role-match analog for `data_dir`-scoped persisted state. Follow its load/save/permissions convention. +- `core/archipelago/src/api/rpc/mesh/assistant.rs` — the handler shape for the three new `assistant.*` methods, and `trusted_only`/`allowed_contacts`/`denied_askers`, which stay the resolution inputs for the `Mesh` variant. +- `.planning/phases/13-.../13-AI-SPEC.md` §4b.3 "Prompt Engineering Discipline" — one static, phase-authored system prompt, never assembled from prior model output, listing only granted-category tools and stating the confirm-gate contract explicitly. + + +Create `core/archipelago/src/assistant/grants.rs` with `pub struct Grants(BTreeSet)`, `pub fn default_closed() -> Grants` returning an empty set, and `load`/`save` against a JSON file under `data_dir` (0600, following `streaming/session.rs`'s convention). A missing file is `default_closed()`, never an error and never a permissive default — D-16 accepts that the assistant looks unconfigured on a fresh node. + +Rewire `CallerScope::granted_categories` in `mod.rs`: `LocalOperator` reads the persisted `Grants`; `Mesh` resolves from the existing `trusted_only`/`allowed_contacts`/`denied_askers` inputs intersected with the persisted `Grants`, so a mesh peer can never exceed what the operator opened. Both variants resolve through the same method — that is the promoted-primary contract from 13-01, and the suggested invariant test +`every_caller_variant_resolves_authority_through_caller_scope` belongs here now that there are two real sources. + +Add the system-prompt builder to `mod.rs`: one static, phase-authored string that states the operator-control persona, appends **only** the granted-category tools' names and descriptions, and states the confirm-gate contract verbatim — that every write requires a human confirmation the model cannot bypass or pre-approve on the user's behalf. It is never assembled from prior model output and never editable by AIUI. + +Add `handle_assistant_list_tools`, `handle_assistant_grants_get` and `handle_assistant_grants_set` to `assistant_chat.rs`, routed through 13-01's existing `assistant.` prefix arm. **Do not touch `dispatcher.rs`** — that is the whole point of the prefix arm, and it keeps this plan's `files_modified` free of a file three other plans also want. + +Write the tests FIRST, one per `` bullet. Name them +`assistant::tests::fresh_node_grants_are_empty` (S-06), +`assistant::tools::tests::settings_tool_respects_category_grant` (S-05), +`assistant::tests::ungranted_tool_absent_from_system_prompt`, +`assistant::tests::grant_revocation_takes_effect_next_turn`, +`assistant::tests::every_caller_variant_resolves_authority_through_caller_scope`. + + + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -25 + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago fresh_node_grants_are_empty + cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs + + +- `grep -q 'pub fn default_closed' core/archipelago/src/assistant/grants.rs` and the function body returns an empty set +- `cd core && cargo test --package archipelago assistant::` exits 0 with `fresh_node_grants_are_empty`, `settings_tool_respects_category_grant`, `ungranted_tool_absent_from_system_prompt`, `grant_revocation_takes_effect_next_turn` and `every_caller_variant_resolves_authority_through_caller_scope` all passing +- `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 — the prefix arm absorbed all three new methods +- The ten `PermissionCategory` variant names in `mod.rs` match the ten category ids in `neode-ui/src/stores/aiPermissions.ts` one-for-one (diff the two lists by hand and record the result in the summary) +- `grep -c '0o600\|from_mode' core/archipelago/src/assistant/grants.rs` ≥ 1 — the grants file is not world-readable + + A fresh node's assistant can do nothing until a category is opened; opening one survives a restart; and an ungranted tool is invisible to the model *and* refused at the gate. + + + + Task 3: Assert the ceiling over the whole registry, so a future tool fails CI not review + core/archipelago/src/assistant/tools.rs + +- `core/archipelago/src/assistant/tools.rs` — the registry and `EXCLUDED_AUTHORITY_TERMS` from Task 1. +- `.planning/phases/13-.../13-AI-SPEC.md` §5 structural invariants **S-04** and **S-07**, and §1b's "Regulatory / Compliance Context" — D-09's exclusion of wallet spends/keys/seeds is what keeps the software inside the MiCA/GENIUS non-custodial carve-out, so this is a regulatory-adjacent invariant, not only a security one. +- `.planning/phases/13-.../13-AI-SPEC.md` §6 guardrail **G-S5** — "the D-09 ceiling is the absence of tools". + + +Add the registry-wide structural assertions to `tools.rs`'s test module. These iterate the **whole registry** rather than checking named tools, so a tool added in a later phase that crosses the ceiling fails CI rather than depending on a reviewer noticing. + +`registry_never_exposes_excluded_authority` (S-04): for every `ToolDef` in `registry()`, assert that neither its `name` nor its `description` contains any term in `EXCLUDED_AUTHORITY_TERMS` (seed, mnemonic, private key, macaroon, spend, send sats, pay invoice, federation trust, factory reset, wipe), and that no tool's category is one this phase does not use for writes. Include a comment naming §1b's regulatory rationale so a future maintainer relaxing this assertion knows what they are relaxing. + +`read_tools_never_confirm` (S-07): for every `ToolDef` with `destructive: false`, run a scripted turn that calls it and assert **zero** confirmation requests were raised. Habituation is a real failure mode here — every unnecessary dialog spends the confirm gate's signal value (AI-SPEC §1b, Bravo-Lillo et al.), so this is a consent property, not a tidiness one. + +`loop_is_bounded` (S-13): assert `MAX_TURNS` is enforced and that a model emitting malformed args for the same tool three times in a row aborts the turn rather than continuing. + +`every_tool_has_explicit_category_and_destructive`: assert by construction that no `ToolDef` in the registry was built with a defaulted field — a tool that silently defaults to `destructive: false` is the exact bug this whole gate exists to prevent. + + + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::tools::tests:: 2>&1 | tail -20 + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago registry_never_exposes_excluded_authority + + +- `cd core && cargo test --package archipelago assistant::tools::tests::` exits 0 with `registry_never_exposes_excluded_authority`, `read_tools_never_confirm`, `loop_is_bounded` and `every_tool_has_explicit_category_and_destructive` all passing +- The S-04 test iterates `registry()` rather than a hardcoded list of tool names — confirm by reading; a test that names tools individually does not catch a tool added later +- Temporarily adding a `ToolDef` named `wallet_send_sats` to `registry()` makes `registry_never_exposes_excluded_authority` fail; remove it afterwards and record the observed failure message in the summary + + The D-09 ceiling is a passing test over the whole registry, and it demonstrably goes red when a tool crosses it. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| model output → `execute_tool` | Tool name and arguments are model-chosen; both are validated before anything runs | +| operator grants → tool authority | The only source of authority. Peer-supplied content is not a source (D-10, enforced in 13-12) | +| tool → existing RPC handler | Tools call the same handlers every other authenticated caller uses; there is no AI-only backdoor | +| `settings_set` → node configuration | The one write surface AIUI-02 opens; bounded by `SETTABLE_KEYS` | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-24 | Elevation of Privilege | A tool for excluded authority (seed, spend, federation trust, factory reset) | **critical** | mitigate | G-S5: no such `ToolDef` exists. Asserted registry-wide by `registry_never_exposes_excluded_authority`, and demonstrated to go red by the negative-case criterion | +| T-13-25 | Elevation of Privilege | `settings_set` reaching `claude_api_key` | high | mitigate | `SETTABLE_KEYS` excludes it; the refusal names the neode-ui Settings path. Key material is UI-only per D-09 | +| T-13-26 | Elevation of Privilege | Ungranted category reached because the prompt filter was the only gate | high | mitigate | G-S6: two independent layers — prompt filtering **and** the `execute_tool` grant check. Asserted by `settings_tool_respects_category_grant` with a tool the prompt omitted | +| T-13-27 | Tampering | Fuzzy-matched `app_id` restarts the wrong container | medium | mitigate | Exact-id validation against `container-list`; an unknown id lists the installed ids instead of guessing (EV-08) | +| T-13-28 | Denial of Service | Model loops on malformed arguments | medium | mitigate | ≤ 2 consecutive validation failures per tool name, then abort the turn; plus `MAX_TURNS`. Asserted by `loop_is_bounded` | +| T-13-29 | Information Disclosure | A permissive grants default on a fresh node | high | mitigate | `default_closed()` returns empty; a missing file is not an error and not permissive. Asserted by `fresh_node_grants_are_empty` | +| T-13-30 | Spoofing | Node-side and browser-side category vocabularies drift, so consent shown ≠ consent enforced | medium | mitigate | Acceptance criterion diffs the ten `PermissionCategory` variants against `neode-ui/src/stores/aiPermissions.ts` | +| T-13-31 | Repudiation | A confirmation raised for a read action trains click-through | medium | mitigate | S-07 `read_tools_never_confirm` over every non-destructive tool. Habituation research (AI-SPEC §1b) treats this as a consent failure, not a UX nit | +| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added; JSON Schema stays hand-written (`schemars` remains rejected as un-audited). No install task, so no legitimacy checkpoint required | + + + +- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::` green +- `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 +- The negative case is demonstrated: adding `wallet_send_sats` to the registry turns S-04 red +- The ten node-side categories match the ten browser-side categories exactly + + + +"What can the chat reach" is answerable by reading one file, and "what it can never reach" is a +test that iterates the whole registry and goes red when crossed. Conversational settings work +within a hand-picked key allowlist that deliberately excludes key material. + + + +Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-SUMMARY.md` when done + diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-PLAN.md new file mode 100644 index 00000000..b22c9827 --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-PLAN.md @@ -0,0 +1,285 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 06 +type: execute +wave: 2 +depends_on: ["13-01"] +files_modified: + - neode-ui/src/composables/archyContentAdapter.ts + - neode-ui/src/composables/__tests__/archyContentAdapter.test.ts + - neode-ui/src/services/contextBroker.ts + - neode-ui/src/types/aiui-protocol.ts + - /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts + - /home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts +autonomous: true +requirements: [AIUI-03] + +must_haves: + truths: + - "AIUI's content grids show the node's real peer files, movies and owned/paid content instead of records regex-scraped out of the model's own prose (D-12)" + - "AIUI's FilmGrid/SongGrid/NewsGrid components take zero code changes — only the data source behind their existing props changes (D-12)" + - "IndeeHub and peer video reach the grids through the content + paid-unlock subsystem that already exists — invoices, X-Payment-Token, Range streaming — with no new payment rail (D-14)" + - "Two content items with identical filename and size from different peers render as two distinct cards keyed by id, never merged; an item present both in this node's own library and in a peer share appears once per source (edge: AIUI-03 adjacency)" + - "An empty content list renders the grid's empty state, not a spinner and not an error; a single item renders a one-card grid; an item with a null or absent description maps to an empty string, never the literal 'null' or 'undefined' (edge: AIUI-03 empty)" + - "Content ordering is added_at descending with id ascending as the deterministic tiebreak, so items with equal timestamps come back in the same order on every call (edge: AIUI-03 ordering)" + - "A content refresh arriving while an earlier one is still in flight is discarded by a request-id guard, so the grids never flip back to older data (edge: AIUI-03 concurrency)" + - "No new streaming URL in this phase carries a credential in its query string — the filebrowser-client.ts streamUrl JWT-in-URL leak is not propagated" + artifacts: + - path: "neode-ui/src/composables/archyContentAdapter.ts" + provides: "ContentItem -> Film/Song/Podcast mapping; there is no shape overlap, so this is hand-written mapping logic" + contains: "export function adaptContentItems" + - path: "neode-ui/src/composables/__tests__/archyContentAdapter.test.ts" + provides: "Fixture-pinned mapping including the adjacency, empty, ordering and concurrency edges" + min_lines: 80 + key_links: + - from: "neode-ui/src/services/contextBroker.ts" + to: "neode-ui/src/composables/archyContentAdapter.ts" + via: "content:push handler adapts content.* RPC records before they cross the iframe boundary" + pattern: "adaptContentItems" + - from: "/home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts" + to: "/home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts" + via: "setArchyContent() writes panelFilms/panelSongs/panelPodcasts directly, bypassing updatePanelFromText's regex path" + pattern: "setArchyContent" +--- + + +Make AIUI's content surfaces real. Today they are fed by regex-parsing the model's own reply +text (`updatePanelFromText` → `contentExtraction.ts`) against fixture catalogs that are +themselves injected into the system prompt — the largest data bucket in AIUI is +LLM-synthesized, not an API awaiting a base URL. D-12 keeps the design exactly and changes what +fills it. + +The hard part is named in RESEARCH Pitfall 4: `content_server.rs::ContentItem` (`id`, +`filename`, `mime_type`, `size_bytes`, `description`, `access`, `availability`, `added_at`) has +**no shape overlap** with AIUI's `Film`/`Song`/`Podcast` (`posterUrl`, `coverUrl`, `sources[]` +with `type: 'plex'|'nextcloud'|…`, `genres`, `runtime`, `director`). This is a hand-written +adapter with fixture-pinned tests, not a pass-through. + +Two things this plan deliberately does not do. It does not revive `ContentPanel.vue` — that is +verified dead code taking `ArchyAppsGrid`, `FavoritesGrid`, `DiscoverPanel`, `RecipeDetail` and +`AppDetail` with it, and CONTEXT.md defers it explicitly. The live render tree is +`ChatPage.vue` → `ContentGridView.vue` → the `*Grid` components, and that is what gets fed. And +it does not attempt to fix AIUI's six dev-only Vite plugins: all of them are +`configureServer`/`configurePreviewServer` only and are therefore absent from the static `dist/` +a node serves, so TMDB posters, web search and RSS stay 404 on a node. Only the slice D-12 +replaces gets a production answer; the rest stays explicitly deferred, and the plan says so +rather than implying otherwise. + +Output: `archyContentAdapter.ts`, a `content:push` channel on the existing broker, and +`setArchyContent` in AIUI. + + + +None in this plan. (AIUI-03's four edge probes are all resolved as `covered` truths above.) + + + +Symbols created by **this plan**: + +**neode-ui** +- `composables/archyContentAdapter.ts`: `export function adaptContentItems`, `adaptToFilm`, + `adaptToSong`, `adaptToPodcast`, `classifyByMime`, `sortDeterministic`, + `export type ArchyContentBundle`, `export interface ArchyContentItem` +- `services/contextBroker.ts`: `handleContentRequest` (private), `pushContent` (private), + `contentRequestSeq` (private field — the concurrency guard) +- `types/aiui-protocol.ts`: `AIUIContentRequest`, `ArchyContentPush` + +**AIUI (`/home/archipelago/Projects/AIUI`, branch `development`)** +- `composables/useArchy.ts`: `requestArchyContent` +- `composables/useContentPanel.ts`: `setArchyContent`, `archyContentActive` (ref) + +Unchanged by design and therefore **not** new symbols: `FilmGrid.vue`, `SongGrid.vue`, +`NewsGrid.vue`, `ContentGridView.vue`, and every `Film`/`Song`/`Podcast` type in +`packages/core/src/types/content.ts`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@CLAUDE.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-PATTERNS.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md + + + + + + Task 1: The adapter — hand-written mapping, fixture-pinned, edges decided + neode-ui/src/composables/archyContentAdapter.ts, neode-ui/src/composables/__tests__/archyContentAdapter.test.ts + + - A video-mime `ContentItem` becomes a `Film` with `id` carried through, `title` derived from `filename` minus its extension, and exactly one entry in `sources[]` describing where it came from. + - An audio-mime `ContentItem` becomes a `Song`; an image or document mime becomes neither and is excluded from all three buckets rather than mis-typed. + - `m4a`, `aac`, `opus` and `wma` classify as audio — the four extensions `ShareModal.vue`'s mime map omits today. + - An `access: 'Paid'` item maps with its price and a locked flag so the grid can render the paid state; it does **not** get a playable source URL until unlocked. + - Two items with identical `filename` and `size_bytes` but different `id` produce two cards. + - An empty input array produces empty `films`/`songs`/`podcasts` arrays — not `undefined`, not a thrown error. + - A `null`/absent `description` maps to `''`; a `null` `added_at` sorts last rather than crashing the comparator. + - Sorting is `added_at` descending, `id` ascending on ties — calling the adapter twice on the same input in a different array order yields identical output order. + + +- `/home/archipelago/Projects/AIUI/packages/core/src/types/content.ts` lines 7-70 — the exact target shapes: `Film` (line 7), `FilmSource` (23), `SongSource` (37), `Song` (44), `Podcast` (63). **This file is read, never modified** — D-12 keeps AIUI's design exactly. +- `core/archipelago/src/content_server.rs` — `ContentItem` and `AccessControl` (`Free | PeersOnly | Paid`), the source shape being mapped from. +- `core/archipelago/src/api/rpc/content.rs` — `content.list-mine`, `content.browse-peer`, `content.owned-list`, `content.preview-peer`, and the MIME auto-filing logic around line 668 (the classification precedent to stay consistent with). +- `neode-ui/src/api/filebrowser-client.ts` lines 172-176 — `streamUrl`'s `?auth=${token}` JWT-in-query-string. **This is the known leak to fix rather than propagate**; read it so you recognise the pattern and do not reproduce it. +- `neode-ui/src/composables/__tests__/useFileType.test.ts` — the in-repo convention for a fixture-driven pure-function Vitest suite. +- `13-RESEARCH.md` Pitfall 4 and Pitfall 5. + + +Create `neode-ui/src/composables/archyContentAdapter.ts` exporting `adaptContentItems(items: ArchyContentItem[], opts: { source: 'own' | 'peer' | 'indeehub'; peerOnion?: string }): ArchyContentBundle` where `ArchyContentBundle` is `{ films: Film[]; songs: Song[]; podcasts: Podcast[] }` structurally matching AIUI's exported types (declare the minimal local interfaces rather than importing across repos — neode-ui does not depend on `@aiui/core`). + +`classifyByMime` decides the bucket from `mime_type` with an extension fallback for the cases the mime is wrong or generic. It must classify `audio/mp4`, `audio/aac`, `audio/opus`, `audio/x-ms-wma` and the `.m4a`/`.aac`/`.opus`/`.wma` extensions as audio — `ShareModal.vue`'s mime map omits exactly these four today, which is why such files currently share as `application/octet-stream`, never route to the audio player, and get auto-filed to `Documents` instead of `Music`. 13-11 fixes the share side; the adapter must not inherit the same blind spot. + +`adaptToFilm`/`adaptToSong`/`adaptToPodcast` carry `id` through unchanged as the card key (this is what makes the adjacency case correct: two peers sharing a byte-identical file are two rows, because they are two things the operator can act on separately). Derive `title` from `filename` with the extension stripped. Map `description ?? ''`. Build exactly one `sources[]` entry per item, with a `type` value that distinguishes this node's own file from a peer's file from IndeeHub — pin those three literal values in the test so a later refactor cannot quietly change what a grid badge means. + +For playback URLs: **do not build any URL containing a credential in its query string.** Own-node media resolves through the existing content endpoints (`/content/`), peer media through the existing Rust Range-streaming proxy (`/api/peer-content//`) — both of which already carry the page's session. Where a bare ` + + cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts + cd neode-ui && npx vue-tsc --noEmit + + +- `cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts` exits 0 with a test per `` bullet +- `grep -q 'export function adaptContentItems' neode-ui/src/composables/archyContentAdapter.ts` +- `grep -ciE 'm4a|aac|opus|wma' neode-ui/src/composables/archyContentAdapter.ts` is ≥ 4 +- `grep -cE '[?&](auth|token)=' neode-ui/src/composables/archyContentAdapter.ts` returns 0 — the `filebrowser-client.ts` leak is not propagated +- The test file contains an assertion that no adapter-produced URL matches `[?&](auth|token)=` +- `git -C /home/archipelago/Projects/AIUI diff --exit-code -- packages/core/src/types/content.ts packages/app/src/components/content/FilmGrid.vue packages/app/src/components/content/SongGrid.vue` exits 0 — D-12's "props unchanged" held +- `cd neode-ui && npx vue-tsc --noEmit` exits 0 + + D-12's grid-source swap is rated costly in CONTEXT.md — the grids stay prop-driven and the source behind them is swappable, but every consumer is written against this mapping's field semantics. Flagged, not gated. + Real `ContentItem` fixtures produce grid-ready `Film`/`Song`/`Podcast` records with stable ordering, correct empty/adjacency behaviour, no credential-bearing URLs, and no change to any AIUI grid component. + + + + Task 2: A content channel on the existing bridge, with a stale-response guard + neode-ui/src/services/contextBroker.ts, neode-ui/src/types/aiui-protocol.ts + +- `neode-ui/src/services/contextBroker.ts` — the `handleMessage` switch (lines 71-84, now carrying 13-01's `chat:request` arm), `handleContextRequest` at 87, the ten `sanitize*` methods at 290-299, and `postToIframe` at 620. +- `neode-ui/src/types/aiui-protocol.ts` — the unions 13-01 extended with `AIUIChatRequest`/`ArchyChatResponse`. +- `neode-ui/src/services/__tests__/contextBroker.test.ts` — the suite that must stay green. +- `core/archipelago/src/api/rpc/content.rs` — the exact `content.*` method names and their param shapes: `content.list-mine`, `content.browse-peer`, `content.owned-list`. +- `neode-ui/src/stores/aiPermissions.ts` — the `media` and `files` categories; content push is gated on them. + + +Add a content channel to the existing origin-checked bridge — a **single generic channel with a `kind` discriminator**, not one channel per content type. 13-11 adds music to it without touching this file again, which is what keeps the music track independent. + +In `aiui-protocol.ts` add `AIUIContentRequest { type: 'content:request'; id: string; kind: 'films' | 'songs' | 'podcasts' | 'all'; scope?: 'own' | 'peers' | 'owned' }` and `ArchyContentPush { type: 'content:push'; id: string; kind: string; films?: …; songs?: …; podcasts?: … }`, adding each to the appropriate union. + +In `contextBroker.ts` add a `case 'content:request'` arm and a private `handleContentRequest(id, kind, scope)` that: checks the `media`/`files` permission categories through the existing `useAIPermissionsStore` (this channel carries node data to the iframe, so it is a consent surface — unlike `chat:request`, whose authority is resolved node-side); calls the relevant `content.*` RPCs via `rpcClient.call`; runs the results through `adaptContentItems`; and posts a `content:push` back through the existing `postToIframe`. + +Add the concurrency guard: a private monotonically-increasing `contentRequestSeq`. Each `handleContentRequest` captures its sequence number before awaiting and discards its own result if a newer request has started in the meantime. Without this, a slow `content.browse-peer` landing after a fast `content.list-mine` flips the grid back to older data — the failure the AIUI-03 concurrency edge names. + +Do not add a second postMessage channel, do not relax `this.allowedOrigin`, and do not let AIUI supply the RPC method name or params — the iframe names a `kind`, the broker decides the call. Extend `contextBroker.test.ts` with a stale-response case asserting that an out-of-order resolution does not overwrite newer data. + + + cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts + cd neode-ui && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts + cd neode-ui && npx vue-tsc --noEmit + + +- `grep -q "content:request" neode-ui/src/services/contextBroker.ts` and `grep -q "adaptContentItems" neode-ui/src/services/contextBroker.ts` +- `grep -q "contentRequestSeq" neode-ui/src/services/contextBroker.ts` — the stale-response guard exists +- `contextBroker.test.ts` contains a test whose name mentions stale or out-of-order, and it passes +- `cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts` both exit 0 +- `grep -c "method: msg\.\|method: request\." neode-ui/src/services/contextBroker.ts` returns 0 — the iframe never names an RPC method +- `cd neode-ui && npx vue-tsc --noEmit` exits 0 + + A `content:request` from the allowed origin, with the media/files categories granted, returns adapted grid records; an ungranted category returns a refusal; a stale in-flight response never overwrites newer data. + + + + Task 3: AIUI renders Archy content in the grids it already has + /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts, /home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts + +- `/home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts` lines 1-45 — the module-level `panelFilms`/`panelSongs`/`panelPodcasts` refs and the mock imports, and `updatePanelFromText` at line 80 with its export list at 495-520. +- `/home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts` — the `__AIUI_EMBEDDED__` detection at lines 80-81 and the existing `archyBridge.requestContext(cat).then(...)` shape at line 134. **Mirror this; do not invent a third convention.** +- `/home/archipelago/Projects/AIUI/packages/app/src/pages/ChatPage.vue` — the live render tree (`ContentGridView`). **Note `ContentPanel.vue` is dead code and must not be built through** (CONTEXT.md Deferred). +- `/home/archipelago/Projects/AIUI/packages/app/src/composables/__tests__/` — the existing suite, including `contentExtraction.test.ts`, which must stay green. + + +Work in `/home/archipelago/Projects/AIUI` on branch `development`. + +In `useContentPanel.ts` add `setArchyContent(bundle: { films?; songs?; podcasts? })`, which writes the module-level `panelFilms`/`panelSongs`/`panelPodcasts` refs directly, and an `archyContentActive` ref it sets true. Export both. Then guard `updatePanelFromText` so that when `archyContentActive` is true it does **not** overwrite the film/song/podcast buckets from regex-scraped model prose — the Archy-sourced grids are the source of truth for those three buckets when a node is supplying them. Leave the rest of `updatePanelFromText` (books, TV, images, places, magazine, code, recipes, news) untouched: those still have no Archy source and are outside D-12's slice. + +Do **not** delete `contentExtraction.ts` or its regex path. `13-PATTERNS.md` calls this a *partial* deprecation: the regex path stays for AIUI's non-Archy content and for standalone mode (D-17), and is bypassed only for the three Archy-sourced buckets. + +In `useArchy.ts` add `requestArchyContent(kind, scope)` following the existing `archyBridge.requestContext` shape, and call `setArchyContent` from its `content:push` handler. Register the handler alongside the existing bridge listeners; do not add a second `window.addEventListener('message')`. + +Do not touch `FilmGrid.vue`, `SongGrid.vue`, `NewsGrid.vue`, `ContentGridView.vue` or `packages/core/src/types/content.ts` — D-12 is explicit that only the data source changes. Do not revive `ContentPanel.vue`, `ArchyAppsGrid.vue`, `FavoritesGrid.vue`, `DiscoverPanel.vue`, `RecipeDetail.vue` or `AppDetail.vue`. + +Record honestly in the summary that TMDB posters, web search and RSS remain 404 on a node because their Vite plugins are dev-server-only — a `Film` adapted from a peer file has no `posterUrl` and the grid must render its existing no-artwork state rather than a broken image. + +Commit and push on `development`, staging explicitly by path. + + + cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run + cd /home/archipelago/Projects/AIUI/packages/app && npx vue-tsc --noEmit + cd /home/archipelago/Projects/AIUI && git diff --exit-code HEAD~1 -- packages/app/src/components/content/ packages/core/src/types/content.ts + + +- `grep -q 'setArchyContent' /home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts` and it appears in the export list +- `grep -q 'archyContentActive' /home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts` +- `grep -q 'requestArchyContent' /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts` +- `grep -c 'ContentPanel' /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts` returns 0 — the dead path was not revived +- `git -C /home/archipelago/Projects/AIUI diff --exit-code HEAD~1 -- packages/app/src/components/content/` exits 0 — no grid component changed +- `cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run` exits 0 (`contentExtraction.test.ts` still green — the regex path was guarded, not removed) +- `cd /home/archipelago/Projects/AIUI/packages/app && npx vue-tsc --noEmit` exits 0 +- The commit is pushed to `development` + + With a node supplying content, `FilmGrid` and `SongGrid` render real peer/owned/paid records through their unchanged props; with no node, AIUI's own regex path still works exactly as before. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| peer-supplied filenames and descriptions → the browser DOM | Peer-authored strings render as card titles and descriptions | +| peer-supplied filenames and descriptions → the model context | Same strings will reach the assistant's context — D-10 territory, enforced in 13-12 | +| broker → iframe | Node content crosses into AIUI; gated on the `media`/`files` grants | +| media URL → ` + + +- `cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts && npx vitest run src/services/__tests__/contextBroker.test.ts && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts` all green +- `cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run && npx vue-tsc --noEmit` green +- `git -C /home/archipelago/Projects/AIUI diff --exit-code HEAD~1 -- packages/app/src/components/content/ packages/core/src/types/content.ts` exits 0 +- No adapter-produced URL matches `[?&](auth|token)=` + + + +AIUI's existing grids show the node's real content, with no grid component or content type +changed; the mapping is pinned by fixtures at its adjacency, empty, ordering and concurrency +edges; and the phase gains no new credential-in-URL leak and no new payment rail. + + + +Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-SUMMARY.md` when done + diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-07-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-07-PLAN.md new file mode 100644 index 00000000..c1cd1b16 --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-07-PLAN.md @@ -0,0 +1,234 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 07 +type: execute +wave: 3 +depends_on: ["13-04"] +files_modified: + - core/archipelago/src/music/index.rs + - core/archipelago/src/music/mod.rs + - core/archipelago/src/api/rpc/music.rs + - core/archipelago/src/api/rpc/dispatcher.rs +autonomous: true +requirements: [AIUI-03] + +must_haves: + truths: + - "The node has a real music library — albums, artists and tracks derived from extracted tags, persisted under data_dir, not a MIME filter over a folder listing (D-13)" + - "The index stays fresh: a file added, changed or removed since the last scan is reflected without a full rebuild, and a full reindex is available on demand" + - "An index written by a newer MUSIC_SCHEMA_VERSION is refused and rebuilt rather than misread" + - "A concurrent read during a reindex returns a consistent snapshot, never a partially-written index (edge: AIUI-03 concurrency)" + - "Album and track ordering is deterministic and stable across repeated calls, with a defined tiebreak when sort keys are equal (edge: AIUI-03 ordering)" + - "An empty library returns empty arrays with a scanned-at timestamp, not an error and not a null (edge: AIUI-03 empty)" + - "The indexer never reads outside the configured media roots" + artifacts: + - path: "core/archipelago/src/music/index.rs" + provides: "Scan, extract, persist and incrementally refresh the library index under data_dir" + contains: "pub async fn reindex" + - path: "core/archipelago/src/api/rpc/music.rs" + provides: "music.* RPC surface backing SongGrid" + contains: "handle_music" + key_links: + - from: "core/archipelago/src/music/index.rs" + to: "core/archipelago/src/music/tags.rs" + via: "extract_tags per file, with media_roots confinement passed through" + pattern: "extract_tags" + - from: "core/archipelago/src/api/rpc/dispatcher.rs" + to: "core/archipelago/src/api/rpc/music.rs" + via: "single music. prefix arm, mirroring 13-01's assistant. arm" + pattern: "starts_with\\(\"music\\.\"\\)" +--- + + +Build the library D-13 asked for: albums, artists, tracks, tag extraction and an index that +stays fresh — over the entity model decided at 13-04's checkpoint, using the extraction built +there. + +CONTEXT.md is blunt about why this exists: today "music" on a node is only a MIME branch and a +hardcoded `Music` folder, with no library domain at all. The user chose the real library over +the narrower MIME-filtered-files option after being told that. + +**Track independence (D-13):** no plan on the control or content track lists any music plan in +its `depends_on`. This plan depends only on 13-04. Peer files, movies and conversational control +ship on their own track; the library lights up `SongGrid` in 13-11 when it is ready. + +**Deliberately out of scope, stated rather than implied:** this phase does not add a music tool +to the assistant's curated registry. Music browsing is a grid surface here, not a chat surface; +the registry's `content_list` (Media) already covers media reads, and adding a music tool would +create a coupling between the two tracks that D-13 exists to avoid. + +Output: `music/index.rs`, `music/mod.rs` completed, and the `music.*` RPC surface. + + + +None in this plan. + + + +Symbols created by **this plan**: +- `core/archipelago/src/music/index.rs`: `pub struct MusicIndex`, `pub async fn reindex`, + `pub async fn refresh_incremental`, `pub fn load`, `pub fn save_atomic`, `struct IndexEntry`, + `struct ScanStats`, `fn group_albums`, `fn group_artists`, `const INDEX_FILENAME` +- `core/archipelago/src/music/mod.rs`: `pub fn media_roots`, `pub struct LibrarySnapshot` +- `core/archipelago/src/api/rpc/music.rs`: `handle_music` (prefix sub-dispatcher), + `handle_music_list_albums`, `handle_music_list_artists`, `handle_music_list_tracks`, + `handle_music_status`, `handle_music_reindex` +- New RPC method names: `music.list-albums`, `music.list-artists`, `music.list-tracks`, + `music.status`, `music.reindex` +- `core/archipelago/src/api/rpc/dispatcher.rs`: one `music.` prefix arm + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@CLAUDE.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-MUSIC-MODEL.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-SUMMARY.md + + + + + + Task 1: The index — scan, group, persist, and stay fresh without a full rebuild + core/archipelago/src/music/index.rs, core/archipelago/src/music/mod.rs + + - A first `reindex` over a directory of tagged files produces tracks, and albums and artists grouped exactly as `13-MUSIC-MODEL.md` decided. + - `refresh_incremental` after adding one file adds one track and does not re-extract tags for unchanged files. + - `refresh_incremental` after deleting one file removes that track, and removes the album if it had no other tracks. + - `refresh_incremental` after a file's mtime changes re-extracts that file's tags and updates the track in place, keeping its identity per the decided scheme. + - Loading an index whose `schema_version` is greater than `MUSIC_SCHEMA_VERSION` returns a distinct error and triggers a full rebuild rather than a partial read. + - A read taken while a reindex is in progress returns either the complete previous snapshot or the complete new one — never a mix and never a truncated file. + - `reindex` on an empty directory produces an index with empty collections and a populated `scanned_at`. + - A symlink pointing outside the media roots is skipped, not followed. + + +- `.planning/phases/13-.../13-MUSIC-MODEL.md` — the decided identity scheme, whether albums/artists are stored or derived, the index path and format, and the reindex path. **This task implements that decision; it does not revisit it.** +- `core/archipelago/src/music/mod.rs` and `music/tags.rs` from 13-04 — `Track`/`Album`/`Artist`, `MUSIC_SCHEMA_VERSION`, `extract_tags(path, media_roots)`. +- `core/archipelago/src/content_server.rs` — `load_catalog`. `13-PATTERNS.md` assigns this as the role-match analog: read its scan-and-persist shape, its `data_dir` convention and its error handling, and follow them. +- `core/archipelago/src/streaming/session.rs` — the other `data_dir`-scoped persisted-state precedent, for file permissions. +- `core/archipelago/src/swarm/payment.rs` — the `#[tokio::test]` + `tempfile` convention. + + +Create `core/archipelago/src/music/index.rs` implementing the entity model recorded in `13-MUSIC-MODEL.md`. + +`media_roots(&Config) -> Vec` in `mod.rs` returns the roots the indexer is confined to, drawn from the sources 13-04 decided to index. Every filesystem operation in this module takes those roots and refuses paths outside them, canonicalizing first and skipping symlinks whose target escapes — an indexer that can be aimed at `data_dir/secrets` is a secret-exfiltration primitive, and this is the second of the two places (with `tags.rs`) that confinement is enforced. + +`reindex` walks the roots, calls `extract_tags` per audio file, builds `Track` rows, and groups albums and artists per the decision. `refresh_incremental` compares each file's `(path, mtime, size)` against the stored `IndexEntry` and only re-extracts changed files, removing rows for files that disappeared and pruning albums that lost their last track. Track a `ScanStats { scanned, extracted, skipped, removed, elapsed_ms }` and return it — a library scan that gives no feedback is indistinguishable from a hang on a large collection. + +Persistence: `save_atomic` writes to a sibling temp file in the same directory and `rename`s over the target, so a read never sees a partial file and a crash mid-write leaves the previous index intact. That single choice is what makes the concurrency behaviour above true; do not write in place. `load` refuses an index whose `schema_version` exceeds `MUSIC_SCHEMA_VERSION` with a distinct error variant and lets the caller rebuild — a forward-incompatible index misread as current is worse than no index. + +Ordering: define one comparator used everywhere — albums by album artist then album title then year, tracks by disc then track number then title, with the decided identity as the final tiebreak so equal keys never reorder between calls. + +Guard the reindex with a lock or an atomic in-progress flag so two concurrent `music.reindex` calls do not both walk the tree; the second returns "already running" with the current stats rather than queueing a duplicate scan. + +Write the tests FIRST, one per `` bullet, generating fixture audio into a `tempfile::tempdir()` with `lofty`'s writing API as 13-04 established (no committed binary fixtures). Name them under `music::index::tests::`. + + + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago music::index:: 2>&1 | tail -25 + + +- `cd core && cargo test --package archipelago music::index::` exits 0 with a test per `` bullet +- `grep -q 'pub async fn reindex' core/archipelago/src/music/index.rs` and `grep -q 'refresh_incremental' core/archipelago/src/music/index.rs` +- `grep -cE 'rename|persist' core/archipelago/src/music/index.rs` ≥ 1 and `grep -c 'save_atomic' core/archipelago/src/music/index.rs` ≥ 1 — the write is atomic, not in place +- `grep -q 'MUSIC_SCHEMA_VERSION' core/archipelago/src/music/index.rs` and the load path compares against it +- `grep -q 'media_roots' core/archipelago/src/music/index.rs` — confinement is a parameter, and it is enforced here as well as in `tags.rs` +- `git ls-files core/archipelago | grep -ciE '\.(mp3|flac|m4a|ogg)$'` returns 0 +- The grouping field names in `index.rs` match `13-MUSIC-MODEL.md` — spot-check each and record the result in the summary + + The on-disk index is the persisted half of D-13's one-way decision — but that door was already gated: the entity model, the index location and the index format were decided at **13-04 Task 1's `checkpoint:decision`**, which this plan depends on. This task implements that recorded decision and adds the `MUSIC_SCHEMA_VERSION` guard plus a written reindex path, which is what turns a future entity-model change from silently lossy into merely costly. No new one-way door is opened here. + A directory of real tagged files becomes a persisted album/artist/track index; adding, changing and deleting one file each update it incrementally; a crash mid-write cannot corrupt it; and a forward-version index is refused rather than misread. + + + + Task 2: The music.* RPC surface, behind one dispatcher arm + core/archipelago/src/api/rpc/music.rs, core/archipelago/src/api/rpc/dispatcher.rs + + - `music.list-albums` returns albums in the deterministic order, with a `scanned_at` and a total count. + - `music.list-tracks` accepts an optional `album_id` filter and paginates with `limit`/`offset`, capping `limit` so a huge library cannot be pulled in one response. + - `music.status` returns the last scan's `ScanStats`, whether a scan is running, and the schema version. + - `music.reindex` starts a scan and returns immediately; a second call while one is running reports already-running instead of starting a duplicate. + - Every `music.*` method is refused without an authenticated session. + - An empty library returns empty arrays with a populated `scanned_at`, never null and never an error. + + +- `core/archipelago/src/api/rpc/mesh/assistant.rs` — `13-PATTERNS.md`'s exact-match analog for handler shape: `impl RpcHandler` + `pub(in crate::api::rpc) async fn handle_* -> Result`. +- `core/archipelago/src/api/rpc/dispatcher.rs` — the arm 13-01 added, `m if m.starts_with("assistant.")`. **Mirror it exactly for `music.`**; do not add five individual arms. +- `core/archipelago/src/api/rpc/mod.rs` lines 264-330 — the session + CSRF + `role.can_access()` gate that runs before dispatch, so no bespoke auth belongs in these handlers. +- `core/archipelago/src/api/rpc/middleware.rs` — `UNAUTHENTICATED_METHODS`. Read it to confirm you are not adding to it. +- `core/archipelago/src/api/rpc/content.rs` — the pagination and response-envelope conventions used by `content.list-mine` / `content.owned-list`; match them so the neode-ui side has one shape to learn. + + +Create `core/archipelago/src/api/rpc/music.rs` with `handle_music(&self, method: &str, params) -> Result` as a prefix sub-dispatcher plus `handle_music_list_albums`, `handle_music_list_artists`, `handle_music_list_tracks`, `handle_music_status` and `handle_music_reindex`. + +Register in `dispatcher.rs` as a **single** guarded arm `m if m.starts_with("music.") => self.handle_music(m, params).await`, mirroring 13-01's `assistant.` arm. Place it adjacent to the `content.*` block so a reader finds the media surfaces together. This is the only `dispatcher.rs` edit in the music track. + +Response envelopes match `content.*`'s conventions so `archyContentAdapter.ts` (13-11) has one shape to consume. `music.list-tracks` caps `limit` at 500 and defaults to 100; an out-of-range `limit` is clamped, not rejected, so a UI bug degrades to a smaller page rather than an error. + +`music.reindex` spawns the scan with `tokio::spawn` and returns immediately with the in-progress flag — a synchronous reindex would hold an RPC connection for the length of a library walk. It must not hold any shared lock across the walk (the same discipline `mesh/listener/assist.rs` documents for its own spawned work). + +Do **not** add any `music.*` method to `UNAUTHENTICATED_METHODS`. Add a test asserting no string starting with `music.` appears there, mirroring 13-01's `assistant_methods_require_session`. + +Write the tests FIRST, one per `` bullet, under `api::rpc::music::tests::`. + + + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago music:: 2>&1 | tail -25 + cd core && CARGO_INCREMENTAL=0 cargo build --package archipelago 2>&1 | tail -5 + + +- `cd core && cargo test --package archipelago music::` exits 0 with a test per `` bullet, including `music_methods_require_session` +- `grep -c 'starts_with("music.")' core/archipelago/src/api/rpc/dispatcher.rs` returns 1 — one arm for the whole surface +- `grep -n 'music\.' core/archipelago/src/api/rpc/middleware.rs` returns no match +- `grep -q 'handle_music' core/archipelago/src/api/rpc/music.rs` +- `grep -cE 'limit' core/archipelago/src/api/rpc/music.rs` ≥ 1 and the clamp is visible in the source +- `cd core && CARGO_INCREMENTAL=0 cargo build --package archipelago` exits 0 + + An authenticated caller can list albums, artists and paginated tracks, read scan status, and trigger a reindex that does not duplicate itself; an unauthenticated caller gets nothing. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| filesystem → indexer | Filenames and tag contents are peer-influenceable for any shared audio | +| index file → readers | A persisted, versioned artifact that survives restarts and upgrades | +| `music.*` RPC → callers | Session + CSRF + RBAC, inherited from the existing dispatch gate | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-39 | Information Disclosure | Indexer walking outside the media roots (symlink escape) | high | mitigate | Canonicalize-and-confine in both `tags.rs` and `index.rs`; symlinks whose target escapes are skipped, not followed. Asserted by the symlink test | +| T-13-40 | Elevation of Privilege | `music.*` reachable unauthenticated | high | mitigate | Registered in the normal dispatch table so the existing session/CSRF/RBAC gate applies; asserted by `music_methods_require_session` and by the `middleware.rs` grep | +| T-13-41 | Denial of Service | A huge library pulled in one response, or a reindex duplicated per click | medium | mitigate | `limit` clamped at 500; `music.reindex` is spawned, returns immediately, and refuses to start a second concurrent scan | +| T-13-42 | Tampering | Crash mid-write corrupts the index | medium | mitigate | `save_atomic` writes to a temp sibling and renames; a crash leaves the previous index intact. Asserted by the concurrent-read test | +| T-13-43 | Tampering | Forward-version index misread as current, producing silently wrong entities | medium | mitigate | `load` refuses `schema_version > MUSIC_SCHEMA_VERSION` with a distinct error and rebuilds | +| T-13-44 | Denial of Service | Hostile audio file hangs or panics the scan | medium | mitigate | Per-file `extract_tags` errors are collected into `ScanStats.skipped` and the walk continues; no `unwrap` on parser output (inherited from 13-04) | +| T-13-45 | Tampering | Peer-authored tag text treated as trusted once indexed | high | accept | Out of this plan's scope by sequencing: nothing here places tag text in a model context. 13-12's `wrap_untrusted` boundary owns it. Recorded so the assumption is explicit | +| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added — `lofty` entered at 13-04 through its human legitimacy gate. No install task here | + + + +- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago music::` green +- `cd core && CARGO_INCREMENTAL=0 cargo build --package archipelago` exits 0 +- `grep -c 'starts_with("music.")' core/archipelago/src/api/rpc/dispatcher.rs` == 1 +- Index field names match `13-MUSIC-MODEL.md` + + + +The node has a real, persisted, incrementally-refreshed music library with a versioned schema +and an atomic write, exposed over an authenticated `music.*` surface — and it got there without +any plan on the control or content track depending on it. + + + +Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-07-SUMMARY.md` when done + diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-08-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-08-PLAN.md new file mode 100644 index 00000000..c7540f8e --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-08-PLAN.md @@ -0,0 +1,317 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 08 +type: execute +wave: 3 +depends_on: ["13-05"] +files_modified: + - core/archipelago/src/assistant/confirm.rs + - core/archipelago/src/assistant/loop_.rs + - core/archipelago/src/assistant/mod.rs + - core/archipelago/src/api/rpc/assistant_chat.rs + - neode-ui/src/components/ToolConfirmModal.vue + - neode-ui/src/services/contextBroker.ts + - neode-ui/src/views/Chat.vue + - neode-ui/src/services/__tests__/toolConfirm.test.ts +autonomous: false +requirements: [AIUI-01, AIUI-04] + +must_haves: + truths: + - "An operator asks for a state change and nothing happens until they approve a dialog that names the real action (D-07, D-11)" + - "The confirmation dialog is drawn by neode-ui outside the iframe, Teleported to body with a full-screen backdrop — the iframe cannot spoof, restyle or pre-click it (D-11)" + - "The approved action is byte-identical to the executed action: approval binds to a node-minted nonce over the tool name and validated arguments, and a mismatched or replayed nonce is refused (S-02)" + - "Confirmation text is assembled from the node's own ToolDef description plus validated arguments — it contains zero model-supplied and zero iframe-supplied strings (S-03)" + - "Two confirmations for different resources produce visibly different text: the resource identifier appears verbatim and differs (S-08)" + - "Pending confirmations are in-memory only — a daemon restart mid-wait resolves as declined and never resurrects a stale write (S-09)" + - "The confirm-gate wait never holds a shared lock: other RPC calls, including mesh.assistant-status, are unaffected while a human decides" + prohibitions: + - statement: "A confirmation must never be raised for an action that does not change state — routine dialogs train the operator to click yes without reading, at which point the gate is present, working, and no longer consent." + status: active + verification: unverified + artifacts: + - path: "core/archipelago/src/assistant/confirm.rs" + provides: "D-11 pending-confirmation queue: node-authored description, nonce binding, in-memory only" + contains: "pub struct PendingConfirmation" + - path: "neode-ui/src/components/ToolConfirmModal.vue" + provides: "Trusted-chrome approve/deny modal, Teleport to body, RPC-fetched text" + contains: "Teleport" + key_links: + - from: "core/archipelago/src/assistant/loop_.rs" + to: "core/archipelago/src/assistant/confirm.rs" + via: "execute_tool suspends on ctx.confirm.request(tool, &args) before (tool.execute)" + pattern: "confirm\\.request" + - from: "neode-ui/src/components/ToolConfirmModal.vue" + to: "core/archipelago/src/api/rpc/assistant_chat.rs" + via: "assistant.confirm-tool over the page's authenticated RPC session, carrying the node-minted nonce" + pattern: "assistant\\.confirm-tool" +--- + + +Build the gate that does the safety work. D-07: every write needs confirmation regardless of +backend — which is what makes backend choice a privacy decision rather than a safety one, and +what makes a mis-called tool from a weak local model a prompt the user rejects instead of a +wrong action. D-11: the dialog renders in neode-ui's trusted chrome, outside the iframe, drawn +by the host from the node's own description of the pending action — never by AIUI and never from +model-authored text. + +Two properties carry the whole threat model and are easy to get subtly wrong: + +**Confirmed-vs-executed parity.** It is not enough that *a* confirmation happened. The action +that runs must be the one the human read. Approval binds to a node-minted nonce over +`hash(tool_name, validated_args)`; a replayed or cross-action "yes" is refused arithmetically. +Without this, EV-12's attack — peer content persuading the model to describe a restart as "a +routine cache refresh" — degrades from "the dialog still names the real action" to a race. + +**Habituation.** AI-SPEC §1b treats a run of near-identical dialogs as a *consent* failure, not +a UX nit: the well-established finding is that identical-looking repeated dialogs lose their +signal after roughly the second exposure, and that habituation generalizes across visually +similar dialogs. So reads never confirm (already asserted in 13-05's S-07), and two +confirmations in a session must be distinguishable at a glance. + +The dialog is also the domain's *signing screen*. The hardware-wallet standard applies: name the +specific resource, the concrete effect, and the blast-radius boundary — not a tool name, not raw +JSON, not a bare "Are you sure?". + +Output: `assistant/confirm.rs`, `assistant.confirm-tool`, and `ToolConfirmModal.vue`. + + + +None in this plan. + + + +Symbols created by **this plan**: + +**Rust** +- `assistant/confirm.rs`: `pub struct ConfirmGate`, `pub struct PendingConfirmation`, + `pub enum Confirmed` (`Yes`, `No`, `TimedOut`), `ConfirmGate::request`, `ConfirmGate::resolve`, + `ConfirmGate::peek`, `fn mint_nonce`, `fn build_description`, `const CONFIRM_TIMEOUT` +- `assistant/loop_.rs`: the `destructive` branch of `execute_tool` filled in +- `api/rpc/assistant_chat.rs`: `handle_assistant_confirm_tool`, `handle_assistant_pending` +- New RPC method names: `assistant.confirm-tool`, `assistant.pending` (both through 13-01's + existing `assistant.` arm — `dispatcher.rs` is not touched) + +**neode-ui** +- `components/ToolConfirmModal.vue` (new component) +- `services/contextBroker.ts`: `handleToolConfirmRequest`, the `aiui:tool-confirm-request` / + `aiui:tool-confirm-response` CustomEvent pair +- `views/Chat.vue`: the modal mount +- `services/__tests__/toolConfirm.test.ts` (new suite) + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@CLAUDE.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-AI-SPEC.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-PATTERNS.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-SUMMARY.md + + + + + + Task 1: The gate — node-authored text, nonce-bound approval, in-memory only + core/archipelago/src/assistant/confirm.rs, core/archipelago/src/assistant/loop_.rs, core/archipelago/src/assistant/mod.rs, core/archipelago/src/api/rpc/assistant_chat.rs + + - A destructive tool call suspends the loop before execution and produces a pending confirmation; nothing runs until it is resolved. + - Resolving with the correct nonce executes exactly the action the pending entry described. + - Resolving with a nonce minted for a *different* pending action is refused; neither action executes. + - Replaying a nonce that was already resolved is refused. + - The built description contains the tool's own description text and the validated argument values, and contains no substring taken from the model's turn. + - Two pending confirmations for different app ids produce descriptions that differ, and each contains its own app id verbatim. + - Dropping and recreating the `ConfirmGate` (the daemon-restart analogue) leaves no pending entry; a subsequent resolve of the old nonce is refused, not executed. + - A confirmation that is never resolved times out and returns a declined result — it does not execute and does not leak the waiting task. + - The confirm wait holds no shared lock: a second RPC needing the same state completes while a confirmation is outstanding. + + +- `.planning/phases/13-.../13-AI-SPEC.md` §4 (the `execute_tool` sketch — its `destructive` branch is what this task fills), §4b.2 "Async-First Design" (the lock-across-await mistake), §4 "State Management" (pending confirmations are in-memory only, keyed by `req_id`/`call_id`, never persisted), §5 invariants **S-01, S-02, S-03, S-08, S-09**, and §1b's "Confirmation clarity" rubric. +- `core/archipelago/src/assistant/loop_.rs` — the tracer's `execute_tool`, whose `destructive` branch currently returns a not-yet-implemented error. +- `core/archipelago/src/assistant/tools.rs` — the four `destructive: true` tools from 13-05 and their args structs; `ToolDef.description` is the source text for the dialog. +- `core/archipelago/src/mesh/listener/assist.rs` — its own doc comment, "Spawned off the radio loop so it never blocks". Inherit that discipline: acquire and drop locks *around* the confirm wait, never across it. +- `core/archipelago/src/api/rpc/mesh/assistant.rs` — the handler shape for the two new methods. + + +Create `core/archipelago/src/assistant/confirm.rs` with a `ConfirmGate` holding an in-memory map from `req_id` to `PendingConfirmation { call_id, tool_name, validated_args, description, nonce, created_at, responder }`. There is no persistence path in this file and none may be added — a daemon restart must force a fresh model turn and a freshly-authored confirmation, not resurrect a stale write whose real-world preconditions may have changed. + +`mint_nonce` computes a nonce over the tool name and the *validated* arguments (post-`ToolDef::validate`, so it binds what will actually run, not what the model sent). `resolve(req_id, nonce, approved)` refuses when the nonce does not match the stored pending entry or when the entry is already resolved, returning a distinct refusal that the caller logs at error level and surfaces to the owner — a nonce mismatch can only mean a replay attempt or a bug in the trusted chrome, so it is loud and sticky, not a toast. + +`build_description(tool, args)` assembles the dialog text from `ToolDef.description` and the validated argument values only. The model's turn is never a source. Write it so the resource identifier — the app id, the setting key — appears verbatim in the text, because that is what makes two confirmations in a session distinguishable at a glance rather than interchangeable. Follow the clear-signing standard: name the resource, name the concrete effect, and name the boundary of what is *not* affected. The `restart_app` description should surface a timing caveat where the node knows one (for instance that a bitcoind restart pauses but does not lose initial-sync progress) — that is the confirmation doing real work, and it is a tool-description requirement rather than a new gate. + +Fill `execute_tool`'s `destructive` branch in `loop_.rs`: after `validate` and after the grant check, call `ctx.confirm.request(tool, &args).await` and branch on `Confirmed::Yes` to execute, `Confirmed::No | Confirmed::TimedOut` to return an error ToolResult saying the user declined. Read the surrounding lock guards and ensure none is held across this await — the wait is human-speed and can be minutes. `CONFIRM_TIMEOUT` is a new constant in this module. + +Add `handle_assistant_confirm_tool` and `handle_assistant_pending` to `assistant_chat.rs`, routed through 13-01's existing `assistant.` prefix arm. `assistant.pending` returns the node-authored description and the nonce for the current pending action so the host chrome can *fetch* the text over the authenticated RPC session rather than receive it from the iframe. **Do not touch `dispatcher.rs`.** + +Write the tests FIRST, one per `` bullet, using 13-01's `ScriptedBackend` to emit a destructive tool call on demand. Name them `assistant::tests::destructive_tool_requires_confirm` (S-01), +`assistant::confirm::tests::approval_nonce_binds_to_exact_action` (S-02), +`assistant::confirm::tests::description_contains_no_model_text` (S-03), +`assistant::confirm::tests::distinct_resources_yield_distinct_text` (S-08), +`assistant::confirm::tests::restart_drops_pending_not_executes` (S-09), +`assistant::confirm::tests::timeout_declines_and_does_not_execute`, +`assistant::confirm::tests::confirm_wait_holds_no_shared_lock`. + + + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -30 + cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago approval_nonce_binds_to_exact_action + cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs + + +- `cd core && cargo test --package archipelago assistant::` exits 0 with all seven named tests passing +- `grep -q 'pub struct PendingConfirmation' core/archipelago/src/assistant/confirm.rs` +- `grep -ciE 'fs::write|save|persist|data_dir' core/archipelago/src/assistant/confirm.rs` returns 0 — the queue has no persistence path (S-09 is structural, not a policy) +- `grep -q 'confirm.request' core/archipelago/src/assistant/loop_.rs` and it appears **before** the `execute` call in `execute_tool` — verify by reading the branch order +- `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 +- Manually flip `restart_app`'s `destructive` flag to false and confirm `destructive_tool_requires_confirm` goes red; restore it and record the observed failure in the summary + + D-11 is rated costly in CONTEXT.md: this is the load-bearing anti-spoofing property, and moving the dialog inside the iframe later would invalidate the threat model, not just the styling. Flagged, not gated. + A destructive tool suspends the loop; only the matching nonce executes it; a mismatched or replayed nonce is refused; the dialog text is node-authored and resource-distinct; and a restart drops the pending action rather than running it. + + + + Task 2: The trusted chrome — a modal the iframe cannot reach + neode-ui/src/components/ToolConfirmModal.vue, neode-ui/src/services/contextBroker.ts, neode-ui/src/views/Chat.vue, neode-ui/src/services/__tests__/toolConfirm.test.ts + + - When the node reports a pending confirmation, the modal opens with the node-fetched description text. + - Approving calls `assistant.confirm-tool` over the page's own RPC session with the node-minted nonce; the iframe is not in that path. + - Denying calls the same method with `approved: false`; the modal closes and the chat reports the decline. + - A message from the iframe that looks like a confirmation payload does not open the modal and does not resolve an open one. + - The modal renders as a direct child of `document.body` with a full-screen backdrop, so no ancestor transform can trap it. + - Two confirmations in sequence render their two different descriptions; the second does not reuse the first's text. + - Closing the modal without a decision leaves the action pending until the node's own timeout, rather than silently approving. + + +- `neode-ui/src/components/NostrSignConsent.vue` (full file, ~70 lines) — `13-PATTERNS.md`'s **exact-match** analog: the project's canonical Teleport-to-body approve/deny modal. Copy its structure, its backdrop, its z-index and its button treatment. +- `neode-ui/src/services/contextBroker.ts` lines 140-196 — the existing `install-app` confirm flow (`aiui:install-request` / `aiui:install-response`, 60s timeout). This is the shape to extend, but **with a new, distinct event pair** — `aiui:install-request` is install-specific and must not be reused (13-PATTERNS.md and RESEARCH both say so). +- `neode-ui/src/views/Chat.vue` lines 25-55 — the iframe element and its surrounding template, where the modal is mounted as a sibling. +- `CLAUDE.md`'s repeatedly-reinforced rule: modals Teleport to body for a full-screen backdrop; a `glass-panel` transform traps `position: fixed`. +- `neode-ui/src/services/__tests__/contextBroker.test.ts` — the suite conventions the new `toolConfirm.test.ts` follows. + + +Create `neode-ui/src/components/ToolConfirmModal.vue` modelled directly on `NostrSignConsent.vue`: `Teleport to="body"`, a `Transition`, a fixed full-screen container, an absolutely-positioned backdrop, and a `glass-card` panel with Deny and Approve buttons. Mount it in `Chat.vue` as a sibling of the iframe, never inside it. + +The component's text comes in as a prop and originates **only** from `assistant.pending`'s RPC response. It must not render its body text as raw markup — use plain interpolation so peer-influenced argument values cannot inject markup — and it must not read anything from the iframe's message channel. There is no code path in this component that accepts a description from the frame. + +In `contextBroker.ts` add `handleToolConfirmRequest`: when a chat turn reports a pending confirmation, fetch the description and nonce with `rpcClient.call({ method: 'assistant.pending' })`, dispatch a `CustomEvent('aiui:tool-confirm-request')` carrying only the node-fetched values, and listen for `aiui:tool-confirm-response` — a **new, distinct** event pair, not the install-app one. On response, call `rpcClient.call({ method: 'assistant.confirm-tool', params: { req_id, nonce, approved } })`. The user's decision travels over the authenticated RPC channel, not back through the frame, so the iframe cannot forge it. + +Add a guard so an inbound frame message whose `type` resembles a confirmation is ignored: the switch has no arm for it, and the new listener is on `window` for the host's own `CustomEvent`, not on the frame's channel. Add an explicit test for this. + +Write `toolConfirm.test.ts` FIRST, one test per `` bullet, mocking `rpcClient`. Name the forgery case `iframe_message_cannot_open_or_resolve_confirmation`. + + + cd neode-ui && npx vitest run src/services/__tests__/toolConfirm.test.ts + cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts src/views/__tests__/chatAiuiEmbed.test.ts + cd neode-ui && npx vue-tsc --noEmit + + +- `cd neode-ui && npx vitest run src/services/__tests__/toolConfirm.test.ts` exits 0 with a test per `` bullet, including `iframe_message_cannot_open_or_resolve_confirmation` +- `grep -q 'Teleport to="body"' neode-ui/src/components/ToolConfirmModal.vue` +- `grep -ci 'postmessage' neode-ui/src/components/ToolConfirmModal.vue` returns 0 — the component has no path from the frame's channel +- `grep -ci 'v-html' neode-ui/src/components/ToolConfirmModal.vue` returns 0 +- `grep -c 'aiui:install-request' neode-ui/src/components/ToolConfirmModal.vue` returns 0 and `grep -c 'aiui:tool-confirm-request' neode-ui/src/services/contextBroker.ts` returns ≥ 1 — a distinct event pair, not the install one +- `grep -q 'assistant.pending' neode-ui/src/services/contextBroker.ts` — the text is RPC-fetched +- `grep -q 'ToolConfirmModal' neode-ui/src/views/Chat.vue` +- `cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts src/views/__tests__/chatAiuiEmbed.test.ts` exits 0 (pre-existing suites still green) +- `cd neode-ui && npx vue-tsc --noEmit` exits 0 + + The confirmation renders in host chrome outside the iframe with a full-screen backdrop, its text comes from the node over RPC, and no message from the frame can open or resolve it. + + + + Task 3: Look at the dialog — anti-spoofing and clear-signing are things you see + +The full write path: ask the embedded AIUI to restart an app; the node suspends the loop, +authors a description, and neode-ui draws it outside the iframe; approving executes exactly that +action, denying executes nothing. + +This is a checkpoint because the two properties that matter here are not `cargo test`-shaped. +Whether the dialog is genuinely outside the iframe and un-restylable by it is a visual/trust +property. And whether the copy clears the clear-signing bar — a non-technical owner can state +which resource is affected and what the consequence is — is a judgement, and AI-SPEC §1b is +explicit that a security-minded reviewer systematically under-catches confusing copy because +they already understand the domain. + + +1. Build and deploy to archi-dev-box per `CLAUDE.md` (dev pair before any OTA). Build the + frontend with `cd neode-ui && npm run build` and **grep the built bundle** for a string from + `ToolConfirmModal.vue` before shipping — the build can silently no-op. Then verify node-side + by resolving the live chunk via `sw.js` and fetching it over HTTP, not by grepping the + node's `assets/` directory (it is a never-pruned graveyard and will report "deployed" before + the deploy). +2. Open neode-ui's Chat view, grant the `apps` category, and type a request to restart a + specific installed app. +3. Observe the dialog. Confirm: it covers the whole viewport including the area over the + iframe; the backdrop is full-screen (not clipped to the chat panel); the app id appears + verbatim in the text; the text names a concrete effect and says what is *not* affected. +4. Read the dialog as if you did not write it. Can a non-technical owner state what will happen? + If it shows a tool name or raw JSON, that is the blind-signing failure and it fails. +5. Deny. Confirm nothing happened to the container and the chat reports the decline honestly + rather than claiming it restarted. +6. Ask again and approve. Confirm the container actually restarted and the chat reports it. +7. Ask for a second, *different* app. Confirm the two dialogs read differently at a glance. +8. Ask a read-only question ("how much space is left"). Confirm **no** dialog appears. +9. Trigger a confirmation, then restart the archipelago service while it is open. Confirm the + action does not execute on restart. + + +- `grep -q "" web/dist/neode-ui/assets/*.js` before deploy +- The dialog's backdrop covers the full viewport including over the iframe (screenshot recorded in the summary) +- The dialog text contains the exact app id, a stated effect, and a stated non-effect; it contains no tool name and no JSON +- Denying leaves `podman ps` output for that container unchanged, and the chat says it was declined +- Approving restarts that container and only that container +- Two different apps produce two visibly different dialogs +- A read-only question produces zero dialogs +- Restarting `archipelago.service` with a confirmation open results in no execution + + Type "approved" and paste the exact dialog text you saw, or describe what read wrong. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| model turn → confirmation text | **Never crosses.** The dialog is assembled node-side from `ToolDef.description` + validated args | +| iframe → confirmation dialog | The dialog renders in host chrome; the frame has no path to open, restyle or resolve it | +| user decision → node | Travels over the authenticated RPC session carrying a node-minted nonce, not back through the frame | +| pending state → disk | **Never crosses.** In-memory only, by construction | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-46 | Spoofing | Model-authored or iframe-authored text presented as a system confirmation | **critical** | mitigate | G-S3: `build_description` reads only `ToolDef.description` + validated args. Asserted by `description_contains_no_model_text`; the component has no path from the frame's channel, asserted by grep and by `iframe_message_cannot_open_or_resolve_confirmation` | +| T-13-47 | Tampering | Confirmed action and executed action diverge (EV-12) | **critical** | mitigate | G-S2: approval binds to a nonce over `hash(tool_name, validated_args)`; a cross-action or replayed yes is refused arithmetically. Asserted by `approval_nonce_binds_to_exact_action` | +| T-13-48 | Elevation of Privilege | A write executes with no confirmation at all | **critical** | mitigate | G-S1: the gate sits in `execute_tool` before `(tool.execute)`, keyed on `ToolDef.destructive`, and the model's output is an input to the check rather than the check. Asserted by `destructive_tool_requires_confirm` and by the flip-the-flag negative case | +| T-13-49 | Tampering | A stale pending write resurrected after a restart, when its preconditions have changed | high | mitigate | S-09: no persistence path exists in `confirm.rs`. Asserted structurally by the no-`fs::write` grep and behaviourally by `restart_drops_pending_not_executes` and the on-device step 9 | +| T-13-50 | Repudiation | Habituation — a run of near-identical dialogs makes consent hollow | high | mitigate | S-07 (13-05) keeps reads dialog-free; S-08 makes resources distinguishable. Recorded as this plan's prohibition. Post-ship, F-3 (median time-to-decision < 2s with a ~0 decline rate) is the rubber-stamp signature | +| T-13-51 | Denial of Service | An unresolved confirmation leaks a waiting task or stalls other RPCs | medium | mitigate | `CONFIRM_TIMEOUT` declines and cleans up; no shared lock is held across the await. Asserted by `timeout_declines_and_does_not_execute` and `confirm_wait_holds_no_shared_lock` | +| T-13-52 | Tampering | Markup injected via a peer-influenced argument value rendered in the dialog | medium | mitigate | Plain interpolation only; the raw-HTML directive is absent, asserted by grep | +| T-13-53 | Spoofing | Reusing `aiui:install-request` so an install confirmation and a tool confirmation become interchangeable | medium | mitigate | A new, distinct event pair; asserted by grep on both files | +| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added in either repo. No install task, so no legitimacy checkpoint required | + + + +- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::` green (S-01, S-02, S-03, S-08, S-09 plus timeout and lock cases) +- `cd neode-ui && npx vitest run src/services/__tests__/toolConfirm.test.ts src/services/__tests__/contextBroker.test.ts src/views/__tests__/chatAiuiEmbed.test.ts` green +- `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 +- On archi-dev-box: deny leaves the container untouched, approve restarts exactly it, reads raise no dialog, and a service restart mid-confirmation executes nothing + + + +No write reaches a node without a human having approved a node-authored description of that +exact action, in a dialog the iframe cannot spoof, restyle or pre-click — demonstrated in code +by nonce-binding tests and on a real device by a person reading the dialog. + + + +Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-08-SUMMARY.md` when done + diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-PLAN.md new file mode 100644 index 00000000..3be4867b --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-PLAN.md @@ -0,0 +1,299 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 09 +type: execute +wave: 4 +depends_on: ["13-02"] +files_modified: + - scripts/build-aiui.sh + - scripts/verify-aiui-deploy.sh + - scripts/aiui.pin + - scripts/deploy-to-target.sh + - image-recipe/configs/nginx-archipelago.conf + - neode-ui/src/views/Chat.vue +autonomous: false +requirements: [AIUI-04, AIUI-05] + +must_haves: + truths: + - "An operator receives AIUI updates through a build and deploy path that fails loudly rather than shipping a black page (AIUI-05, D-15)" + - "The AIUI commit shipped by a given Archy build is pinned in this repo and recorded in the deployed artifact, so 'which AIUI is on this node' is answerable (D-15)" + - "`VITE_BASE_PATH=/aiui/` is enforced by the build script, not remembered — the script exits non-zero when it is unset or wrong (D-15)" + - "The post-deploy check fetches a live asset over HTTP resolved through sw.js, never trusting a directory listing — the node's assets/ is a never-pruned graveyard that reports 'deployed' before the deploy" + - "AIUI's own JavaScript is browser-prevented from reaching /rpc/v1 with the ambient session cookie — the sandbox is an enforced boundary, not only a code-discipline convention (AIUI-04, RESEARCH Open Question 2)" + - "AIUI keeps its standalone mode and its own fast dev loop — none of this requires a node to work on the UI (D-17)" + artifacts: + - path: "scripts/build-aiui.sh" + provides: "The one way AIUI is built for a node: base-path enforced, commit pinned, output verified" + contains: "VITE_BASE_PATH" + - path: "scripts/verify-aiui-deploy.sh" + provides: "Post-deploy live-asset fetch check resolved via sw.js" + contains: "sw.js" + - path: "scripts/aiui.pin" + provides: "The AIUI commit + branch this repo ships" + key_links: + - from: "scripts/deploy-to-target.sh" + to: "scripts/build-aiui.sh" + via: "the deploy path calls the build script instead of inlining a pnpm build with a remembered env var" + pattern: "build-aiui\\.sh" + - from: "image-recipe/configs/nginx-archipelago.conf" + to: "neode-ui/src/views/Chat.vue" + via: "a /aiui/-scoped Content-Security-Policy connect-src that the iframe document cannot widen" + pattern: "Content-Security-Policy" +--- + + +Two things that are currently held together by memory rather than by machinery. + +**Delivery (AIUI-05, D-15).** AIUI is a `*-ui` app outside the signed catalog; it reaches nodes +on the frontend rsync, which is how the `/assets` 404 happened. D-15 keeps the rsync path +because it is the one that works, but makes it deliberate: AIUI's commit pinned in this repo, +`VITE_BASE_PATH=/aiui/` enforced by the build script rather than remembered, and a post-deploy +check that **fetches a live asset** instead of trusting a directory listing. Today +`deploy-to-target.sh` inlines the base path at line 716 and `setup-aiui-server.sh` documents it +in a comment — both are the "remembered" form D-15 rejects. Making AIUI a signed-catalog app was +considered and rejected for this phase. + +**The sandbox (AIUI-04, RESEARCH Open Question 2).** Verified: the AIUI iframe in `Chat.vue` +has no `sandbox` attribute, is served same-origin under `/aiui/`, and the site CSP does not +restrict same-origin fetches. So "AIUI never gets an RPC session" is a **code-discipline +convention today, not an enforced boundary** — AIUI's own JavaScript, running in the operator's +authenticated session, is not browser-prevented from calling `/rpc/v1` directly. D-11's whole +premise assumes the postMessage channel is the only channel. This plan makes that true, and the +plan does not claim a property it does not implement. + +**The mechanism, decided (Open Question 2):** a `/aiui/`-scoped `Content-Security-Policy` whose +`connect-src` permits only the AIUI path prefix, plus the G-B3 rate-limit/anomaly counter as the +compensating control. The `sandbox` attribute is **rejected** for this phase: AIUI needs +`allow-scripts`, and `allow-scripts` together with `allow-same-origin` is the well-known escape +pattern, while dropping `allow-same-origin` moves AIUI to an opaque origin and breaks its +storage, its cookies and its origin-checked bridge — a change of a different size than this +phase budgeted. That rejection is recorded here rather than left implicit. + +Output: `scripts/build-aiui.sh`, `scripts/verify-aiui-deploy.sh`, `scripts/aiui.pin`, a +`/aiui/`-scoped CSP, and the deploy path rewired to use them. + + + +**FLAGGED — unresolved edge probe, AIUI-04, category `unclassified`.** Not auto-resolved and not +auto-backstopped. Surfaced for a human read: AIUI-04's requirement text ("sandboxed by +construction, permissioned by the user") does not itself say what "by construction" must mean — +browser-enforced, or enforced by the node regardless of what the browser does. This plan reads +it as browser-enforced-where-possible plus node-side compensating controls, and says so. If the +intent was a hard origin split (serving AIUI from a different origin entirely), that is a larger +change than this phase scoped and should be raised now rather than at seal time. + +**FLAGGED — unresolved edge probe, AIUI-05, category `unclassified`.** Not auto-resolved and not +auto-backstopped. Surfaced for a human read: the requirement says AIUI needs "a delivery path an +operator can actually receive updates through", but does not say whether that means the OTA +update path specifically (so an existing node self-updates AIUI), or only that a maintainer +deploy is reliable. This plan delivers the second and makes the first *checkable*; if the first +is required, it needs an `update.rs` change this phase has not scoped. + + + +Symbols created by **this plan**: +- New file `scripts/build-aiui.sh`: `require_base_path`, `pin_commit`, `verify_dist` +- New file `scripts/verify-aiui-deploy.sh`: `resolve_live_chunks`, `fetch_and_grep` +- New file `scripts/aiui.pin` (data: branch + commit SHA) +- `image-recipe/configs/nginx-archipelago.conf`: a `Content-Security-Policy` header on the + `location /aiui/` blocks (both server blocks) +- `neode-ui/src/views/Chat.vue`: a `referrerpolicy` attribute and an explanatory comment on the + iframe recording why `sandbox` is absent +- `scripts/deploy-to-target.sh`: call sites for the two new scripts, replacing the inline build + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@CLAUDE.md +@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.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-02-SUMMARY.md + + + + + + Task 1: Make the sandbox an enforced boundary, and say exactly what it enforces + image-recipe/configs/nginx-archipelago.conf, neode-ui/src/views/Chat.vue + +- `image-recipe/configs/nginx-archipelago.conf` lines 36-48 and 955-962 — **both** `location /aiui/` blocks, and the existing site-wide CSP wherever it is set. A change to one block only leaves the boundary open on whichever block serves the request. +- `neode-ui/src/views/Chat.vue` lines 33-42 — the iframe element: `:src="aiuiUrl"`, `allow="microphone"`, no `sandbox`. +- `.planning/phases/13-.../13-RESEARCH.md` Pitfall 2 ("Assuming the iframe boundary is a hard sandbox") in full, and Open Question 2. +- `.planning/phases/13-.../13-AI-SPEC.md` §6 "Residual risks" — the first row is exactly this, and names G-B3 as the compensating control. +- `/home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts` — what AIUI actually needs to reach at runtime when embedded, so the policy does not break it. + + +Add a `Content-Security-Policy` response header to **both** `location /aiui/` blocks. Its `connect-src` directive permits `'self'`-equivalent access only under the AIUI path prefix, built from nginx's `$scheme` and `$host` variables so it stays correct across http/https, LAN IP, hostname, Tailscale and onion access. Include `blob:` and `data:` where AIUI's runtime needs them, keep `script-src`/`style-src`/`img-src`/`font-src`/`media-src` permissive enough that the existing bundle still runs, and set `frame-ancestors` to the node's own origin so the AIUI document cannot itself be framed by a third party. The load-bearing directive is `connect-src`: it must not include a source expression that resolves to `/rpc/v1`. + +Add a comment above the header stating in one sentence what the policy does and does not +guarantee — that it prevents AIUI's own JavaScript from issuing a same-origin fetch to the RPC +surface, and that it is *not* an origin split. The previous comment in this file +("no session gate needed") is the reasoning error that produced 13-02's exposure; do not leave a +comment here that could be read the same optimistic way. + +In `Chat.vue`, do **not** add a `sandbox` attribute. Add `referrerpolicy="no-referrer"` to the +iframe (so a media URL or a page path never leaks upstream through a Referer header) and a +comment above the element recording, in three lines: that `sandbox` was considered and rejected +for this phase; that `allow-scripts` + `allow-same-origin` together is a known escape while +dropping `allow-same-origin` breaks AIUI's storage and its origin-checked bridge; and that the +enforced boundary is the `/aiui/`-scoped CSP plus the node-side rate limit, with the residual +risk named in `13-AI-SPEC.md` §6. + +**Do not claim more than this implements.** If any acceptance check below fails on device, the +correct outcome is to record the residual risk explicitly rather than to relax the check. + + + grep -c 'Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf | grep -qvx 0 + cd neode-ui && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts && npx vue-tsc --noEmit + grep -c 'no session gate needed' image-recipe/configs/nginx-archipelago.conf | grep -qx 0 + + +- `grep -c 'Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf` returns 2 — one per server block +- The CSP's `connect-src` value contains the AIUI path prefix and does not contain a bare `'self'` — verify by reading the directive +- `grep -q 'referrerpolicy' neode-ui/src/views/Chat.vue` +- `grep -ci 'sandbox=' neode-ui/src/views/Chat.vue` returns 0, and the comment explaining why is present +- `grep -c 'no session gate needed' image-recipe/configs/nginx-archipelago.conf` returns 0 +- `cd neode-ui && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts` exits 0 +- On a deployed node, `fetch('/rpc/v1', {method:'POST'})` executed from the AIUI frame's console is blocked by CSP and logs a violation; the same fetch from the top-level neode-ui console succeeds (recorded in Task 3) + + This is the enforcement mechanism AIUI-04's "sandboxed by construction" claim rests on. A CSP header is a config change and reverting is trivial, but the *claim* it supports is load-bearing for D-11's threat model — weakening it later silently invalidates the phase's security story rather than just its config. Flagged, not gated. + AIUI's document carries a policy that browser-prevents a direct RPC fetch, both nginx server blocks carry it, and the iframe records why `sandbox` is absent rather than implying it is present. + + + + Task 2: One way to build AIUI, and it refuses to build it wrong + scripts/build-aiui.sh, scripts/aiui.pin, scripts/deploy-to-target.sh + +- `scripts/deploy-to-target.sh` lines 703-735 — the current AIUI build and rsync section, including the inline `VITE_BASE_PATH=/aiui/ pnpm build` at 716 and the `demo/aiui/` fallback at 721-723. Note that 13-02 already removed the proxy machinery from this file; read the current state, not the pre-13-02 state. +- `scripts/setup-aiui-server.sh` lines 17 and 47 — the base-path requirement stated as a comment, which is the "remembered" form D-15 rejects. +- `CLAUDE.md` — "Frontend: `neode-ui/` → `npm run build` outputs to `web/dist/neode-ui/`. **Grep the built bundle for new strings before shipping** — the build can silently no-op." The same rule applies to AIUI's dist and is what this script automates. +- `/home/archipelago/Projects/AIUI/packages/app/package.json` — the real scripts: `build` is `vue-tsc --noEmit && vite build`; the workspace runs under `pnpm`/`turbo`. + + +Create `scripts/build-aiui.sh`, the single supported way to build AIUI for a node. + +`require_base_path` exits non-zero with a plain-language message when `VITE_BASE_PATH` is unset or is not exactly the AIUI mount path. The script sets it itself for the normal case; the check exists so an operator overriding it with a wrong value fails loudly instead of shipping a black page. D-15's point is that the requirement is enforced, not documented. + +`pin_commit` reads `scripts/aiui.pin` (a two-line file: branch, then commit SHA), checks out that commit in the AIUI working tree, and refuses to proceed if the tree is dirty — a build from an uncommitted AIUI tree cannot be reproduced or attributed. Add a `--update-pin` flag that rewrites the pin from the AIUI tree's current HEAD, so bumping the pin is a deliberate, committed act in this repo. Create `scripts/aiui.pin` with AIUI's `development` branch and its current HEAD. + +The build runs AIUI's real command (`vue-tsc --noEmit && vite build`) so a type error fails the build rather than producing a stale `dist`. + +`verify_dist` then asserts, before anything is copied anywhere: `dist/index.html` exists; every `