Skip to content

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

Merged
Juliusolsson05 merged 8 commits into
mainfrom
feat/extension-platform
Sep 13, 2026
Merged

Juliusolsson05 merged 8 commits into
mainfrom
feat/extension-platform

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Jul 20, 2026 •

Copy link
Copy Markdown
Owner

PR #577 turns the initial visible-frame prototype into an isolated extension platform with one managed API v2 runtime, independent sandboxed views, durable installation identity, and permissioned host services. It integrates main 173a5702 and is tracked by #934.

Implemented behavior

  • API v2 gives each extension one background runtime for commands and shared state. Views use separate entry modules and attach with host-minted instance identity. Startup, command, and view activation are explicit; commands work with no view open, and multiple windows share one runtime. An onStartupFinished extension can also be restarted by its own commands and views after a crash, deadline or failed activation.
  • API v1 remains a compatibility path for existing visible-view extensions.
  • Install/update publishes immutable bundle generations, full-byte identity, manifest, and grants as one recoverable transaction. Update, removal, navigation, and close revoke stale runtimes, frames, requests, and replies. Uninstall preserves extension-owned saved data. A bundle from a different source that claims an installed id is refused.
  • Extension panes participate in Dispatch placement, focus, detach/restore, close, and undo without spawning provider processes. Agent-only features (text delivery, drafts, prompts, conditions) refuse extension panes.
  • Native shortcuts route through the focused sandbox document by host-minted instance id. Contributed keybindings must be Cmd chords, and the host drops any chord a first-party feature, tab handler or macOS editing shortcut owns.
  • Declarative themes appear in the existing picker without executing extension code.
  • API v2 runtimes and views expose scoped fs.read/fs.write (bounded UTF-8, atomic compare-and-swap, editor serialization) and notifications.show (short, extension-attributed toasts in every window).
  • Startup/import/mount/deadline failures retain an actionable Settings cause, offer Retry, and destroy the failed document. A cancelled quit re-attaches paused views. Runtime admission, storage, requests, and shutdown are bounded.

Pre-merge review

Five Claude reviewers covered install lifecycle, runtime and services, frames and input, workspace integration, and the SDK. Commit ec17764a fixes the blocker and every should-fix finding:

  • Blocker: manifest keybindings could take over bare keys, Cmd+digit or editing chords app-wide.
  • Native input no longer drops shortcuts in hash-routed views, no longer lets a focused extension close the window, and passes Cmd+Left/Right through to extension text fields.
  • Bundle assets carry the child CSP, so a navigated SVG/HTML asset cannot reach the network.
  • Key Vault insertion can no longer persist a secret into an invisible extension-pane draft.
  • Legacy grants survive upgrade, read-only bundles are reclaimed, and the uninstall test exercises real storage.
  • Expected runtime replies are no longer charged to the rate limit; attach/detach cannot leak connections; runtime windows are excluded from the freeze watchdog.

Deferred as #959: per-row ledger quarantine after rolling back past a future API version.

Companions (merged)

Verification

  • npm run typecheck passes with the review fixes.
  • The affected unit, renderer and system suites pass: 735 tests across 104 files in src/main/extensions, src/main/incident, the renderer extension host, keybindings, text delivery, workspace control/commands/hook/tile tree, and test-tier routing. New regressions cover keybinding admission, first-party command collisions, fragment-changed frames, extension-pane text delivery, same-id installs, read-only bundles, legacy grants, and real-storage uninstall.
  • Earlier in this PR, both real Electron journeys passed with the committed Timer and Mini Games bundles. They were not rerun locally for the review-fix commit; root CI's quality gate runs both.

Not in this release

  • Transcript, Git, prompt and network services. Manifests that request them fail validation with a clear error.
  • Full application restart/layout acceptance and packaged-build verification. A quick manual check on a packaged build is recommended before release: install Timer, start it, close the panel, restart the app.

Execution plan: docs/superpowers/plans/2026-09-12-extension-platform-completion.md

Refs #934

🤖 Generated with Claude Code

https://claude.ai/code/session_01SnzR4ggdHfDfmFcTA7H8kw

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
Juliusolsson05 force-pushed the feat/extension-platform branch from b0a7baa to cd4a61a Compare July 28, 2026 19:47
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
…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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
…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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
…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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
… 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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
… 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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
…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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
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 added a commit that referenced this pull request Aug 28, 2026
…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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
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
Juliusolsson05 added a commit that referenced this pull request Aug 28, 2026
`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
Juliusolsson05 added a commit that referenced this pull request Sep 12, 2026
Record the accepted lifecycle, integration, recovery, API and verification work before continuing implementation on PR #577.

Refs #934
Add consented installation, immutable bundle publication, main-owned grants and storage, sandboxed views, and managed background runtimes for user-authored extensions.

Integrate commands, settings, panes, native input ownership, declarative themes, and scoped project file reads across the main, preload, renderer, and public SDK boundaries. Cover hostile install inputs, lifecycle recovery, capability revocation, storage durability, runtime/view coordination, and real Electron author journeys.
Juliusolsson05 and others added 4 commits September 12, 2026 17:09
Expose API v2 fs.write through the runtime and view brokers with generation-bound consent, live-session path authority, atomic compare-and-swap publication, editor cache invalidation, and bounded UTF-8 payloads. Extend the production Electron harness to install and exercise real external modal bundles, including the Mini Games v2 migration.
API v2 runtimes and views that declare the `notifications.show` permission
can post a short status message (at most 200 characters). Main verifies
consent against the installed generation and every window shows it as a
toast attributed to the extension, so a background engine such as Timer can
report completion with no view open. API v1 manifests reject the capability.

The SDK moves to 0.8.0 with matching types, contexts and fixtures. The
external-bundle Electron harness gains a background check: it runs a command
with no view mounted, reads the view, closes it, waits and requires the view
state to have advanced when it reopens.

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

A startup-only (`onStartupFinished`) API v2 extension could not be started by
its own command or view once its runtime was absent: after a crash, a command
deadline, a failed activation, or an install made before startup activation
was enabled. The host required an exact `onCommand`/`onView` match, so Timer's
panel never attached in the Electron journey until an app restart.

`onStartupFinished` now grants the same activation eligibility as `*`. Lazy
extensions keep exact event matching. The runtime journey covers a cold
command on an installed-but-stopped startup extension and verifies the later
startup pass reuses that engine instead of activating twice.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnzR4ggdHfDfmFcTA7H8kw
@Juliusolsson05
Juliusolsson05 marked this pull request as ready for review September 13, 2026 01:25
Juliusolsson05 and others added 2 commits September 12, 2026 19:20
SDK PR #1 merged as a merge commit (503bced), which includes its two
build-path review fixes. The previous pointer named a commit that existed
only on the feature branch, so a later branch cleanup would have made
`git submodule update` fail in CI and release builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnzR4ggdHfDfmFcTA7H8kw
A five-reviewer pass over the extension platform found one blocker and a
set of real edge defects.

The blocker: a manifest keybinding could claim any key app-wide. The
router consults extension defaults before its fixed handlers, so a bare
"a", Enter or Cmd+1 was swallowed from every composer and terminal.
Installs now require a Cmd chord, and load-time admission drops
first-party defaults, the fixed tab/resize handlers and macOS editing
chords. Built-in command ids always beat an extension declaring the same
dotted id.

Also fixed:
- native input matches frames by instance id, so hash-routed views keep
  app shortcuts and a focused extension can never close the window;
  Cmd+Left/Right reach extension text fields again
- bundle assets carry the child CSP, so an asset navigated to as a
  document cannot reach the network
- text delivery, draft/condition/prompt control tools, Undo Clear
  Composer and autosave refuse processless panes, which stops a Key Vault
  secret from being persisted into an invisible draft
- opening a view while a related-agent tab is selected splits the
  physical leaf instead of silently doing nothing
- legacy grants honour their original provenance binding; a same-id
  bundle from another source is refused instead of inheriting saved data;
  staging normalizes directory modes and the sweep continues past errors
- a cancelled quit re-attaches paused v2 views; expected runtime replies
  are not charged to the API budget; attach registers before its ABI
  gate so a racing detach cannot leak; runtime windows are excluded from
  the renderer freeze watchdog; failed frames discard queued commands

Deferred: per-row ledger quarantine after rolling back past a future API
version, tracked separately.

Refs #934

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnzR4ggdHfDfmFcTA7H8kw
@Juliusolsson05
Juliusolsson05 merged commit 4855c88 into main Sep 13, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the feat/extension-platform branch September 13, 2026 02:34
Juliusolsson05 added a commit that referenced this pull request Sep 20, 2026
Main gained three quit-time owners after this branch was cut, and each
had to be placed in the committed-shutdown composition rather than left
on before-quit, which runs before the renderer's unload vote:

- The extension runtime (#577). Its PAUSE stays on before-quit: it is
  the one preparation that must finish before windows unload, and it is
  reversible (Keep Editing resumes it). Its disposal is a new committed
  stage, `stopExtensions`, which runs beside sessions and workflows —
  extensions run user code — and only AFTER startup settles, because
  startup creates the runtime and an earlier stop would complete against
  nothing and keep that receipt.
- The performance monitor and trace drain (#958), with its 1.8s/2s
  bounded race, folded into the `performance` stage. Main also ran
  monitor/watchdog/performanceService stops in will-quit; those are
  already stages here, so the duplicates are dropped.
- tmux recovery now goes through restoration's canonical envelope
  decoder. A quit committed mid-read must not authorize killing tmux
  sessions, so the startup assertion moved inside the reader, where it
  becomes the decoder's read-failure path and withholds cleanup.

`workflowShutdownPromise`/`Complete` are gone with the before-quit
handler that used them; workflow stop is a committed stage.

The shutdown diagram was rewritten as a flowchart in main's current
convention (accTitle/accDescr/scope, #954) and says what the composition
actually does: the editor decides first and Keep Editing leaves every
service live; only a committed quit stops execution; a failed stop keeps
the app and its process lock. Re-rendered with the pinned toolchain;
every other SVG restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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