Files
archy/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-PLAN.md
T
archipelagoandClaude Opus 5 9ac4770773 docs(13-09): retarget plan to in-repo aiui/ (D-19)
Substantive rework, not a path swap:

- Retires D-15's pin-and-verify model. scripts/aiui.pin, pin_commit and
  --update-pin are dropped outright — there is no second repository left
  to pin, so build-aiui.sh now attributes a build to this repo's own
  `git rev-parse HEAD` instead.
- Re-derives the build: aiui/ is an in-repo pnpm/turbo workspace with its
  own package.json and lockfile but no committed node_modules, so
  build-aiui.sh must `pnpm install --frozen-lockfile` before it can build
  (new requirement; the old model assumed a developer's separate AIUI
  clone was already installed).
- Retargets deploy-to-target.sh (both its primary and --both/secondary
  AIUI sections) and setup-aiui-server.sh off the stale
  $PROJECT_DIR/../AIUI/packages/app/dist path, which still resolves on
  disk to a stale pre-migration clone and would otherwise silently ship
  old bytes instead of failing loudly.
- Carries the /aiui/-scoped CSP sandbox work (AIUI-04) through unchanged
  per D-19, and fixes two acceptance-criteria drifts discovered while
  verifying the plan against deploy-to-target.sh's post-13-02 state and
  nginx-archipelago.conf's post-pentest-hardening state (CSP header count
  and the "no session gate needed" grep), neither of which is a D-19
  effect.
- Folds in a real defect found while doing this work: the 2026-07-31
  same-host deploy guard only catches path containment, not sibling
  directories — the exact shape this worktree's own topology exhibits
  (archy-phase13 as a sibling of the main checkout, reachable over
  loopback SSH). New Task 3 widens it to refuse any same-host
  source/destination mismatch, extracted into a testable
  assert_safe_same_host_deploy in scripts/lib/common.sh and pinned by
  tests/production-quality/deploy-guard-same-host.sh. The checkpoint task
  is renumbered Task 3 -> Task 4 accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:07:07 -04:00

39 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/deploy-to-target.sh
scripts/setup-aiui-server.sh
scripts/lib/common.sh
tests/production-quality/deploy-guard-same-host.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 bytes a node runs are attributable to this repo's own commit — no separate pin file and no second checkout to go stale, because D-19 made AIUI's commit this repo's own commit (D-15's delivery half survives; its pinning half is retired)
`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)
A same-host deploy whose resolved source and destination differ is refused before rsync --delete can run, whether the mismatch is containment or sibling directories — the 2026-07-31 data-loss guard now covers the shape it originally missed
path provides contains
scripts/build-aiui.sh The one way AIUI is built for a node: base-path enforced, deps installed from the committed lockfile, output verified and attributed to this repo's own commit 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 contains
scripts/lib/common.sh assert_safe_same_host_deploy — the shared, unit-testable same-host guard deploy-to-target.sh calls assert_safe_same_host_deploy
path provides contains
tests/production-quality/deploy-guard-same-host.sh Regression pin for the sibling-directory data-loss gap: identical/contained/containing/sibling/unrelated path fixtures against the guard function assert_safe_same_host_deploy
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
scripts/deploy-to-target.sh scripts/lib/common.sh the same-host guard is a shared, testable function instead of two inline containment-only case blocks assert_safe_same_host_deploy
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
Three things: two carried over unchanged (delivery, sandbox), and one folded in as a directly relevant defect surfaced while re-deriving the delivery half for D-19.

Delivery (AIUI-05, D-15 as amended by D-19). 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. D-19 (2026-08-03) changed what "deliberate" means for it: AIUI is no longer a second repository at git.tx1138.com/lfg2025/AIUI — it was migrated in-repo to aiui/ via git subtree, full history intact. D-15's pinning half is retired: scripts/aiui.pin made sense when "which AIUI is on this node" meant tracking a second repository's HEAD; now the answer is simply this repo's own commit, so there is no separate pin file, no --update-pin flag, and no dirty-second-tree refusal. D-15's delivery half still stands and is what this plan still builds: 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. Both deploy-to-target.sh and scripts/setup-aiui-server.sh still point at the old $PROJECT_DIR/../AIUI/packages/app/dist sibling-checkout path — a directory that, on this machine, happens to still physically exist at its last pre-migration commit, which would let the old code silently ship stale bytes instead of failing. Both get retargeted to the in-repo $PROJECT_DIR/aiui/packages/app/dist and rewired to call scripts/build-aiui.sh — AIUI builds from its own pnpm/turbo workspace under aiui/, which this repo has no root package.json to collide with (D-19; D-17's standalone mode is unaffected). A fresh checkout of this repo has no aiui/node_modules — unlike the old world, where a developer's separate AIUI clone was assumed already pnpm installed as part of their normal AIUI workflow — so build-aiui.sh must install from the committed lockfile before it can build; that is new, not carried over. Making AIUI a signed-catalog app was considered and rejected for this phase: *-ui apps are outside the catalog by design today, and changing that platform rule mid-phase is its own work.

The sandbox (AIUI-04, RESEARCH Open Question 2) — unaffected by the migration (D-19: "a build-time/runtime property, not a repository-location property"). 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.

Folded in: widen the same-host deploy guard — a real gap found while verifying this plan against the file's current state, not a D-19 effect. 13-02 already rewrote large parts of deploy-to-target.sh (see its SUMMARY); reading its current state for this retarget surfaced that the 2026-07-31 data-loss guard only refuses a same-host deploy when one resolved path contains the other. A sibling directory on the same host — for example this very worktree, archy-phase13, deploying onto $TARGET_DIR's resolved symlink target (/home/archipelago/Projects/archy, the main checkout) — is neither contained by nor containing of the destination, so the existing guard lets it through, and rsync --delete would mirror the sibling onto the main checkout and delete everything the sibling lacks: the same incident class the guard exists to prevent, through the one shape it does not cover. This plan widens it from containment-only to any resolved-path mismatch.

Output: scripts/build-aiui.sh, scripts/verify-aiui-deploy.sh, a /aiui/-scoped CSP, the deploy path (deploy-to-target.sh and setup-aiui-server.sh) retargeted to the in-repo aiui/ location, and the same-host deploy guard widened and pinned by a regression test.

<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, verify_dist (no pin_commit, no --update-pin — D-19 retires the pin; verify_dist now attributes the build to this repo's own git rev-parse HEAD instead of a second repo's pinned SHA)
  • New file scripts/verify-aiui-deploy.sh: resolve_live_chunks, fetch_and_grep
  • New function in scripts/lib/common.sh: assert_safe_same_host_deploy(local_src, remote_dst) — pure, no SSH inside it, callable directly from a test with fixed inputs
  • New file tests/production-quality/deploy-guard-same-host.sh: the fixture matrix over assert_safe_same_host_deploy (identical / contained / containing / sibling / unrelated)
  • 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 build/verify scripts (replacing the inline build, in both the primary AIUI deploy section and the --both-secondary section), retargeted from $PROJECT_DIR/../AIUI to $PROJECT_DIR/aiui, and its same-host guard now calling assert_safe_same_host_deploy instead of two inline containment-only case blocks
  • scripts/setup-aiui-server.sh: AIUI_DIST retargeted to the in-repo path; calls scripts/build-aiui.sh when the dist is missing or stale instead of printing a manual command </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` — **both** `location /aiui/` blocks (currently ~line 38 in the HTTP server block, ~line 959 in the HTTPS block; search `location /aiui/ {` rather than trusting these numbers — they have already drifted once, from 36-48/955-962 to here, because 13-02 rewrote large parts of this file). A change to one block only leaves the boundary open on whichever block serves the request. - The **site-wide** `add_header Content-Security-Policy "default-src 'self'; ... connect-src 'self' ws: wss: http://$host:* https:; ..."` already present in both server blocks (from the 22-item pentest hardening pass, commit `6656d2f1` — this predates this plan's own creation commit and is not a D-19/migration effect, but it means the file already carries 2 `add_header Content-Security-Policy` lines before this task adds its own). - `neode-ui/src/views/Chat.vue` — the iframe element around lines 33-42: `: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. - `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. test "$(grep -c 'add_header Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf)" -ge 4 cd neode-ui && npx vitest run src/views/tests/chatAiuiEmbed.test.ts && npx vue-tsc --noEmit test "$(grep -c 'no session gate needed' image-recipe/configs/nginx-archipelago.conf)" -eq 1 <acceptance_criteria>

  • grep -c 'add_header Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf returns 4 — the 2 pre-existing site-wide headers (commit 6656d2f1, unrelated to this plan) plus the 2 new /aiui/-scoped ones this task adds, one per server block
  • The new /aiui/-scoped 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 exactly 1 — the pre-existing 13-02 citation of the old, discredited comment (already correctly framed as a past reasoning error), not a second bare instance introduced by this task's own new comment
  • 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 4) </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 from its new in-repo home, and it refuses to build it wrong scripts/build-aiui.sh, scripts/deploy-to-target.sh, scripts/setup-aiui-server.sh - `scripts/deploy-to-target.sh` — search for `Build and deploy AIUI` for the primary section (currently ~line 708-737: `AIUI_DIR="$PROJECT_DIR/../AIUI"`, the inline `VITE_BASE_PATH=/aiui/ pnpm build`, and the `demo/aiui/` fallback), and search for `Deploy AIUI — prefer a sibling dist` for the secondary/`--both` section (currently ~line 369-387: `AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"` with a fallback to streaming from .228). **Both** reference the old out-of-repo sibling checkout and both need retargeting; 13-02 already removed the proxy machinery from this file (see its SUMMARY) — read the file's current state, not the pre-13-02 or pre-migration version described in either plan. - `scripts/setup-aiui-server.sh` in full (87 lines) — its header comment already documents 13-02's changes; `AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"` and the "Build it first: cd ../AIUI/packages/app && ..." error message both need retargeting to the in-repo location. - `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. - `aiui/packages/app/package.json` — the real scripts: `build` is `vue-tsc --noEmit && vite build`; confirmed unchanged by the migration (subtree import preserved the file byte-for-byte). - `aiui/pnpm-workspace.yaml`, `aiui/package.json`, `aiui/pnpm-lock.yaml` — this is a real pnpm/turbo workspace with its own lockfile, in-repo, and **no `node_modules` is committed** (confirmed absent in a fresh checkout of this worktree). Unlike the old world, where a developer's separate AIUI clone was assumed already `pnpm install`ed, `build-aiui.sh` must install from this lockfile before it can build — this is new, not carried over from the pre-migration plan. Create `scripts/build-aiui.sh`, the single supported way to build AIUI for a node, operating on the in-repo `aiui/` directory (D-19 — there is no second repository to check out or pin).

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 — that half of D-15 survives the migration unchanged.

Before building, run pnpm install --frozen-lockfile at the aiui/ workspace root (fast no-op when already satisfied; hard failure, not a silent pnpm install fallback, when the lockfile and package.json disagree — that disagreement is exactly the kind of unreviewed drift D-15 exists to catch, and it should fail the build rather than quietly resolve it). Do not add pin_commit, --update-pin, or any dirty-tree refusal — D-19 retires that mechanism outright: there is no second working tree to check for dirtiness, and this repo's own ordinary commit discipline (CLAUDE.md) is what keeps its history honest, not a second-repo-specific check.

The build runs AIUI's real command (vue-tsc --noEmit && vite build, invoked from aiui/packages/app) 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 this repo's own current commit (git -C "$PROJECT_DIR" rev-parse HEAD) appears somewhere in the emitted output, so a deployed node is attributable to a commit of this repo — not to a second repo's pinned SHA, since D-19 made those the same thing. Emit the SHA as a build-time define or a small dist/BUILD-INFO file, whichever is simpler in this build.

Rewire both AIUI sections of scripts/deploy-to-target.sh. In the primary section, replace the inline AIUI_DIR/pnpm build with a call to scripts/build-aiui.sh, retarget AIUI_DIST to $PROJECT_DIR/aiui/packages/app/dist, and 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. In the secondary/--both section, retarget AIUI_DIST to $PROJECT_DIR/aiui/packages/app/dist and update its explanatory comment — it no longer talks about "a machine that doesn't have an ../AIUI checkout" (impossible now; aiui/ is part of this repo), just "a machine that hasn't run scripts/build-aiui.sh yet" — and leave its check-then-fall-back-to-.228 behavior otherwise unchanged; this task retargets the path, it does not change that section's design.

Update scripts/setup-aiui-server.sh: retarget AIUI_DIST to $PROJECT_DIR/aiui/packages/app/dist, and when it's missing or older than AIUI's source, call scripts/build-aiui.sh instead of printing a manual cd ../AIUI/packages/app && ... command and exiting — D-15's "enforced, not remembered" applies here too, and a script that only prints instructions is the remembered form. Update the header comment's "Prerequisites" line to match. bash -n scripts/build-aiui.sh && bash -n scripts/deploy-to-target.sh && bash -n scripts/setup-aiui-server.sh VITE_BASE_PATH=/wrong/ bash scripts/build-aiui.sh; test $? -ne 0 bash scripts/build-aiui.sh && grep -c 'src="/aiui/' 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
  • After a successful run, every src=/href= in 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
  • This repo's own current commit SHA is discoverable in the built output (grep -rq "$(git rev-parse HEAD)" aiui/packages/app/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
  • grep -c '\.\./AIUI' scripts/deploy-to-target.sh scripts/setup-aiui-server.sh returns 0 across both files — no reference to the retired out-of-repo checkout survives in either script this task touches
  • grep -c 'build-aiui.sh' scripts/setup-aiui-server.sh returns ≥ 1 — it calls the one true build script instead of printing a manual command
  • grep -cE 'aiui\.pin|pin_commit|update-pin' scripts/build-aiui.sh returns 0 — the retired pinning mechanism is not present in any form </acceptance_criteria> A wrong base path or a type error fails the build loudly; a successful build is attributable to this repo's own commit (no separate pin file, no second-repo dirty check — D-19 retired both); deploy-to-target.sh (both its primary and secondary AIUI sections) and setup-aiui-server.sh both build and deploy AIUI from its in-repo location.
Task 3: Widen the same-host deploy guard from containment-only to any resolved-path mismatch scripts/lib/common.sh, scripts/deploy-to-target.sh, tests/production-quality/deploy-guard-same-host.sh - Identical resolved source and destination on the same host: allowed (exit 0) — the normal in-place deploy from the main checkout onto its own symlinked destination. - Source path is inside (a subdirectory of) the destination path: refused (non-zero) — the original 2026-07-31 containment case. - Destination path is inside the source path: refused (non-zero) — the mirror-image containment case. - Sibling directories that share a parent but neither contains the other — for example `/home/archipelago/Projects/archy-phase13` as source and `/home/archipelago/Projects/archy` (this worktree's own resolved `$TARGET_DIR` symlink target) as destination — are refused (non-zero). This is the gap: the pre-existing guard's two `case` patterns match only containment and let this shape through. - Two completely unrelated same-host paths with no shared parent at all are refused (non-zero) — same-host plus any mismatch is refused, not just the two containment shapes. - A refused case prints a message naming both resolved paths and pointing at the 2026-07-31 incident, so a future operator understands why rather than reflexively retrying with a force flag. - `scripts/deploy-to-target.sh` — search for `GUARD (2026-07-31 incident)` for the current guard: it computes `_LOCAL_SRC` and, only when the remote machine-id matches the local one, `_REMOTE_DST`, then refuses only when one `case` pattern matches the other path as a prefix. Read the full existing comment; it already documents the 2026-07-31 incident this task closes a gap in. - `scripts/lib/common.sh` in full — the double-source guard (`_ARCHY_COMMON_LOADED`) and the existing function shapes (`ssh_cmd`, `scp_cmd`, the `log_*` helpers), so the new function matches this file's style and naming rather than inventing a new convention. - This very worktree's own topology: `archy-phase13`, whose `$TARGET_DIR` resolves via a symlink to the main checkout at `/home/archipelago/Projects/archy`. The sibling-directory shape this task fixes is not hypothetical — it is this session's own layout, and is exactly what the deploy guard would need to catch if this worktree's `deploy-to-target.sh` were ever run. Add `assert_safe_same_host_deploy(local_src, remote_dst)` to `scripts/lib/common.sh`. It takes two **already-resolved** (`readlink -f`) absolute paths and makes no SSH calls itself — same-host detection stays in `deploy-to-target.sh`, which already does it via `/etc/machine-id`. The function returns 0 when the two paths are equal, and returns non-zero with a message on stderr naming both paths and the 2026-07-31 incident in every other case — it does not special-case containment. This is the actual fix: the old code refused only two specific shapes (source-in-destination, destination-in-source); replacing that with an unconditional "refuse unless equal" is both the widening and a simplification, since any resolved-path mismatch on the same host is the identical `rsync --delete` hazard regardless of shape.

In deploy-to-target.sh, replace the guard block's two case statements with a single call to assert_safe_same_host_deploy "$_LOCAL_SRC" "$_REMOTE_DST" (the script already sources scripts/lib/common.sh near the top), still gated behind the existing same-machine-id check. Update the surrounding comment to say the guard now covers any mismatch, not just containment, and name the sibling-directory case as the reason — do not leave the old two-shape explanation standing next to a function that no longer works that way.

Write tests/production-quality/deploy-guard-same-host.sh, sourcing scripts/lib/common.sh directly (no SSH, no rsync, no real deploy) and asserting all five <behavior> cases against assert_safe_same_host_deploy with literal path strings — including the exact archy-phase13 vs. main-checkout pair as the sibling-directory regression pin, so this specific incident shape cannot silently regress. Follow tests/production-quality/lnd-cors-test.sh's overall shape for structure (this test takes no host argument, since it needs no live node). bash -n scripts/lib/common.sh && bash -n scripts/deploy-to-target.sh bash tests/production-quality/deploy-guard-same-host.sh <acceptance_criteria>

  • grep -q 'assert_safe_same_host_deploy' scripts/lib/common.sh and grep -q 'assert_safe_same_host_deploy' scripts/deploy-to-target.sh
  • grep -cE '"\$_LOCAL_SRC/" in|"\$_REMOTE_DST/" in' scripts/deploy-to-target.sh returns 0 — the two old containment-only case blocks are removed, not left dead alongside the new call
  • bash tests/production-quality/deploy-guard-same-host.sh exits 0, and its output shows all five cases from <behavior> — including the sibling-directory pin — passing
  • Deliberately flipping the sibling-directory fixture's expectation to "allow" and re-running makes the test fail (checked by hand during execution to confirm the test is not vacuously green; not left as a permanent artifact)
  • bash -n scripts/deploy-to-target.sh exits 0 — the refactor did not break the script's syntax </acceptance_criteria> A stricter guard than before; the only behavior change is refusing deploys that were already unsafe. Nothing that was safe before becomes refused now, and nothing unsafe becomes newly allowed. The same-host guard refuses any resolved source/destination mismatch, not only containment; the sibling-directory shape this session's own worktree topology exhibits is pinned by a regression test; the old, narrower logic is removed rather than left alongside the new call.
Task 4: 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
same-host deploy source → deploy destination Any same-host path mismatch is an rsync --delete hazard, not only a containing/contained one — the fold-in this plan widens
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 4 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 medium mitigate D-19 retired the second-repo pin-and-dirty-check mechanism (there is no second working tree to go stale); verify_dist now embeds this repo's own current commit SHA in the built output, so a deployed node is attributable to a commit of this repo directly
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 4 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-61 Tampering A same-host deploy whose source and destination are sibling directories (not containment) is not refused, so rsync --delete mirrors the wrong source onto a real checkout and deletes what it lacks high mitigate assert_safe_same_host_deploy refuses any resolved-path mismatch on the same host, not only containment; pinned by tests/production-quality/deploy-guard-same-host.sh's sibling-directory fixture (this session's own worktree topology)
T-13-SC Tampering npm/pip/cargo installs high mitigate Zero new packages. The build script runs AIUI's existing in-repo pnpm/turbo/vite toolchain, installing only what aiui/pnpm-lock.yaml already pins (--frozen-lockfile, hard failure on drift rather than a silent resolve). No install task adds a package, 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 && bash -n scripts/setup-aiui-server.sh && bash -n scripts/lib/common.sh` - `VITE_BASE_PATH=/wrong/ bash scripts/build-aiui.sh` exits non-zero - `grep -c 'add_header Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf` >= 4 (2 pre-existing site-wide + 2 new `/aiui/`-scoped) - `bash tests/production-quality/deploy-guard-same-host.sh` exits 0, covering the sibling-directory regression - 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. Separately, the same-host deploy guard folded into this plan refuses any resolved-path mismatch on the same host, closing the sibling-directory gap in the 2026-07-31 incident's original fix. </success_criteria>

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