Files

23 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-aiui-functional-conversational-node-control-and-content-surf 09 execute 4
13-02
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
false
AIUI-04
AIUI-05
truths artifacts key_links
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)
path provides contains
scripts/build-aiui.sh The one way AIUI is built for a node: base-path enforced, commit pinned, output verified VITE_BASE_PATH
path provides contains
scripts/verify-aiui-deploy.sh Post-deploy live-asset fetch check resolved via sw.js sw.js
path provides
scripts/aiui.pin The AIUI commit + branch this repo ships
from to via pattern
scripts/deploy-to-target.sh scripts/build-aiui.sh the deploy path calls the build script instead of inlining a pnpm build with a remembered env var build-aiui.sh
from to via pattern
image-recipe/configs/nginx-archipelago.conf neode-ui/src/views/Chat.vue a /aiui/-scoped Content-Security-Policy connect-src that the iframe document cannot widen 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_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>

@.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 <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> 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 <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. bash -n scripts/build-aiui.sh && bash -n scripts/deploy-to-target.sh VITE_BASE_PATH=/wrong/ bash scripts/build-aiui.sh; test $? -ne 0 bash scripts/build-aiui.sh && grep -c 'src="/aiui/' /home/archipelago/Projects/AIUI/packages/app/dist/index.html | grep -qvx 0 <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> 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.
Task 3: Fetch the bytes off a real node — the directory listing lies scripts/verify-aiui-deploy.sh `scripts/verify-aiui-deploy.sh ` — 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.

  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. <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> Type "approved" with the two console results from step 6, or describe what the CSP broke.

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

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

Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-SUMMARY.md` when done