Files

300 lines
23 KiB
Markdown
Raw Permalink Normal View History

2026-08-12 10:55:50 +00:00
---
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"
---
<objective>
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.
</objective>
<flagged_assumptions>
**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.
</flagged_assumptions>
<artifacts_this_phase_produces>
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
</artifacts_this_phase_produces>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@CLAUDE.md
@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-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
</context>
<tasks>
<task type="auto">
<name>Task 1: Make the sandbox an enforced boundary, and say exactly what it enforces</name>
<files>image-recipe/configs/nginx-archipelago.conf, neode-ui/src/views/Chat.vue</files>
<read_first>
- `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.
</read_first>
<action>
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.
</action>
<verify>
<automated>grep -c 'Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf | grep -qvx 0</automated>
<automated>cd neode-ui &amp;&amp; npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts &amp;&amp; npx vue-tsc --noEmit</automated>
<automated>grep -c 'no session gate needed' image-recipe/configs/nginx-archipelago.conf | grep -qx 0</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<reversibility rating="costly">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.</reversibility>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: One way to build AIUI, and it refuses to build it wrong</name>
<files>scripts/build-aiui.sh, scripts/aiui.pin, scripts/deploy-to-target.sh</files>
<read_first>
- `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`.
</read_first>
<action>
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 `<script>`/`<link>` href in it begins with the AIUI mount path (a hand-built bundle with the wrong base path gives a black page, and the router base is what actually breaks, not the assets); the built asset filenames differ from the previous build when the source changed; and the pinned commit SHA appears somewhere in the emitted output so a deployed node can be attributed. Emit the SHA as a build-time define or a small `dist/BUILD-INFO` file, whichever is simpler in this build.
Rewire `scripts/deploy-to-target.sh` to call `scripts/build-aiui.sh` instead of building inline, and to call `scripts/verify-aiui-deploy.sh` after the copy. Keep the existing `demo/aiui/` fallback path but make it print a loud warning naming that it is shipping a checked-in dist rather than a fresh build, so that path stops being silent.
Also update `scripts/setup-aiui-server.sh`'s comments to point at `build-aiui.sh` rather than restating the env var.
</action>
<verify>
<automated>bash -n scripts/build-aiui.sh &amp;&amp; bash -n scripts/deploy-to-target.sh</automated>
<automated>VITE_BASE_PATH=/wrong/ bash scripts/build-aiui.sh; test $? -ne 0</automated>
<automated>bash scripts/build-aiui.sh &amp;&amp; grep -c 'src="/aiui/' /home/archipelago/Projects/AIUI/packages/app/dist/index.html | grep -qvx 0</automated>
</verify>
<acceptance_criteria>
- `bash -n scripts/build-aiui.sh` exits 0 and the file is executable
- `VITE_BASE_PATH=/wrong/ bash scripts/build-aiui.sh` exits non-zero with a message naming the required value
- `scripts/aiui.pin` exists and contains the branch name and a 40-character commit SHA
- Running the script with a dirty AIUI tree exits non-zero
- After a successful run, every `src=`/`href=` in `/home/archipelago/Projects/AIUI/packages/app/dist/index.html` starts with the AIUI mount path — `grep -cE '(src|href)="/(?!aiui/)' dist/index.html` finds no non-AIUI-prefixed local asset
- The pinned SHA is discoverable in the built output (`grep -rq "<pinned-sha>" dist/`)
- `grep -c 'build-aiui.sh' scripts/deploy-to-target.sh` returns ≥ 1 and `grep -c 'VITE_BASE_PATH=/aiui/ pnpm build' scripts/deploy-to-target.sh` returns 0 — the inline build is gone
</acceptance_criteria>
<done>A wrong base path, a dirty AIUI tree, or a type error each fail the build loudly; a successful build is attributable to a pinned commit recorded in this repo.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Fetch the bytes off a real node — the directory listing lies</name>
<files>scripts/verify-aiui-deploy.sh</files>
<what-built>
`scripts/verify-aiui-deploy.sh <node-host>` — a post-deploy check that resolves the *live* asset
chunks by fetching the service worker manifest over HTTP, then fetches each live chunk and greps
the **fetched bytes** for a string the new build introduced.
This exists because the node's `assets/` directory is a never-pruned graveyard: a disk grep over
it reports "deployed" before the deploy, because a dead chunk from an old build still contains
the string. The only honest check fetches what the browser would actually load.
</what-built>
<how-to-verify>
1. Write `scripts/verify-aiui-deploy.sh` following `tests/production-quality/lnd-cors-test.sh`'s
shape. It takes a host and an expected marker string, fetches the service worker manifest
over HTTP to resolve live chunk URLs, fetches each, and greps the fetched bytes. It exits
non-zero when the marker is absent from every live chunk. It must **not** ssh in and grep
`/opt/archipelago/web-ui/aiui/assets/`.
2. Build with `bash scripts/build-aiui.sh` and deploy to archi-dev-box per `CLAUDE.md` (dev pair
before any OTA).
3. Run `bash scripts/verify-aiui-deploy.sh <node> "<a string only the new build contains>"`.
Expect exit 0.
4. Negative control: run it again with a string that does not exist in any build. Expect a
non-zero exit. A check that always passes is not a check.
5. Load neode-ui's Chat view on that node in a desktop browser. Confirm AIUI renders — not a
black page. A black page means the router base broke; confirm by fetching the node's
`/aiui/index.html` and reading its asset hrefs.
6. Open the browser devtools console **inside the AIUI frame** and attempt a POST to `/rpc/v1`.
Confirm the browser blocks it with a CSP violation. Then run the same fetch from the
top-level neode-ui frame and confirm it succeeds — that difference is the boundary this plan
claims, and step 6 is the only place it is actually observed.
7. Exercise the embedded chat and one content grid to confirm the CSP did not break AIUI's own
runtime.
</how-to-verify>
<acceptance_criteria>
- `bash scripts/verify-aiui-deploy.sh <node> "<new-build marker>"` exits 0
- The same script with a non-existent marker exits non-zero (negative control recorded)
- `grep -c 'sw.js' scripts/verify-aiui-deploy.sh` returns ≥ 1 and `grep -ci 'ssh' scripts/verify-aiui-deploy.sh` returns 0 — the check is an HTTP fetch, not a disk grep
- `curl -s http://<node>/aiui/index.html | grep -c 'src="/aiui/'` returns ≥ 1
- AIUI renders in the embedded iframe on the node — not a black page (screenshot in the summary)
- A `fetch('/rpc/v1', {method:'POST'})` from inside the AIUI frame is blocked with a CSP violation; the same call from the top-level frame succeeds (both console outputs recorded in the summary)
- Embedded chat still answers and one content grid still populates after the CSP landed
</acceptance_criteria>
<resume-signal>Type "approved" with the two console results from step 6, or describe what the CSP broke.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| AIUI document → `/rpc/v1` | **The boundary this plan enforces.** Same-origin today, so only a policy can stop it |
| maintainer workstation → node filesystem | The rsync deploy path; what lands is what runs |
| AIUI repo → Archy build | A second repository's HEAD becomes part of this repo's shipped artifact |
| node `assets/` → verification | The graveyard that makes a disk grep lie |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-13-54 | Elevation of Privilege | AIUI's JS calling `/rpc/v1` with the ambient session cookie | high | mitigate | `/aiui/`-scoped CSP `connect-src` excluding the RPC path; verified in the browser, per-frame, in Task 3 step 6. `sandbox` explicitly rejected with reasons recorded |
| T-13-55 | Elevation of Privilege | Residual: a browser that ignores or partially enforces CSP | medium | accept | Named residual (AI-SPEC §6 row 1). Compensating control is G-B3's rate limit and anomaly counter on `assistant.chat`, landing in 13-12. Recorded, not silently assumed away |
| T-13-56 | Information Disclosure | Media URL or page path leaking upstream via Referer | medium | mitigate | `referrerpolicy="no-referrer"` on the iframe; complements 13-06's no-credential-in-URL rule |
| T-13-57 | Tampering | A wrong `VITE_BASE_PATH` ships a black page to every node | high | mitigate | `require_base_path` exits non-zero; `verify_dist` asserts every asset href carries the mount path before anything is copied |
| T-13-58 | Tampering | An unattributable AIUI build from a dirty second-repo tree | medium | mitigate | `scripts/aiui.pin` + refuse-on-dirty + the SHA emitted into the built output |
| T-13-59 | Repudiation | A disk grep over the node's asset graveyard reports a deploy that did not happen | high | mitigate | `verify-aiui-deploy.sh` resolves live chunks via the service worker manifest and greps the **fetched** bytes; asserted by the no-ssh grep and by a negative control |
| T-13-60 | Denial of Service | CSP breaks AIUI's runtime and the chat surface goes dark | medium | mitigate | Task 3 steps 5 and 7 exercise chat and a content grid after the policy lands; a break is recorded as a residual rather than papered over by relaxing the check |
| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added. The build script runs AIUI's existing `pnpm`/`vite` toolchain and installs nothing new. No install task, so no legitimacy checkpoint required |
</threat_model>
<verification>
- `bash -n scripts/build-aiui.sh && bash -n scripts/verify-aiui-deploy.sh && bash -n scripts/deploy-to-target.sh`
- `VITE_BASE_PATH=/wrong/ bash scripts/build-aiui.sh` exits non-zero
- `grep -c 'Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf` == 2
- On archi-dev-box: `verify-aiui-deploy.sh` passes with the real marker and fails with a fake one; AIUI renders; an RPC fetch from inside the frame is CSP-blocked while the same call from the top-level frame succeeds
</verification>
<success_criteria>
AIUI cannot be built wrong silently, cannot be deployed unverifiably, and cannot reach the RPC
surface from inside its own frame — and where the boundary is not absolute, the plan says so in
the config comment, in the iframe comment and in the threat register rather than claiming a
property it did not implement.
</success_criteria>
<output>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-SUMMARY.md` when done
</output>