Skip to content

feat(reader): load trusted component packs in isles live reader mode - #170

Merged
zpyoung merged 2 commits into
mainfrom
claude/reader-pack-loading-2a6bc1
Jul 9, 2026
Merged

zpyoung merged 2 commits into
mainfrom
claude/reader-pack-loading-2a6bc1

Conversation

@zpyoung

@zpyoung zpyoung commented Jul 9, 2026

Copy link
Copy Markdown
Owner

What & why

Since #168, isles live always serves the client-rendered reader SPA, which bundled only the core agent-* components and never read the project's isles.config.json. As a result, trusted local component packs no longer loaded: a live doc containing <quirk-free-text …> rendered the element into the DOM, but customElements.get('quirk-free-text') was undefined and the island sat inert. Under the pre-#168 agent-screen shell this worked because the server-side render path resolved packs per render.

This PR brings that pack support to the reader: the server exposes resolved pack assets over HTTP, and the reader injects them at boot. Resolution scope is identical to the shell path — project config only, includeUserPacks: false, no CLI packs.

Implements docs/plans/reader-pack-loading.md.

Changes

Server (src/live.mjs)

  • Resolve packs once at startup (resolvePackInputs({ projectDir, includeUserPacks: false }) → buildPackAssetRecords()) and cache the manifest JSON.
  • GET /__agent-isles/pack-manifest → { assets: [{ type, url }] }.
  • GET /__agent-isles/pack-asset?pack=<i>&path=<rel> → serves one asset. pack indexes the resolved list; path must exactly match a manifest-declared asset — the route never does path arithmetic on user input, so traversal is impossible by construction. Reads via readFileNoFollow (O_NOFOLLOW, refuses symlink swaps). .js/.mjs → text/javascript, .css → text/css.
  • Any resolution/load failure (PackResolutionError or PackLoadError) degrades to {"assets": []} with a single stderr note — a broken pack never bricks the reader.

Reader (src/reader/reader-entry.js)

  • loadPacks() fetches the manifest and, per asset, injects a <link> stylesheet or dynamic-import()s the module via Promise.allSettled, awaited before the first render so a pack island upgrades on initial paint and one failing asset degrades only that pack.

Trust model

Unchanged from the shell: packs are trusted local code declared by a config file in the directory the user launched isles live on — the same trust boundary as the docs and the signal endpoint. The manifest/asset routes expose only files those local packs declare; no remote fetches. Access posture matches /tree and /raw (plain GET, no token, loopback-bound server).

Zero-pack projects see no behavior change beyond one small manifest fetch.

Tests

  • Unit (tests/live.test.mjs): manifest + asset serving with correct content types; empty manifest for zero-pack projects; broken-config degradation (server stays up); all four asset-route guards (undeclared path, out-of-range/invalid pack, traversal, symlinked asset via O_NOFOLLOW); and multi-pack index alignment (pack=0 vs pack=1 each serve their own declared asset).
  • Sanitizer regression (tests/reader-render-browser.test.mjs): pins that an unknown pack custom element reaches the rendered DOM with attributes intact.
  • Browser (tests/browser/reader-packs.spec.mjs): full companion loop — pack element upgrades, pack CSS applies (computed-style assertion), click produces a proceed record in state/events and satisfies a parked GET /__agent-isles/agent/events long-poll.

Also ran a 4-lens adversarial review (security / correctness / plan-conformance / tests); the only confirmed finding was the multi-pack coverage gap, now closed by the alignment test above.

Reviewer notes

  • dist/isles-reader.js is a build artifact — npm install && npm run build after pulling.
  • CI (Linux) should be fully green. Locally on macOS, npm test exits non-zero because of 2 pre-existing failures in tests/cli-packs.test.mjs (user-config packs): those tests set XDG_CONFIG_HOME, but getUserConfigDir() uses ~/Library/Application Support on darwin. Verified identical with this branch's changes stashed — not caused by this PR (unrelated to reader/pack loading). Happy to fix separately.

Since #168, `isles live` always serves the client-rendered reader SPA,
which bundled only the core agent-* components and never read the
project's isles.config.json — so trusted local component packs (e.g.
Quirk's `<quirk-free-text>`) rendered into the DOM but sat inert because
their custom element was never registered.

Bring pack support to the reader by exposing resolved pack assets over
HTTP and injecting them at boot:

- src/live.mjs: resolve packs once at startup (project config only,
  includeUserPacks: false — identical scope to the server-rendered shell
  path) and cache the manifest. Two new routes:
  - GET /__agent-isles/pack-manifest -> { assets: [{type, url}] }
  - GET /__agent-isles/pack-asset?pack=<i>&path=<rel> serves one asset,
    matching `path` exactly against a manifest-declared asset (no path
    arithmetic on user input -> traversal impossible by construction) and
    reading via readFileNoFollow (O_NOFOLLOW). Any resolution/load
    failure degrades to an empty manifest with one stderr note, so a
    broken pack never bricks the reader.
- src/reader/reader-entry.js: loadPacks() fetches the manifest and, per
  asset, injects a stylesheet or dynamic-imports the module via
  Promise.allSettled, awaited before the first render so a pack island
  upgrades on initial paint and one failing asset degrades only that pack.

Zero-pack projects see no behavior change beyond one small manifest fetch.

Tests: unit coverage for manifest/asset serving, content types, empty and
broken configs, the four asset-route guards (undeclared path,
out-of-range/invalid pack, traversal, symlink), and multi-pack index
alignment; a sanitizer regression test pinning that unknown pack elements
reach the rendered DOM with attributes intact; and a Playwright spec
driving the full companion loop (element upgrades, pack CSS applies, click
-> proceed record -> satisfies a parked agent long-poll).

Implements docs/plans/reader-pack-loading.md.
Copilot AI review requested due to automatic review settings July 9, 2026 15:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restores trusted local component pack loading in isles live reader SPA mode by having the live server expose a resolved pack manifest/assets over HTTP and having the reader inject those assets before the first render, bringing behavior back in line with the pre-#168 server-rendered path.

Changes:

  • Add server routes to expose a startup-resolved pack manifest and serve only manifest-declared pack assets (/__agent-isles/pack-manifest, /__agent-isles/pack-asset).
  • Add reader boot logic to fetch the pack manifest and inject CSS + dynamically import JS modules before initial document render.
  • Add unit/regression/browser tests to cover manifest/asset behavior, security guards (traversal, undeclared assets, symlinks), and end-to-end pack upgrade/CSS/signal loop.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/live.mjs Resolves packs at server startup, serves pack manifest + pack assets over HTTP for reader injection.
src/reader/reader-entry.js Fetches pack manifest and injects styles/modules before first render in reader mode.
tests/live.test.mjs Adds unit coverage for manifest/asset serving, guards, degradation, and multi-pack index alignment.
tests/reader-render-browser.test.mjs Adds regression test ensuring unknown pack custom elements survive client render with attributes intact.
tests/browser/reader-packs.spec.mjs Adds Playwright E2E verifying pack element upgrade, CSS application, and signal → agent long-poll loop.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/live.mjs Outdated
Comment thread src/live.mjs
…ension

The manifest permits any extension for a `module` asset (e.g. `.cjs`,
extensionless), but a module must be served with a JavaScript MIME type or
the reader's import() is rejected. Serve by declared asset type instead of
the file suffix; add a regression test pinning a non-.js module.

Addresses Copilot review feedback on #170.
@zpyoung
zpyoung merged commit bb656d8 into main Jul 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants