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

349 lines
32 KiB
Markdown
Raw Normal View History

2026-08-12 10:55:50 +00:00
---
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"
---
<objective>
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.
</objective>
<assumption_delta_decision>
**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.
</assumption_delta_decision>
<flagged_assumptions>
None in this plan.
**Edge-probe accounting for AIUI-01.** Its probe resolved `covered` and produced two findings,
both here: the pending-confirmation lifecycle truth tagged `(edge: AIUI-01 concurrency)` above,
and the two-tab nonce finding carried as a `verification: backstop` scalar rather than a plain
truth. The four probes that returned `unclassified` belong to other requirements and are
surfaced where those requirements live — AIUI-02 in 13-05, AIUI-04 and AIUI-05 in 13-09, AIUI-06
in 13-15. Six requirements probed, two `covered`, four `unclassified`, nothing dropped; the full
reconciliation with its counts is in `13-VALIDATION.md` § Edge-Probe Reconciliation.
</flagged_assumptions>
<artifacts_this_phase_produces>
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)
</artifacts_this_phase_produces>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/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
</context>
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "how much space is left" — the Rust spine, one tool, one backend</name>
<files>
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
</files>
<read_first>
- `.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<serde_json::Value>`) 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.
</read_first>
<action>
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 `<assumption_delta_decision>` block above: `pub enum CallerScope { Mesh { peer_id: String }, LocalOperator { session_id: String } }` with `fn granted_categories(&self) -> BTreeSet<PermissionCategory>`, 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<RpcHandler> }`, and the public `pub async fn chat(ctx, user_text) -> Result<String>` 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::<SystemDiskStatusArgs>` 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<BackendTurn> }` and `pub enum BackendTurn { Text(String), ToolCalls(Vec<ToolCall>) }`, 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<BackendTurn>`, 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<Value>` 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`).
</action>
<verify>
<automated>cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -20</automated>
<automated>cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant_methods_require_session</automated>
<automated>grep -c 'schemars' core/archipelago/Cargo.toml | grep -qx 0</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<reversibility rating="costly">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.</reversibility>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: neode-ui carries chat over the existing origin-checked bridge</name>
<files>neode-ui/src/types/aiui-protocol.ts, neode-ui/src/services/contextBroker.ts</files>
<read_first>
- `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.
</read_first>
<action>
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.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npx vitest run src/services/__tests__/contextBroker.test.ts</automated>
<automated>cd neode-ui &amp;&amp; npx vue-tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: AIUI delegates the loop to the node when embedded, keeps its own when not</name>
<files>/home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts, /home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts</files>
<read_first>
- `/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.
</read_first>
<action>
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.
</action>
<verify>
<automated>cd /home/archipelago/Projects/AIUI/packages/app &amp;&amp; npx vitest run</automated>
<automated>cd /home/archipelago/Projects/AIUI/packages/app &amp;&amp; npx vue-tsc --noEmit</automated>
<automated>cd /home/archipelago/Projects/AIUI &amp;&amp; git log --oneline -1 &amp;&amp; git status --porcelain | grep -c . | grep -qx 0</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
- `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
</verification>
<success_criteria>
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.
</success_criteria>
<output>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md` when done
</output>