Skip to content

feat(extensions): extension platform — host user-authored apps inside Agent Code - #577

Draft
Juliusolsson05 wants to merge 36 commits into
mainfrom
feat/extension-platform
Draft

feat(extensions): extension platform — host user-authored apps inside Agent Code#577
Juliusolsson05 wants to merge 36 commits into
mainfrom
feat/extension-platform

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Builds the platform for user-authored extensions that live inside Agent Code —
installed by the user, invoked from the command palette, opening their own view as
a modal or a pane, backed by a documented app API.

Extensions run inside a sandboxed cross-origin iframe served from a privileged
agent-code-ext://<id> scheme. The extension never enters Agent Code's realm: it
cannot reach window.api, the host DOM, another extension, or the network. Its
only channel is postMessage, brokered in the parent and validated against a closed
schema.

What ships

  • Install from a GitHub repo, or from a local folder for development.
  • Contributions — commands, views, settings rows and keybindings — declared in
    a manifest and merged into the first-party systems without importing the
    extension
    , so the palette lists a command before a single byte is loaded.
  • Views as a floating modal or a tiled pane, persisted across restarts.
  • Capabilities — three Tier-1 observe permissions, approved in a native dialog
    and bound to a hash of the installed bundle.
  • Authoring guide (docs/extensions/authoring.md) and a types + build-preset
    SDK (packages/agent-code-extension-api).

Review and remediation

An eight-agent adversarial review ran in July and produced ~47 findings that were
never actioned. A second six-agent review ran against the current branch. This PR
now contains the remediation for both, plus the merge of 113 commits of main
that the branch was behind.

The most serious defect was found by neither review. The generated frame
document contained a literal script closing tag inside one of the bootstrap's own
comments — the comment explaining how such a tag breaks a document quoted the
payload verbatim. An HTML tokenizer ends a script element at the first </script
regardless of JavaScript context, so in a real browser the frame's module script
terminated after ~300 characters and the entire runtime became inert text. No
extension frame could ever have worked.
It is valid TypeScript, valid
JavaScript, and the existing "parses as JavaScript" test passed because
new Function on a comment-only prefix parses fine. It is now caught by asserting
a property of the emitted document rather than of the source.

Blockers fixed

Frame bootstrap truncated by its own comment The runtime never ran. Now pinned by an emitted-document test.
Grant binding was a tautology The check compared the ledger's hash against the grant's hash — both written by the same install call, so it could never fail. Now recomputed from disk on every read.
Seven capabilities had no implementation fs.write, git.commit, network.fetch and four others showed a warning dialog and granted nothing. Removed; asking for one now fails install with a message.
SessionKind widening ~30 sites spelled "is an agent" as kind !== 'terminal', so every extension pane became an agent: agent-only commands enabled, pinnable to Dispatch, badged "Claude". Replaced with a positive predicate.
Undo-close restored an orphan leaf Came back as a dead Claude pane, then autosave deleted it — and the whole tab if it was the only leaf.
Contributed keybindings never ran The chord resolved, then dispatchCommand searched only the built-in catalog and returned unknown, which that path ignores. Silent no-op that also swallowed the chord.
Authoring guide was actively breaking §3 told authors to get React from globalThis.__agentCodeHost, unreachable from a cross-origin frame. Rewritten around the architecture that ships.
SDK submodule pinned to a branch-only commit Deleting the branch after merge would permanently break every clone, including tagged releases. Tagged v0.3.0; SDK PR opened separately.

Also fixed

Install durability (commit ordering destroyed the installed version on any late
failure; ledger races lost rows; nothing swept staging), a decompression bomb
(the 32 MB cap bounded only the compressed download), unbounded fetch and hashing,
tar hardening plus post-extraction containment, Update on a locally-loaded
extension always failing, stale frames after an update, one frame registry entry
deafening a second view, the consent dialog over-warning while omitting what it
discloses, revocation not reaching a live frame, the ViewMount cleanup contract
never being called, registerCommand/registerView no longer rejecting undeclared
ids, and Escape not closing an extension view.

~330 lines of the deleted host-realm activation path were removed along with the
__agentCodeHost global it existed to publish.

Verification

tsc -b clean. Test contract, keybindings check, and live-resume probe pass. Suite
green: unit + system + renderer, with the extension boundary going from 12 tests to
~110 — manifest validation, bundle hashing, grant binding, install containment,
pane ownership, command resolution, and the frame document.

One pre-existing failure remains and is not from this PR:
imageAttachment.test.ts asserts a session JSONL exists in the developer's home
directory. It fails identically on main, and is inverted — green on CI, red
locally — because the corpus root does not exist on a runner.

Known limitations, stated deliberately

  • activationEvents are validated and never fire. An extension activates on
    first view open; there is no background execution. Documented in the guide.
  • Keystrokes do not leave a cross-origin frame, so app shortcuts do not fire while
    focus is inside a view. Escape is forwarded as the one exception.
  • Tier 2/3 capabilities do not exist. Adding one means adding it to the schema, the
    broker's tier record and its dispatch arm in the same change.

Refs #577

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Architecture settled — Stage 1 plan pushed

Four consultants ran under orchestration run extension-platform-consult. Both Claude
consultants were given opposing briefs — one to steelman compiled-in, one to steelman
runtime-loaded — and independently converged on the same staging. That convergence is the
main reason to trust it.

Decision: Stage 1 is compiled-in, built as Stage 2's substrate.

What changed from the investigation's assumptions

Stage 2's loader is one line, not a multi-week problem. out/renderer/assets/editor.main-*.js
already contains a fully runtime-variable import() that Rollup passed through untouched, and
92 Monaco language chunks load via ESM dynamic import from file:// in production. Both halves
of the loading risk are empirically retired — by code already shipping.

Two objections to the iframe option were refuted. Paint order survives (the iframe wrapper
is a normal registry sibling), and interaction ownership survives with a marker on the host shell
(~1–10 LOC, fixes all seven consumers). The perf-baseline objection was also wrong — it only
colors a number yellow. What is genuinely broken is keyboard: every host shortcut dies while
focus is inside a frame, and Cmd+W closes the window rather than the pane. Full iframe bridge
priced at ~350–650 LOC.

The API is transport-independent. Same schemas for compiled-in, runtime-loaded, utility-process
and iframe — only bootstrap, serialization, mounting and teardown differ. So AgentCodeApiV1 gets
built now and used even while apps are compiled in.

The two rules that make Stage 2 a swap

  1. An app imports nothing from @renderer/* and receives one prop, api: AgentCodeApiV1.
  2. Every host call returns a Promise, including ones that could be synchronous — postMessage
    cannot be made sync later, and widening those signatures afterwards touches every call site in
    every app.

The plan ends with the grep that proves rule 1 held. If it fails, Stage 1 built something Stage 2
would tear out — and that is worth finding while there is exactly one app.

Stage 2 is conditional, not scheduled

Trigger: extensions shipping to people who run packaged releases. The owner runs npm start
against a local build and rebuilds routinely, so Stage 1 is sufficient for personal use
indefinitely.

📄 docs/superpowers/plans/2026-07-20-extension-platform-stage1.md

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Stage 1 implemented — all six tasks

Six commits, 28 files, +2668. Host built before its first consumer so the
infrastructure could not be shaped around one app's needs; Timer landed last.

npm run typecheck
npm test ✅ 209 files / 1187 tests
npm run build
portability grep

What's in it

  • src/main/extensions/storage.ts — per-app JSON under STATE_DIR/extensions/<id>/,
    atomic temp+rename. Ids are validated and rejected, not sanitized: an id becomes a
    directory name, so a permissive id is a traversal primitive — and sanitizing silently
    collapses ../timer and timer onto one directory.
  • apps/api/types.tsAgentCodeApiV1. Tier 0 only (storage, close, toast, theme
    tokens). Every method returns a Promise even where it is synchronous internally,
    because postMessage cannot be made sync later.
  • apps/surfaces/AppHostSurface.tsx — one surface entry, appended last in
    modalSurfaces. Apps mount inside DialogContent so they inherit the
    interaction-ownership marker that seven consumers query synchronously.
  • apps/commands/appCommands.ts — commands derived by .map(APPS), so an app
    cannot ship without an opener.
  • Settings → Apps — fourth instance of the existing self-subscribing marker pattern.
  • Timer — SVG progress ring, presets, pause/resume, deadline-based countdown.

The test that matters

$ grep -rn "@renderer/" src/renderer/src/apps/timer/
apps/timer/index.ts:      import type { AppDefinition } from '@renderer/apps/types'
apps/timer/index.ts:      import { TimerApp } from '@renderer/apps/timer/ui/TimerApp'
apps/timer/ui/TimerApp.tsx: import type { AgentCodeApiV1 } from '@renderer/apps/api/types'

Only the ABI types and its own file. The Timer directory should lift into its own repo in
Stage 2 unchanged — that claim is currently under adversarial audit.

Deviations from the plan, both found by reading

  • uiShell actions are declared in app-state/types.ts (UiShellSlice), not
    uiShell/types.ts as the plan assumed. State field went in the latter, actions in the
    former.
  • Timer styling is inline style={{ }} with var(--theme-*) rather than Tailwind
    utilities — the utilities are a build-time binding that will not exist out-of-tree.

Still open

Two reviewers running (extension-platform-review): correctness/integration, and an
adversarial audit of the Stage-2 migration claim. Findings to follow.

No manual smoke run yet — npm start verification pending.

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Full extension platform — Phase B complete

16 commits, 42 files, +5528. typecheck ✅ · 1187 tests ✅ · build

The compiled-in app approach was removed; this is now a real VS Code-style
extension system — install from a repository, declare contributions, activate lazily.

Phase B

B1 agent-code-ext:// scheme + CSP
B2 Host runtime global
B3 Manifest contributions + activation events
B4 Extension host — import, activate, disposables
B5 View bridge
B6 Declarations → palette / Settings
B7 Four review defects

Verified, not assumed

Scheme spike — real built renderer, real CSP, file:// origin:

document origin       file:            dynamic import        activate() OK
missing extension     404              relative ./sibling.js resolved
../../../.ssh         404 blocked      ..%2f..%2f encoded    404 blocked

Install — the real installer against a real repository:

id timer · ref v0.1.0 · sha256 1fb05a80… · 194 KB on disk · exports activate

Plus error paths: unknown repo, private repo, malformed input.

Manifest — the real timer manifest accepted, and nine rejection cases
including foreign-namespace ids, duplicate ids, dangling keybindings, unknown
activation events, and traversal entries.

Production build__agentCodeHost and agent-code-ext:// both present in
the shipped renderer bundle.

First real extension

Juliusolsson05/agent-code-timer v0.1.0 —
ported from focus-flow-timer, restructured so a headless engine owns the session
and the view is a subscriber. Closing the window does not stop the timer; a
wall-clock deadline means it survives an app restart too.

It is already installed on this machine and will appear on next launch.

Two bugs found by building the real thing

  • The contribution-id regex demanded kebab-case and rejected timer.inheritTheme.
    Caught by running the schema against the real manifest.
  • GlobalToast sat at z-50, under the dialog scrim at z-[1100] — every toast
    raised from inside a modal was invisible. Harmless while all callers were
    non-modal chrome; fatal for an extension whose reminders fire as toasts.

Not verified

Nobody has launched the app and clicked it. Everything above is automated
verification of the pieces. The end-to-end run is the remaining gap.

Juliusolsson05 added a commit that referenced this pull request Jul 28, 2026
…idate ledger rows

Three known #577 defects, cleared before building the contribution wiring on top.

1. Update button was a no-op. onClick did `setRepo(entry.repo); void install()`, but
   setRepo is async and install() closed over the OLD repo, so Update installed the
   empty/last-typed value. install now takes an explicit target
   (`install(target?: string)`), and a new `update(entry)` calls install(entry.repo)
   directly — no closure round-trip.

2. Uninstall (and Update) never deactivated the live extension. AppsSettingsRow held
   no ExtensionHost reference and main has no handle on the renderer-side host, so a
   removed extension's subscriptions/registrations/intervals leaked for the session.
   The row now uses useExtensionHost() and calls deactivate(id) before removing files
   (remove) and before reinstalling over the bundle (update). deactivate works off the
   in-memory module, so it runs correctly before the on-disk bundle changes.

3. Ledger rows were cast unvalidated (`return parsed as InstalledExtension[]`). Each
   row's manifest.id/entry is interpolated into a path join and the import() URL, so a
   hand-edited extensions.json was the one way an unvalidated id/entry could reach
   those sinks. readLedger now validates every row against the manifest schema (which
   already enforces the id regex + entry `..`/absolute/backslash refinements) and drops
   malformed rows INDIVIDUALLY with a warning, never the whole ledger.

First commit of the no-sandbox tranche (plan WS0). Verified: tsc -b tsconfig.node.json
and tsc -p tsconfig.web.json --noEmit both clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Juliusolsson05 Juliusolsson05 changed the title Extension platform: host user-authored apps inside Agent Code feat(extensions): extension platform — host user-authored apps inside Agent Code Jul 28, 2026
Juliusolsson05 and others added 11 commits July 28, 2026 21:46
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion registry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ngs systems

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n & capability enforcement

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 force-pushed the feat/extension-platform branch from b0a7baa to cd4a61a Compare July 28, 2026 19:47
Juliusolsson05 and others added 13 commits July 28, 2026 21:55
Iterating on an unpublished extension meant cutting a GitHub release for every
change (installExtension resolves releases/latest). This adds a local-folder
install so an author rebuilds, clicks Load folder, reloads — no release.

- install.ts: extract the shared finalizeInstall tail (consent → move → ledger →
  grant) so GitHub and local installs converge; add installExtensionFromPath — a
  SNAPSHOT copy (cp excluding node_modules/.git) through the exact same manifest
  validation + entry-containment checks as the tarball path. No tarball to hash,
  so the grant binds to the built ENTRY bytes (a rebuild correctly forces re-consent).
- ipc/extensions.ts: extract the consent dialog (shared by both paths); add
  extensions:install-path with a native openDirectory picker.
- preload + AppsSettingsRow: extensionsInstallPath + a 'Load folder…' button.

Snapshot, not a live mount — a live-reference mode is a larger scheme-handler
change left for later; a copy reuses the tarball path's containment guarantees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame's activate() ran but its deactivate() never did: closing a view just
navigated the iframe to about:blank, so the extension leaked its intervals /
AudioContext / listeners on every close. The bootstrap now captures the module and,
on pagehide (fired by that about:blank navigation and by app quit), calls
module.deactivate() then disposes context.subscriptions in reverse order — the same
cleanup contract the same-realm host honored. Best-effort/synchronous, since the
document is being torn down (the timer's engine.dispose()/removeStyles() fit this).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion.id in frame

The SDK's AgentCodeApiV1 was Tier-0 only while the runtime had gained the Tier-1
observe groups — an author could not type api.workspace/sessions/panes.observe.
Bumps the submodule to v0.2.0, which mirrors them. Also fills api.extension.id in
the frame bootstrap (from the frame's own agent-code-ext://<id> origin host): the
type promised it and the same-realm api already provided it; the frame did not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…echanism)

The extension modal was a fixed 560px, so a big game canvas could not get room
without making every small extension look lost in an empty box. The frame now
reports its content width alongside height; the iframe takes a definite width and
the DialogContent is content-width (up to a cap), so a large view grows the modal
to fit while a small one stays snug. Clamped 240-1200px against a hostile child.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An extension with a fixed natural size — a game canvas is the motivating case —
reports e.g. 892x652. That fits a normal window, but on a short one the modal
CLIPPED it: DialogContent has no maxHeight and is overflow-hidden, so the bottom
of the view simply vanished with no scrollbar and no way to reach the controls.

The frame now keeps its natural pixel size and is scaled visually to fit, with
the wrapper occupying the scaled footprint so the auto-sized modal reserves the
right room. Scaling rather than scrolling because the content is a single
fixed-aspect surface: a scrollbar on a game is worse than a slightly smaller
game, and clipping is worse than both.

This has to live host-side. The obvious alternative — have the extension size
itself in vw/vh — is a trap: those units resolve against the IFRAME's viewport,
which the host sets from the content's reported size, so the extension's size
would depend on its own size. That is precisely the resize feedback loop
frameDocument.ts's measurement is built to avoid.
reportSize measured root.scrollWidth, but #root is width:100% and scrollWidth
is by definition never smaller than clientWidth — so the reported width was
always at least the CURRENT iframe width. The modal could grow and never
shrink: switching from a wide view to a narrow one (an 892px game to a 375px
one) left the modal frozen at the old width with the new content marooned in
the corner.

Width now comes from the children, which carry their own intrinsic size, so
the report can go down as well as up. Height still comes from #root, which is
height:auto and therefore already measures content.

The ResizeObserver had the matching blind spot: watching #root alone cannot
see a content change that only alters WIDTH, because #root's own box is pinned
to 100% and never moves. It now observes the children too, and a MutationObserver
re-syncs that list when the extension swaps its tree — a router switching
screens replaces the child outright, which is exactly the case that was stale.
…ack palettes

The frame's only baked CSS is a margin/overflow reset — there is no theme
baseline — and onLoad pushed mount synchronously while the theme followed a
microtask later, because tokens() is async. So a view rendered against
--theme-* variables that did not exist yet.

Given that, a local fallback palette was the only defense against a flash of
unstyled content, which is why extension authors end up re-declaring the entire
token set locally instead of using var(--theme-canvas) directly. The platform
never offered a moment at which the theme was guaranteed present.

postMessage preserves order, so pushing theme first and mount second closes the
window: by the time the child mounts, the tokens are already set on its
documentElement. The catch is deliberate — a theme failure must never prevent
the view from mounting, since an unthemed extension is a bug but an unmounted
one is a broken product.
Brings the branch up to date after 40 commits landed on main (command palette
sort modes, unified command settings, UI primitive theme fidelity, dictation
history, dispatch lane removal, session lifecycle observability).

14 files were touched by both sides; 4 genuinely conflicted, all of the
"re-apply our small change onto their rewrite" kind:

- CommandKeybindingsRow.tsx — main rewrote it (+237) for the unified command
  list and Palette column. Both sides added real behaviour, so both survive:
  extension-contributed keybinding defaults still merge into the table the
  editor conflict-checks against, and the deps array is now the union.
- settingsRegistry.ts — main removed the Commands category (it moved into
  CommandKeybindingsRow), taking listPickerCommandMeta with it. Kept main's
  shape and re-applied only what extensions still need: the `extension` control
  arm and deriveExtensionSettings. getSettingsRegistry drops its now-dead
  extensionCommands parameter, and SettingsPage was updated with it — the
  params were positional, so leaving it would have passed commands where
  manifests were expected and silently rendered nothing.
- registry.ts — took main's removal of PickerCommandMeta; our per-call
  allCommandDefs concat was outside the conflict and survives.
- SettingsList.tsx — both added a marker row; kept both.

Two things git merged cleanly but wrongly, caught by tsc rather than by review:
the extension keybinding imports in registry.ts were removed with main's hunk
while the code using them survived, and deriveExtensionSettings lost its
definition while keeping its call site.

One real integration gap: main's new `grouped` sort mode sections the palette by
CommandCategory, and our `extensions` category had no entry in CATEGORY_ORDER or
CATEGORY_LABELS, so extension commands would have grouped under an unnamed
heading. Extensions sort last — third-party commands should not sit above the
app's own, matching why they are concatenated last in the registry.

Also fixes the SESSION_KINDS parity test, which was already red on this branch
before the merge. It now asserts the derivation rather than a hand-written list,
which is what the guard was always for. Review finding H3 (extension-view being
a SessionKind at all) stays open and is documented at the assertion.

Verified: tsc clean on both projects, 1818/1818 tests passing.
Six boundary defects, each independently confirmed by two or more reviewers.

Scheme-handler traversal (blocker). `url.hostname` was used verbatim as a path
segment, and the containment check below it resolves against a root DERIVED
FROM THAT VALUE — so an escape made the check certify the wrong root and
approve everything beneath it. `agent-code-ext://../extension-grants.json`
parsed to hostname `..`, rooting the handler at the whole state directory:
grants, ledger, workspace.json, and the proxy dumps that carry provider
Authorization headers. Reachable from a Tier-0 extension that triggered no
consent dialog. The id is now validated before anything else touches it.

The pattern lived in four files and was missing from the one that mattered
most, so it now lives in @shared/types/extensionId and everything imports it.
That also fixes removeExtension, which ran a recursive rm on an unvalidated,
IPC-supplied path component.

HTML injection into the frame document. The bootstrap interpolated viewId and
entry into JS source and relied on JSON.stringify, which escapes quotes and
backslashes but not `<` or `/` — so a value containing `</script>` closed the
element from inside a string literal. The entry path passed all four of the
manifest's negative refinements with that payload embedded. Config now travels
in a JSON island with `<` escaped, which removes the sink rather than filtering
it, and the view id is checked against the manifest's declared views. Covered
by a test using the reviewer's exact payload.

Child CSP was per-SCHEME, not per-origin. `'self'` already covers the document's
own origin, so the bare `agent-code-ext:` source granted only the cross-extension
case — one extension could fetch and execute another's bundle. Paired with a
wildcard CORS header on every asset. Both are now scoped to the frame's own
origin. base-uri and form-action are set explicitly because neither falls back
to default-src.

window.open egress. No CSP directive governs window.open, so a Tier-0 extension
could exfiltrate to any URL through the OS browser with no prompt. Fixed with
sandbox="allow-scripts allow-same-origin", which keeps the origin-derived broker
identity intact while killing popups, top-nav, modals, forms, and downloads. The
comment claiming sandbox was unusable was wrong — that is only true without
allow-same-origin. Enforced on the attribute rather than in setWindowOpenHandler
because Electron's HandlerDetails has no frame field and referrer is suppressible.

viewBridge's side channel authenticated on one gate while claiming parity with
frameHost's two. contentWindow is identity-stable across navigations, so source
alone could not tell a self-navigated frame from the original; it now checks
origin too.

__proto__ storage keys silently vanished instead of failing, because the write
hit Object.prototype's setter rather than creating an own property.

Adds 12 tests over a boundary that previously had none.
Undo Close was permanently brickable (the review's only CRITICAL). Both undo
paths called spawn() for every kind, main rejects an extension-view spawn, the
catch turned that into 'retryable-failure', and undoClose PUSHES A FAILED ENTRY
BACK — poisoning the stack head so every later Cmd+Shift+T popped the same
entry, failed, re-pushed, and returned. All older undo history became
unreachable for the rest of the session. Scenario: split a panel view into a
pane, close it, then accidentally close a Claude pane — undo never works again.

The tab path was worse. It spawns leaves in order, so hitting an extension-view
leaf threw partway through and the rollback then killed every sibling it had
just spawned: undoing a tab containing one extension pane started N real
claude/codex processes plus proxies, killed them all, restored nothing, and
poisoned the stack. A process-less leaf is now restored rather than spawned, and
deliberately not added to spawnedIds so rollback does not try to kill it.

Bury/revive and detach/attach stranded a pane forever. isSessionKind accepts
'extension-view', so ensureSessionLive sailed past its unsupported-kind guard,
called recoverSession, and threw on main's rejection. Bury and
Detach-to-Dispatch are both ungated commands, making it a one-way trip with Kill
Buried as the only exit. Fenced at that single choke point, which covers all
three callers (Revive, Attach Detached, Attach All).

A rehydrated pane claimed its extension was missing. installedExtensions starts
empty and is filled by an async IPC whose failure path deliberately leaves the
store untouched, so "still loading" and "not installed" were the same state:
every reload flashed a false message, and one failed extensionsList() made it
permanent — sending the user to reinstall something that was fine. The store now
records whether the list ever loaded.

Extension panes could not be focused by clicking them. The leaf declared
focused/onFocusRequest in Props and used neither, while every sibling leaf wires
onMouseDown — so clicking an extension pane left focus elsewhere and Cmd+W closed
the wrong pane. The cross-origin iframe still swallows mousedown over its own
content, so this catches the surrounding gutter: partial, but strictly better
than unfocusable.

A panel view's action command opened as a modal, contradicting its manifest,
because the cold-activation fallback called openApp() unconditionally while the
targetView branch beside it already honoured the declared mount.

Also validates the persisted extensionViewId against the manifest's declared
views. It is an unconstrained string restored from workspace.json, and trusting
everything after split('.')[0] let any "victim.anything" mount a live broker for
victim.
The host document permitted agent-code-ext: in script-src, connect-src, and
every asset directive. That existed solely to serve ExtensionHost.activate() —
a host-realm import('agent-code-ext://…') that evaluates third-party extension
code in the renderer's own realm, where window.api exposes every IPC handler
including extensions:install and extensions:remove. That path has no callers;
command execution moved into the sandboxed frame. So the concession bought
nothing and left the renderer permitted to run extension code directly, which
is precisely what the iframe design exists to prevent.

The scheme now appears in frame-src and nowhere else. The child serves its own
assets under its own far stricter policy. The old rationale block described
directives that no longer grant it, so it is gone rather than left to mislead;
what replaces it says that frame-src is the directive doing the containment
work, which the previous comment never mentioned.

Install staged into the OS temp dir and committed with rename(staging, final).
rename cannot cross filesystems, so wherever /tmp is its own mount — Linux
tmpfs, the common case — it fails with EXDEV. And since the commit deletes the
live bundle BEFORE renaming, that is not "install failed" but "install
destroyed the version you had". Staging is now a sibling of the destination, so
the rename is same-filesystem by construction.
…switch

The gate was `await requireGrant(...)` lines sprinkled through a switch. Adding
a Tier-2/3 member to frameRequestSchema and forgetting its line was a one-line,
review-invisible privilege escalation — nothing existed to notice the omission.
And `perform` had no default arm, so an unhandled method fell off the end
returning undefined, which the caller reported as ok:true — a silent false
success for a capability that was never performed.

Tiering is now a Record keyed by the method union, enforced once before the
dispatch, plus an exhaustiveness assert. Verified by adding a `network.fetch`
member to the schema: it fails to compile in both places, so an ungated method
can no longer be expressed rather than merely being caught in review.
The branch was 113 commits behind and its last green CI predated three and a
half weeks of movement in exactly the subsystems it hooks into: the command
palette, panes, sessions, keybindings and settings.

Two conflicts, both resolved toward main:

- surfaces/registry.tsx — an import collision only; AgentTitlePromptSurface
  and AppHostSurface both keep their authored array positions.
- sessionOwnership.ts — main had ALREADY split collectTileLeafIds out of
  collectLiveProcessIds *for this feature*, with a comment instructing that a
  process-less pane kind be narrowed in collectLiveProcessIds "and nowhere
  else" (narrowing the leaf collector instead drops the pane's metadata on the
  next autosave). The branch's hand-rolled loop predated that split, so the
  extension-view skip now filters main's set rather than re-walking the tree.

Also takes main's submodule pointers for agent-transcript-parser and
codex-headless: git resolved those to the branch's older commits because the
branch had touched them in its previous main merge, which broke the node
typecheck against codex-headless's current exports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
Juliusolsson05 and others added 9 commits August 27, 2026 18:21
Adding 'extension-view' to SessionKind reclassified every extension pane as an
AGENT at roughly thirty renderer call sites at once, and nothing could see it:
`kind !== 'terminal'` stayed valid TypeScript and silently changed meaning.

What that actually did, all user-visible:

- Copy Last Response, Send Prompt, Clear Composer, Jump to Latest and Tail Mode
  became enabled on an extension pane. `copy-last-assistant` then calls
  getRuntime() on a session that has no runtime.
- The pane was pinnable into Dispatch and detachable as an agent, and appeared
  in Reader Mode's session list and the Agent Activity modal with agent status.
- Bury accepted it, parking a leaf with no process for Revive to fence.
- Prompt templates and Reply to Selection targeted its non-existent composer.
- buildGridRelatedAgentTabs treated it as a related-agent owner.
- Its pane header read "Claude Code", via providerLabel's `undefined` fallback.

Replaces all of it with `isAgentSessionKind`, spelled POSITIVELY over
AGENT_PROVIDER_KINDS. The negative form was only ever correct while 'terminal'
was the sole non-agent kind; the positive form excludes a future non-agent pane
kind everywhere at once, by construction, instead of requiring another sweep.

`undefined` deliberately reads as an agent — that is the same back-compat truth
DEFAULT_PROVIDER encodes (SessionMeta.kind postdates the workspace format, and
pre-kind sessions genuinely were Claude), and it is what makes this substitution
behaviour-preserving for real sessions while fixing extension panes.

Also switches sessionManager's three `revokeAgentMcp` sites, which mean "has an
agent MCP token to revoke". The terminal-attach routing check at 2849 keeps its
literal `!== 'terminal'` — there the question really is "is this a terminal",
and an extension-view entry reaching it is a routing bug either way.

The test is written as a PARTITION over SESSION_KINDS rather than a list of
literals, with a non-empty guard so it cannot pass vacuously. A list of literals
would not have caught the original defect, because the defect was precisely
"a kind was added and we forgot where it flows".

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
…e rechecked

grants.ts documented the load-bearing invariant as "when the bytes change, the
grant no longer matches and the capabilities must be re-approved". It was not
implemented. The grant recorded the ledger's `sha256` and the check read the
ledger's `sha256` back — both written by the same finalizeInstall() call — so
`row.sha256 !== sha256` compared two copies of one value and could never be
true. Nothing looked at the bundle on disk again after install, so editing any
file under EXTENSIONS_DIR kept every capability the user had approved for the
original code.

The tarball digest cannot be the fix: the tarball is deleted after extraction,
so it is unrecomputable by construction. It answers a real but different
question — "which bytes did GitHub hand me" — and stays in the ledger as
provenance.

Adds computeBundleHash(): a deterministic digest over every file in the
installed bundle directory, which IS recomputable, and is what the grant now
binds to. The IPC check recomputes it from disk on every read, so the documented
rule becomes an enforced one.

Three details that are not incidental:

- The whole bundle, not just `entry`. The scheme handler serves every file under
  the root and the entry can import('./util.js') at runtime, so hashing only the
  entry would let a swapped sibling chunk keep a matching grant — capabilities
  held by code nobody approved.
- Hashed AFTER the rename, from the final directory, so the value is a function
  of exactly what the scheme handler will serve.
- Every failure path returns [] rather than throwing. An unreadable or missing
  bundle must mean "no capabilities", never "unchanged".

`bundleSha256` is optional on the ledger row: a row written before this field
existed has nothing to compare, and grantedCapabilities fails closed on it. The
cost of an old row is re-consent, not a silently retained grant.

Cost is one bundle read per frame creation — once when a view opens, not per
capability call, since frameHost caches the grant for the frame's lifetime.
Bundles are capped at 32 MB by the installer and are typically a few hundred KB.

The tests are `.system.` because the entire behaviour is what the function reads
off disk; a mocked fs would exercise the mock's traversal. They cover the two
collisions the encoding has to survive (rename-vs-edit, sibling swap) and assert
that a symlink is hashed by target STRING — following it would both make the
digest depend on a file outside the bundle and turn the hasher into an
arbitrary-file read for anyone who can commit a symlink to a repository.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
The manifest schema accepted ten capabilities. Three worked. The other seven —
fs.read, transcript.read, git.read, sessions.prompt, fs.write, git.commit,
network.fetch — had no member in frameRequestSchema, no arm in frameHost.perform
and no API surface an extension could call. network.fetch was additionally
impossible by construction, since the child CSP is `connect-src <self>` only.

What that shipped was consent theatre. A manifest could request "filesystem
write and git commit", the user got a blocking OS warning dialog naming exactly
those powers, approved it, and a permanent grant was written for capabilities
that did nothing at all. The dead code is not the problem; training people to
click through the one dialog in the product that must never become routine is.

Cutting them makes a manifest that asks for one fail install with a message
naming the gap, which is actionable — the author learns their extension needs a
newer host. Granting nothing in silence teaches nobody anything.

The rule that replaces the tier vocabulary: a capability is added to
ExtensionCapability in the same change that implements it, alongside its
frameRequestSchema member and its REQUIRED_CAPABILITY entry (which will not
compile without one). Declaring the vocabulary ahead of the mechanism is exactly
what produced this.

Also fixes the API-version gate, which had the same shape of hole. It lived only
in parseExtensionManifest, so it ran at install and never again — while
readLedger validates rows with the shared schema, which accepts any positive
integer. A row recorded under a host implementing v1 kept loading against a host
implementing v2, which is the precise thing versioning an ABI exists to prevent.
Version skew arrives by UPGRADING AGENT CODE, an event involving no install, so
an install-time-only check structurally cannot observe it. Extracted as
apiVersionMismatch() and applied on every ledger read.

Adds the manifest suite this boundary never had: entry path safety, id grammar,
contribution namespacing, dead activation events, and the capability refusal.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
…t frame failures

Once activation moved into the sandboxed iframe, ExtensionHost had no callers
left. `activate()`, `executeCommand()`, `getView()` and the whole
ExtensionRegistrations store were dead — and index.html had already dropped
`agent-code-ext:` from script-src, so the host-realm import they existed to
perform could not have run even if something had called them. `hostGlobal.ts`
published `globalThis.__agentCodeHost` for extension bundles to bind React
against, which a cross-origin child document cannot see at all.

Six files still called useExtensionHost() and threaded the object into derive*,
which ignored it. AppsSettingsRow called host.deactivate() before remove and
update under a comment claiming it tore the live extension down; it drove an
always-empty map. Removing the host removes all of that: -4 modules, and the
provider becomes InstalledExtensionsLoader, which is what it had been reduced to.

ExtensionHost also collected import/activate failures, and that was load-bearing
— it fed the "failed to start" line on the Settings row. The frame is now the
only thing that can observe those throws, so it reports them:

- The bootstrap posts `boot` as its very first statement and `error` from the
  import/activate catch.
- viewBridge treats `boot` — not the iframe's `load` event — as proof the frame
  started. An iframe fires `load` for an HTTP ERROR BODY exactly as it does for a
  real page and never fires `error` for one, so the existing onError handler was
  unreachable and a 404/403 from the scheme handler rendered as a permanently
  blank frame stuck in "ready" with no message anywhere.
- A 10s backstop covers failures that produce no message at all: a CSP violation
  that blocks the bootstrap, a syntax error in it.

Two further fixes in the same blast radius:

- viewComponentFor now caches by (extensionId, viewId, fill). React remounts on
  component IDENTITY, and both call sites derive from the installed list, which
  is refetched on every install/update/remove — so installing ANY extension
  unmounted every open extension view, destroyed its iframe and re-ran activate()
  in a fresh document. A running timer reset because something unrelated was
  installed. The component reads its display name from the store rather than
  closing over a manifest, which is what makes caching it correct.

- `category: 'extensions'` is assigned once, in derive.ts. Two Settings surfaces
  re-tagged it locally with `.map()` and the command palette did not, so the same
  command grouped under "Extensions" in the keybinding editor and under "Other"
  in the palette — while sortCommands carried a comment asserting extension
  commands "have no way to declare one at all". A manifest still cannot set a
  category, deliberately; the host assigns it.

Also fixes a regression the main merge exposed rather than caused: useKeybinds
reads the extension slice, and focusModeKeyboardOwnership's hand-built store mock
does not have it, so the global keydown router threw during render. Guarded with
`?? []` — this hook owns the whole keyboard, it throws before anything can catch
it usefully, and a missing store slice is precisely the #249 shape — and the mock
now includes the slice the hook actually reads.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
…w source

Five defects in the path that downloads and unpacks third-party code, found by
walking it as an attacker rather than as its author.

1. Unbounded download. The size cap checked `content-length` and then called
   `res.arrayBuffer()`. The header is supplied by the server, so it is a hint, not
   a bound: omit it (chunked encoding suffices) or lie, and the entire body is
   allocated before the check runs. A URL the user merely pasted a repo name for
   could drive the MAIN process — the one holding every agent session — to an OOM
   kill. Now counted while reading, so peak allocation is bounded by the cap plus
   one chunk regardless of what the server claims.

2. No deadline on any of the three fetches. A host that accepts the connection and
   never answers left the install promise pending forever: the row stays
   "Installing…", the button stays disabled, and there is no cancel anywhere in the
   UI. 30s per request, with the host named in the message.

3. `normalizeRepo` accepted `..` as an owner or repo segment, because `[\w.-]+`
   matches it. The value is interpolated into two URLs where the parser normalizes
   the `..` away, so the request addressed a different GitHub endpoint than the
   code believed, and the bogus value was persisted and rendered.

4. Extraction trusted the extractor. bsdtar refuses `..` members and refuses to
   write THROUGH a symlink — verified empirically on macOS — but resolveTarBinary
   falls back to whatever `tar` is on PATH, which may not. assertBundleTreeIsSafe
   now checks the RESULT: any symlink resolving outside the bundle, or any entry
   that is not a regular file/dir/symlink, fails the install before consent is
   asked and before anything is moved into place. A dangling link stays legal — it
   points nowhere, so it leaks nothing. Also passes --no-same-owner and
   --no-same-permissions so an attacker-controlled archive cannot choose the mode
   or ownership of what lands on disk.

5. `fs.cp` resolves symlinks by default (`verbatimSymlinks: false`), which
   rewrote a bundle's internal RELATIVE link into an absolute link into the
   author's source folder — so an ordinary bundle failed the new containment check
   through no fault of its own. Found by the test asserting internal links are
   allowed. Copying verbatim is also the safer default: an escaping link stays
   exactly as escaping as the author wrote it, so the check judges the repository
   rather than an absolutised rewrite of it.

Separately, Update was broken for every locally-loaded extension. A local install
stores an absolute path in `repo`, and Update fed that to the GitHub installer,
so it failed `normalizeRepo` every time — on the one install path an author uses
for every rebuild. The ledger now records `origin`, Update dispatches on it, and
`extensions:update-local` reinstalls from the folder MAIN already has recorded.
The renderer passes only an id: it re-runs a directory the user chose in a native
picker, rather than becoming "install any path on the renderer's say-so". Old
ledger rows default to `github`, which is accurate — that was the only installer
writing rows when they were written.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
… ship

The guide described the pre-iframe platform. Section 3, "React must come from the
host", told authors to alias `react` to a shim reading
`globalThis.__agentCodeHost` — advice that is not merely stale but ACTIVELY
BREAKING: an extension runs in a cross-origin frame that cannot see the host's
globals, so the shim reads undefined at module-evaluation time and the extension
dies before activate(). Every extension written to the published guide would have
failed, and the SDK's own build preset said the opposite.

The rest had drifted the same way. §5 claimed workspace/session access "needs a
capability model and a consent flow that do not exist yet" — both exist. §9
claimed "no process isolation… the threat model is 'I made a mistake', not
'someone is attacking me'" and "mount: 'modal' only" — the iframe is the whole
architecture now, and 'panel' panes ship.

Rewritten around the one fact everything else follows from: your extension is a
web page in its own origin. That reframing is what makes the rest derivable
rather than memorised — bundle your own dependencies, you have no network, you
cannot reach the host, your document is destroyed when the view closes.

Newly documented because it was true and unsaid: there is no network access at
all (connect-src is self-only, so `fetch` elsewhere fails); keystrokes never
leave a cross-origin frame, so Escape and the palette do not fire while focus is
inside a view, and an author must provide their own way out; internal symlinks
must be relative; the local-folder dev loop; and the honest state of
activationEvents, which are validated and then never fire.

Also implements the ViewMount cleanup contract instead of documenting it twice.
`(element) => void | (() => void)` was in the type, in moduleContract, and in this
guide — and the frame discarded the return value, so a view that started an
interval or an AudioContext leaked it on every close with no way for the author
to notice. Teardown is now view cleanup → deactivate() → subscriptions in
reverse: the reverse of establishment order, which is the only order in which a
later hook cannot depend on something an earlier one tore down.

Bumps the SDK submodule to v0.3.0, which drops the seven capabilities the host
refuses to install and corrects the preset's import specifier — that package
exists to turn runtime surprises into type errors, and it was doing the reverse.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
… bound the unpack

Findings from an adversarial review of the install pipeline. The containment story
was sound; durability was not.

**The commit point preceded two fallible steps.** The order was rm(finalDir) →
rename(staging) → computeBundleHash → writeLedger, so the installed version was
destroyed and the new one committed BEFORE the last two things that can throw.
Three ordinary failures broke it: a crash between the rm and the rename left no
bundle and a live ledger row; a throwing computeBundleHash (a concurrent remove,
ENOSPC, EACCES) left a bundle on disk with no row — and since nothing enumerates
EXTENSIONS_DIR, that directory is invisible to the UI and unreachable by
extensions:remove, a ghost nothing can delete; writeLedger throwing did the same,
and is reachable in combination with a disk-filling archive.

Now: hash in staging, then rename the previous version ASIDE, swap, write the
ledger, and restore the old bundle if anything fails. A failed update is a no-op
instead of an uninstall, which is the single most important property of an update
path. Hashing in staging is safe because staging is a sibling of the destination
by construction, so the rename moves the same inodes — the previous comment was
buying that guarantee a second time at the cost of putting a throwing call after
the point of no return.

**The 32 MB cap bounded only the COMPRESSED download.** gzip on repetitive
content runs 1000:1, so an archive that passed every check could write tens of
gigabytes into $HOME — from a pasted `owner/repo`, with no dialog at all, since a
Tier-0 manifest never prompts. Extraction is now polled against a 256 MB ceiling
and the tar child is SIGKILLed on breach. A file-COUNT cap covers the other half:
500,000 empty files compress to nothing and pass both byte caps, while costing a
stat each at install and a read each on every bundle hash.

**Ledger mutations were unserialised read-modify-write.** temp+rename makes a
write atomic, which is what its comment claimed — it does nothing about a lost
update. Two concurrent installs both read `[]` and one row is lost while its
bundle stays on disk; an install racing a remove of the same id resurrects the
extension the user just uninstalled, with a stale grant. All mutations now run
through one queue, remove drops the row before the bundle (so an interruption
leaves a reclaimable bundle rather than a broken row), and the Remove button —
the only action with no busy guard — has one.

**Nothing swept staging.** The dot-prefix comment claimed it kept staging "out of
the ledger's view of installed extension directories"; nothing enumerates that
directory, so there was no scan to be excluded from, and the false claim hid the
real problem. A startup sweep now reclaims `.staging-*` and `*.replacing-<uuid>`.
Startup is the only safe moment: no install can be in flight, so anything matching
is definitionally abandoned. It deliberately leaves unreferenced BUNDLE
directories alone — that is a different problem whose right answer is not silent
deletion from a directory holding installed code.

**My own `origin` migration was wrong and its comment was false.** It defaulted
pre-`origin` rows to 'github' on the claim that the GitHub installer was the only
one writing rows at the time. `installExtensionFromPath` shipped on this same
branch, so every Load-folder row took the GitHub Update path into
`normalizeRepo('/Users/…')` — reproducing the exact bug `origin` was added to fix.
Now migrated on `ref === 'local'`, which is the literal that installer writes.

Three smaller ones: a throwing cleanup `rm` in a `finally` REPLACED the real error
(reachable via Load folder on a tree with a mode-0000 directory), so a precise
InstallError became a raw errno; the tar stderr accumulator was unbounded on
attacker-influenced output; and viewBridge issued its own
extensionGrantedCapabilities call alongside frameHost's, so every frame open
recomputed the bundle hash twice while the IPC comment claimed it happened once.

The download deadline was also wrong in both directions: 32 MB inside 30s demands
a sustained 8.7 Mbps, so legitimate installs failed, and when the signal fired
mid-body it threw out of the `for await` — outside the try/catch that translates
it — producing the exact "The operation was aborted" string that translation
exists to prevent. Headers keep a total deadline; the body gets an idle budget
that rearms per chunk.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
…closed

Findings from an adversarial review of the capability and consent model. The
review confirmed the tautology is genuinely gone — the compared hashes now have
independent provenance — and found that most of what remained was the surface
around it describing something other than what it does.

**The consent dialog was wrong in three directions at once.** It rendered raw
enum names, so `• sessions.observe` gave the user no way to know it discloses the
absolute working directory of every open session — which for a working developer
enumerates client names, private repositories and employer directory layout. It
carried a warning icon and "these let the extension act outside its own sandbox"
for three read-only snapshots, which is the same click-through training as saying
too little; the capabilities that ACTED were removed because nothing implemented
them. And it showed `manifest.name` — attacker-chosen, length-bounded only —
while omitting the repo or folder the user actually typed, which AppsSettingsRow's
own header calls "the trust decision". It now leads with the source, describes
each capability in the user's terms via a Record keyed on the union (so a new
capability does not compile without a disclosure), and states the two limits that
bound the risk: it cannot change anything, and it has no network.

**The grant store accepted arbitrary capability strings.** `z.array(z.string())`
round-tripped anything into the Set frameHost checks against, so a grants file
written by an older build kept live-looking authorisations for the seven
capabilities that no longer exist. Now filtered per element against the real enum
— per element, not wholesale, because rejecting the whole array on one unknown
member would silently revoke the capabilities the user did approve.

**`bundleSha256` was write-only and its doc described a mechanism that does not
exist.** Nothing read it: the check recomputes from disk and compares against the
grants file, never a ledger row. But the type said it "is the integrity record the
capability grant binds to" and that a missing one fails closed. A persisted copy of
a security value that is deliberately not consulted is a trap — the obvious
optimisation ("the ledger already has the hash, skip the recompute") reinstates
exactly the tautology this branch removed — so the field is deleted rather than
documented.

**Revocation could fail to reach a live frame.** Teardown is a side effect of the
store losing an entry, and `refresh` deliberately leaves the store untouched when
the list IPC fails — so one failed list after a successful remove left the pane
mounted, the iframe running and the grant promise resolved, against a bundle main
had already deleted. Remove now drops the row locally when refresh fails, which is
not a guess: `extensionsRemove` resolved, so the extension is gone.

**computeBundleHash was unbounded on a path that runs per frame open.** The
installer's caps do not imply one — they bound a compressed download on the GitHub
path only, and the local-folder path has none — while every capability check
re-reads the whole tree in the process holding every agent session. Now bounded by
file count, total bytes and depth, throwing on breach because every caller already
treats a throw as "no capabilities". An empty directory now throws too, instead of
returning SHA-256("") — a well-formed digest for a bundle containing nothing is the
one input where a valid-looking hash is worse than an error. Files are also
`lstat`ed once rather than twice, which halves the syscalls and narrows the window
in which the tree can change between deciding what to hash and hashing it.

Three ordering and comment fixes: `extensions:remove` revokes the grant in a
`finally`, since a partial removal retaining capabilities is the worse of the two
failure modes; the local-install comment claimed the grant binds to the entry
bytes, which is the pre-fix behaviour and was half the original defect; and the
storage IPC header described a "Stage 1/Stage 2" architecture that shipped, with
its conclusion inverted — sender binding is unavailable not because identity is
missing but because the extension frame has no IPC channel at all, so the sender
is always the trusted broker.

Adds the grant-store suite, which did not exist: hash binding, replacement rather
than accumulation, revocation isolation, and hostile-file handling.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
Findings from an adversarial review of the pane and session lifecycle.

**BLOCKER: undo-close restored a leaf with no SessionMeta.** The earlier fix
stopped undo calling spawn() for an extension pane, but spawn does two things —
mint the id AND write `sessions[newId]` — and only the first was replaced. The
pane branch's setState returned `{ ...prev, tabs }`; the tab branch built
`freshSessions` and never merged it.

The result was an orphan leaf, and it failed in two directions at once.
renderWorkspaceLeaf has no missing-meta guard, so `meta?.kind ?? DEFAULT_PROVIDER`
resolved to 'claude', the extension short-circuit was skipped, and the restored
pane came back as a dead Claude pane with a live composer sending into nothing.
Then the 400 ms autosave classified the leaf as an orphan and closed it out of the
serialized tree — dropping the whole tab if it was the only leaf. On-screen and
on-disk diverged until relaunch, after which the pane was gone. This is exactly
the orphan class sessionOwnership was rewritten to make impossible, reached from
the other side. Both branches now write the metadata in the SAME updater as the
tree edit, because two setStates would leave a one-tick window in the same state.

**Two frames of one extension collided in the registry.** frameRegistry held one
dispatcher per extension id under a comment asserting "at most one visible frame
per extension" — an assumption the pane path broke on arrival, since
openExtensionViewInPane always splits a new leaf and a pane and a modal are
designed to coexist. The newer frame overwrote the older's dispatcher, and closing
the newer one deleted the entry outright while an older frame was still on screen,
so the extension went deaf and the command handler responded by opening yet
another pane. Now a stack: newest receives, and teardown hands back to the next
live frame.

**Dispatch rendered extension panes as Claude agents.** The badge fell through to
DEFAULT_PROVIDER and read "Claude"; the subtitle reported "starting" forever, or
"idle" after a relaunch, claiming an agent lifecycle that does not exist. Missed by
the isAgentSessionKind sweep because these are spelled
`=== 'terminal' ? … : <agent default>` — no banned `!== 'terminal'` to grep for,
identical defect. Same shape fixed in providerGlyph, where the shared `return '$'`
fallback silently handed an extension pane the shell prompt.

**An update left every open pane running the old bundle.** The component cache and
the mount-once effect made the frame URL constant, so nothing about a reinstall
reached a live frame — while install had already re-bound the grant to the new
bytes, so the stale frame could start failing capability calls it held. The cache
key and the frame URL now carry a bundle revision, so an update remounts and an
unrelated list refresh still does not.

`Exclude<SessionKind, 'terminal'>` was the type-level twin of the whole defect
class — five annotations meaning "an agent kind" that widened silently when the
union grew. Now `AgentProviderKind`.

Also: `killOwned` is the third renderer→main path carrying this kind and was
unfenced (it returned false by accident of empty ownership tables, not by
decision); `openExtensionViewInPane` read grid focus while Dispatch may be active,
splitting a pane the user cannot see, and wrote metadata unconditionally even when
splitLeaf declined the anchor, leaving an unowned row for autosave to drop; and
`extensionViewId`'s doc claimed rehydrate validates the extension is installed,
which happens at render time instead.

Removes `parentOrigin`, which was a required, attacker-supplied query parameter
threaded into the frame document and never read — every post targets '*', for a
documented reason. A required unused parameter reads as a security control that is
doing nothing.

Adds the ownership regression test: an extension pane must be OWNED (so autosave
keeps it) and NOT live (so rehydrate never spawns for it), asserted as the gap
between the two sets so collapsing them back into one fails.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
Juliusolsson05 and others added 3 commits August 27, 2026 19:12
…roject gap

Findings from an adversarial review of the build, packaging and CI slice. The
reviewer verified the end-to-end path in the built artifact rather than reasoning
about it — scheme registration provably lands before app.whenReady() in
out/main/index.js, the frame-src CSP survives into the built HTML, all 1253 src
files are type-checked, and in a CI-equivalent environment (HOME pointed at an
empty directory) the suite is 299/299 files green with coverage above every
threshold.

**The blocker was one I had just created.** The SDK submodule was re-pinned to a
commit reachable only from an unmerged feature branch. It clones today and stops
the moment that branch is deleted — GitHub's default one-click action after a
merge, automatic on squash — at which point `git clone --recurse-submodules`, the
release workflow's submodule init, and CI's `submodules: recursive` all fail
permanently, including for agent-code tags already released, because the
superproject commit hard-codes the SHA.

Fixed by tagging the SDK commit `v0.3.0` rather than by merging: a tag keeps the
object reachable regardless of the branch's lifetime, so the fuse is gone without
pre-empting review of the SDK change itself (opened as
Juliusolsson05/agent-code-extension-api#1).

**The shipped guide told authors to import a package with no install route.** It
is not on npm (404) and its GitHub release carries no asset, so every author
following §3 failed at `npm install`. The only working route — `npm i -D
github:…`, which works because `dist/` is committed — was documented nowhere. Now
it is, along with the fact that the SDK is optional: an extension is a plain ES
module and nothing in the host requires depending on it.

**A `*.test.tsx` outside the renderer naming convention ran in NO project.** The
unit project included only `.ts` and the renderer project takes only
`*.renderer.test.tsx`, so `Foo.test.tsx` matched nothing: vitest ran it nowhere,
reported success, and the contract script — which greps for `.only`, not for
project membership — agreed. A test that silently never runs is worse than a
missing one, because the coverage it appears to provide is counted. All ~300
current files map to exactly one project, so this closes a hole rather than
fixing a live miss. Widening the include required widening the tier excludes to
match: `*.renderer.test.tsx` matches the new glob too, and without the `.tsx`
excludes every renderer test was also collected into the node project — which is
exactly what happened on the first attempt, and is now pinned by the routing test.

Also corrects the preload header, which asserted that these methods had a single
call site in `apps/api/useAppHostApi.ts`. That module does not exist and five
files call them directly, so it stated a security-shaped invariant that was
false. The real rule is two rules: storage is extension-facing and
createAppHostApi is its chokepoint, while list/install/remove/granted-capabilities
are host-facing by design — an extension cannot reach them at all, having no
preload and no ipcRenderer.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
The generated frame document contained a literal script closing tag inside one of
the bootstrap's comments — the comment explaining how such a tag breaks the
document quoted the payload verbatim. An HTML tokenizer ends a script element at
the first `</script` it sees, with no regard for JavaScript context. So in a real
browser the module script terminated after ~300 characters of comment, and the
entire runtime — the API proxy, the mount logic, the dynamic import, teardown —
became inert text in the body. No extension frame could ever have worked.

Nothing caught it. It is valid TypeScript and valid JavaScript; six reviewers read
the source without seeing it; the existing "emits a bootstrap that parses as
JavaScript" test passed because `new Function` on a comment-only prefix parses
fine; and the test helper that extracts script bodies is itself non-greedy, so
every assertion built on it had been quietly examining the same truncated prefix.

Found by asserting a property of the EMITTED document rather than of the source:
closing tags must equal opening tags. That check, plus one that the module script
survives to its final statement, is now in the suite.

Also restores a guarantee that was silently lost. `registerCommand` and
`registerView` are documented — in the type, in the authoring guide, and in the
error messages — to reject an id the manifest does not declare. That check lived in
the host-realm ExtensionHost, which held the manifest; when activation moved into
the frame and that host was deleted, the check went with it and the bootstrap just
wrote into its maps. The failure it prevents is invisible: a handler under an
undeclared id can never be invoked by anything, so a typo produced a command that
did nothing, with no error anywhere. The declared ids now travel to the frame in
the config island, which is the only way the frame can enforce it — it has no one
to ask.

Two smaller corrections. An extension failure was cleared on the frame's `boot`
signal, but failures are keyed by extension and two frames of one extension can be
open at once — so a second frame merely STARTING wiped a real activate() failure
the first had just reported. Cleared on `ready` instead, since only a completed
activation is evidence of health. And `frame-ancestors` is now documented as
deliberately absent: no value expresses "the Agent Code renderer" when the host is
localhost in dev and opaque file:// in production, and `default-src 'none'` already
prevents any extension frame from embedding anything.

Guide corrections from the same review: command-to-view routing was described
incompletely, and contributed settings never said how to READ the value an author's
own settings row writes.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
`contributes.keybindings` was wired end to end except for the last step. The
binding index included contributed defaults, the keydown handler matched the
chord, selected the contributed command id, called preventDefault and queued the
invocation — and then `dispatchCommand` resolved the id against
`builtInCommandCatalog` only, found nothing, and returned `status: 'unknown'`,
which the keybinding path does not inspect.

So every manifest-declared shortcut was a silent no-op that ALSO swallowed
whatever the chord would otherwise have done. Clicking the same command in the
palette worked, which is why it looked fine: that path runs an already-resolved
row and never consults the catalog.

Extension commands are derived per render from the installed manifests, so they
cannot be in the frozen module-scope catalog. The gateway now accepts them as an
injected `extraCommands` list — injected rather than imported so the module stays
React-free and pure, and because the palette already holds the derived list and is
the only caller that needs it. Built-ins are still resolved first: contributed ids
are namespaced at install and cannot collide, but this lookup should not depend on
that validator being right.

Tests assert all three halves: a contributed id does not resolve without the list,
it resolves AND runs with it, and a contributed command cannot shadow a
first-party id even if the namespacing were bypassed.

Refs #577

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hyzz9bxqTQ2zSawmHDN72o
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.

1 participant