--- 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 `