feat(extensions): extension platform — host user-authored apps inside Agent Code - #577
feat(extensions): extension platform — host user-authored apps inside Agent Code#577Juliusolsson05 wants to merge 36 commits into
Conversation
Architecture settled — Stage 1 plan pushedFour consultants ran under orchestration run Decision: Stage 1 is compiled-in, built as Stage 2's substrate. What changed from the investigation's assumptionsStage 2's loader is one line, not a multi-week problem. Two objections to the iframe option were refuted. Paint order survives (the iframe wrapper The API is transport-independent. Same schemas for compiled-in, runtime-loaded, utility-process The two rules that make Stage 2 a swap
The plan ends with the grep that proves rule 1 held. If it fails, Stage 1 built something Stage 2 Stage 2 is conditional, not scheduledTrigger: extensions shipping to people who run packaged releases. The owner runs 📄 |
Stage 1 implemented — all six tasksSix commits, 28 files, +2668. Host built before its first consumer so the
What's in it
The test that mattersOnly the ABI types and its own file. The Timer directory should lift into its own repo in Deviations from the plan, both found by reading
Still openTwo reviewers running ( No manual smoke run yet — |
Full extension platform — Phase B complete16 commits, 42 files, +5528. The compiled-in app approach was removed; this is now a real VS Code-style Phase B
Verified, not assumedScheme spike — real built renderer, real CSP, Install — the real installer against a real repository: Plus error paths: unknown repo, private repo, malformed input. Manifest — the real timer manifest accepted, and nine rejection cases Production build — First real extension
It is already installed on this machine and will appear on next launch. Two bugs found by building the real thing
Not verifiedNobody has launched the app and clicked it. Everything above is automated |
…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>
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>
b0a7baa to
cd4a61a
Compare
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
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
…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
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: itcannot reach
window.api, the host DOM, another extension, or the network. Itsonly channel is postMessage, brokered in the parent and validated against a closed
schema.
What ships
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.
and bound to a hash of the installed bundle.
docs/extensions/authoring.md) and a types + build-presetSDK (
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
mainthat 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
</scriptregardless 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 Functionon a comment-only prefix parses fine. It is now caught by assertinga property of the emitted document rather than of the source.
Blockers fixed
fs.write,git.commit,network.fetchand four others showed a warning dialog and granted nothing. Removed; asking for one now fails install with a message.SessionKindwideningkind !== 'terminal', so every extension pane became an agent: agent-only commands enabled, pinnable to Dispatch, badged "Claude". Replaced with a positive predicate.dispatchCommandsearched only the built-in catalog and returnedunknown, which that path ignores. Silent no-op that also swallowed the chord.globalThis.__agentCodeHost, unreachable from a cross-origin frame. Rewritten around the architecture that ships.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,
Updateon a locally-loadedextension 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
ViewMountcleanup contractnever being called,
registerCommand/registerViewno longer rejecting undeclaredids, and Escape not closing an extension view.
~330 lines of the deleted host-realm activation path were removed along with the
__agentCodeHostglobal it existed to publish.Verification
tsc -bclean. Test contract, keybindings check, and live-resume probe pass. Suitegreen: 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.tsasserts a session JSONL exists in the developer's homedirectory. It fails identically on
main, and is inverted — green on CI, redlocally — because the corpus root does not exist on a runner.
Known limitations, stated deliberately
activationEventsare validated and never fire. An extension activates onfirst view open; there is no background execution. Documented in the guide.
focus is inside a view. Escape is forwarded as the one exception.
broker's tier record and its dispatch arm in the same change.
Refs #577