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>
This commit is contained in:
archipelago
2026-08-03 17:07:07 -04:00
co-authored by Claude Opus 5
parent 6989b1d387
commit 9ac4770773
@@ -7,8 +7,10 @@ depends_on: ["13-02"]
files_modified:
- scripts/build-aiui.sh
- scripts/verify-aiui-deploy.sh
- scripts/aiui.pin
- 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
autonomous: false
@@ -17,25 +19,34 @@ 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)"
- "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"
artifacts:
- path: "scripts/build-aiui.sh"
provides: "The one way AIUI is built for a node: base-path enforced, commit pinned, output verified"
provides: "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"
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"
- path: "scripts/lib/common.sh"
provides: "assert_safe_same_host_deploy — the shared, unit-testable same-host guard deploy-to-target.sh calls"
contains: "assert_safe_same_host_deploy"
- path: "tests/production-quality/deploy-guard-same-host.sh"
provides: "Regression pin for the sibling-directory data-loss gap: identical/contained/containing/sibling/unrelated path fixtures against the guard function"
contains: "assert_safe_same_host_deploy"
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: "scripts/deploy-to-target.sh"
to: "scripts/lib/common.sh"
via: "the same-host guard is a shared, testable function instead of two inline containment-only case blocks"
pattern: "assert_safe_same_host_deploy"
- 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"
@@ -43,20 +54,36 @@ must_haves:
---
<objective>
Two things that are currently held together by memory rather than by machinery.
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).** 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.
**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 install`ed 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).** 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
**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
@@ -70,8 +97,21 @@ pattern, while dropping `allow-same-origin` moves AIUI to an opaque origin and b
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.
**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.
</objective>
<flagged_assumptions>
@@ -93,14 +133,24 @@ is required, it needs an `update.rs` change this phase has not scoped.
<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/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 file `scripts/aiui.pin` (data: branch + commit SHA)
- 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 new scripts, replacing the inline build
- `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>
@@ -123,11 +173,20 @@ Symbols created by **this plan**:
<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`.
- `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.
- `/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.
- `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`.
@@ -150,66 +209,169 @@ risk named in `13-AI-SPEC.md` §6.
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>test "$(grep -c 'add_header Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf)" -ge 4</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>
<automated>test "$(grep -c 'no session gate needed' image-recipe/configs/nginx-archipelago.conf)" -eq 1</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 -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 0
- `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 3)
- 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>
<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>
<name>Task 2: One way to build AIUI from its new in-repo home, and it refuses to build it wrong</name>
<files>scripts/build-aiui.sh, scripts/deploy-to-target.sh, scripts/setup-aiui-server.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.
- `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.
- `/home/archipelago/Projects/AIUI/packages/app/package.json` — the real scripts: `build` is `vue-tsc --noEmit && vite build`; the workspace runs under `pnpm`/`turbo`.
- `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.
</read_first>
<action>
Create `scripts/build-aiui.sh`, the single supported way to build AIUI for a node.
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.
`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.
`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.
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`) so a type error fails the build rather than producing a stale `dist`.
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 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.
`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 `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.
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.
Also update `scripts/setup-aiui-server.sh`'s comments to point at `build-aiui.sh` rather than restating the env var.
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.
</action>
<verify>
<automated>bash -n scripts/build-aiui.sh &amp;&amp; bash -n scripts/deploy-to-target.sh</automated>
<automated>bash -n scripts/build-aiui.sh &amp;&amp; bash -n scripts/deploy-to-target.sh &amp;&amp; bash -n scripts/setup-aiui-server.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>
<automated>bash scripts/build-aiui.sh &amp;&amp; grep -c 'src="/aiui/' 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/`)
- 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>
<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>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Widen the same-host deploy guard from containment-only to any resolved-path mismatch</name>
<files>scripts/lib/common.sh, scripts/deploy-to-target.sh, tests/production-quality/deploy-guard-same-host.sh</files>
<behavior>
- 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.
</behavior>
<read_first>
- `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.
</read_first>
<action>
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).
</action>
<verify>
<automated>bash -n scripts/lib/common.sh &amp;&amp; bash -n scripts/deploy-to-target.sh</automated>
<automated>bash tests/production-quality/deploy-guard-same-host.sh</automated>
</verify>
<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>
<reversibility rating="reversible">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.</reversibility>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Fetch the bytes off a real node — the directory listing lies</name>
<name>Task 4: 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
@@ -263,27 +425,29 @@ the string. The only honest check fetches what the browser would actually load.
|----------|-------------|
| 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 |
| 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 3 step 6. `sandbox` explicitly rejected with reasons recorded |
| 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 from a dirty second-repo tree | medium | mitigate | `scripts/aiui.pin` + refuse-on-dirty + the SHA emitted into the built output |
| 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 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 |
| 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>
<verification>
- `bash -n scripts/build-aiui.sh && bash -n scripts/verify-aiui-deploy.sh && bash -n scripts/deploy-to-target.sh`
- `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 'Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf` == 2
- `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
</verification>
@@ -291,7 +455,9 @@ the string. The only honest check fetches what the browser would actually load.
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.
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>
<output>