From e13a99109fc70d29988257bf88ba28c5313a16b8 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 12 Sep 2026 16:15:02 -0700 Subject: [PATCH 1/7] feat(extensions): add isolated extension platform 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. --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 4 +- .gitmodules | 3 + docs/extensions/authoring.md | 516 ++++++ .../2026-07-20-extension-platform-stage1.md | 1382 +++++++++++++++++ .../plans/2026-07-20-extension-platform.md | 814 ++++++++++ ...nsion-contributions-into-merged-systems.md | 200 +++ ...-07-22-full-extension-platform-buildout.md | 638 ++++++++ ...026-09-12-extension-platform-completion.md | 380 +++++ ...-07-20-extension-platform-investigation.md | 305 ++++ electron.vite.config.ts | 30 +- package-lock.json | 1 + package.json | 2 + packages/agent-code-extension-api | 1 + scripts/check-extension-frames.mjs | 117 ++ src/main/extensions/bundleHash.system.test.ts | 141 ++ src/main/extensions/bundleHash.ts | 148 ++ src/main/extensions/bundleLimits.ts | 5 + .../capabilityService.system.test.ts | 129 ++ src/main/extensions/capabilityService.ts | 135 ++ src/main/extensions/electron.system.test.ts | 32 + src/main/extensions/frameDocument.test.ts | 185 +++ src/main/extensions/frameDocument.ts | 522 +++++++ src/main/extensions/grants.system.test.ts | 118 ++ src/main/extensions/grants.ts | 160 ++ src/main/extensions/install.test.ts | 67 + src/main/extensions/install.ts | 763 +++++++++ .../installLifecycle.system.test.ts | 226 +++ .../extensions/installTree.system.test.ts | 170 ++ src/main/extensions/ledger.ts | 226 +++ src/main/extensions/manifest.test.ts | 217 +++ src/main/extensions/manifest.ts | 326 ++++ src/main/extensions/nativeInput.ts | 86 + src/main/extensions/runtimeDocument.ts | 106 ++ src/main/extensions/runtimeIpc.ts | 75 + src/main/extensions/runtimeService.ts | 466 ++++++ src/main/extensions/runtimeViews.ts | 106 ++ src/main/extensions/scheme.ts | 307 ++++ src/main/extensions/storage.system.test.ts | 132 ++ src/main/extensions/storage.ts | 191 +++ .../extensions/testing/electronHarness.ts | 312 ++++ .../extensions/testing/electronPreload.ts | 6 + src/main/extensions/testing/runtimeHarness.ts | 329 ++++ src/main/index.ts | 86 +- src/main/ipc/extensions.ts | 236 +++ src/main/ipc/index.ts | 5 + src/main/remote/RemoteServer.ts | 2 +- src/main/sessionManager.ts | 36 +- src/main/storage/paths.ts | 33 + src/preload/api/extensions.ts | 96 ++ src/preload/api/index.ts | 2 + src/preload/extensionRuntime.ts | 31 + src/renderer/index.html | 18 +- .../app-state/settings/customAppearance.ts | 85 +- .../extensionThemes.renderer.test.tsx | 99 ++ .../src/app-state/settings/extensionThemes.ts | 21 + .../src/app-state/settings/persistence.ts | 4 + src/renderer/src/app-state/settings/slice.ts | 10 +- src/renderer/src/app-state/settings/theme.ts | 21 +- src/renderer/src/app-state/settings/types.ts | 6 +- src/renderer/src/app-state/types.ts | 7 + src/renderer/src/app-state/uiShell/slice.ts | 29 +- src/renderer/src/app-state/uiShell/types.ts | 46 + src/renderer/src/app/main.tsx | 11 +- .../src/app/surfaces/GlobalModals.tsx | 7 +- src/renderer/src/app/surfaces/registry.tsx | 8 + src/renderer/src/app/surfaces/types.ts | 26 + src/renderer/src/apps/api/createAppHostApi.ts | 161 ++ src/renderer/src/apps/api/types.ts | 145 ++ .../apps/host/InstalledExtensionsLoader.tsx | 21 + src/renderer/src/apps/host/derive.ts | 207 +++ src/renderer/src/apps/host/frameHost.ts | 231 +++ src/renderer/src/apps/host/frameProtocol.ts | 151 ++ src/renderer/src/apps/host/frameRegistry.ts | 98 ++ ...installedExtensionsState.renderer.test.tsx | 219 +++ .../src/apps/host/installedExtensionsState.ts | 51 + .../apps/host/nativeInput.renderer.test.ts | 128 ++ src/renderer/src/apps/host/nativeInput.ts | 53 + .../src/apps/host/testing/electronHarness.tsx | 92 ++ src/renderer/src/apps/host/viewBridge.tsx | 416 +++++ .../src/apps/surfaces/AppHostSurface.tsx | 93 ++ src/renderer/src/apps/types.ts | 80 + src/renderer/src/apps/ui/AppsSettingsRow.tsx | 273 ++++ .../src/apps/ui/ExtensionSettingRow.tsx | 110 ++ src/renderer/src/components/ui/README.md | 24 + .../features/command-keybindings/normalize.ts | 357 +---- .../command-palette/executeCommand.test.ts | 59 +- .../command-palette/executeCommand.ts | 41 +- .../command-palette/lib/sortCommands.ts | 19 +- .../src/features/command-palette/registry.ts | 47 +- .../src/features/command-palette/types.ts | 11 + .../command-palette/ui/CommandPalette.tsx | 36 +- .../ui/useCommandExecutionRequest.ts | 15 +- .../debug/surfaces/DebugSurfacesImpl.tsx | 4 +- .../surfaces/PinAgentsSurface.tsx | 3 +- .../prompt-templates/targetSession.test.ts | 8 +- .../prompt-templates/targetSession.ts | 10 +- .../src/features/reader/ui/ReaderView.tsx | 4 +- .../commands/replyToSelectionCommands.ts | 11 +- .../features/settings/hooks/useThemeSync.ts | 9 +- .../settings/lib/settingsCategories.ts | 10 + .../features/settings/lib/settingsRegistry.ts | 113 +- .../settings/ui/CommandKeybindingsRow.tsx | 38 +- .../settings/ui/KeyboardShortcutsModal.tsx | 20 +- .../src/features/settings/ui/SettingsList.tsx | 18 + .../src/features/settings/ui/SettingsPage.tsx | 12 +- .../features/settings/ui/ThemePickerRow.tsx | 27 + .../workspace/commands/paneCommands.ts | 30 +- .../features/workspace/lib/sessionDisplay.ts | 8 +- .../workspace/ui/AgentActivityModal.tsx | 10 +- src/renderer/src/ui/GlobalToast.tsx | 14 +- .../workspace/dispatch/DispatchAgentList.tsx | 25 +- .../workspace/dispatch/dispatchSelectors.ts | 8 +- .../src/workspace/dispatch/rowTitle.ts | 3 +- .../workspace/extensionPaneOwnership.test.ts | 107 ++ .../src/workspace/gridRelatedAgents.ts | 5 +- .../src/workspace/hook/actions/dispatch.ts | 9 +- .../extensionPlacement.renderer.test.tsx | 134 ++ .../src/workspace/hook/actions/pane.ts | 75 +- .../src/workspace/hook/actions/session.ts | 32 +- .../src/workspace/hook/actions/undoClose.ts | 146 +- src/renderer/src/workspace/hook/index.ts | 1 + .../workspace/hook/invalidation/effects.ts | 9 +- .../workspace/hook/persistence/rehydrate.ts | 9 +- .../src/workspace/sessionOwnership.ts | 17 +- .../workspace/tile-tree/AgentTerminalLeaf.tsx | 3 +- .../workspace/tile-tree/ExtensionViewLeaf.tsx | 126 ++ .../workspace/tile-tree/TileLeaf/labels.ts | 22 +- .../src/workspace/tile-tree/TileTree.tsx | 17 + ...cusModeKeyboardOwnership.renderer.test.tsx | 9 + .../src/workspace/tile-tree/useKeybinds.ts | 81 +- src/renderer/src/workspace/types.ts | 8 + src/shared/appearanceColors.ts | 85 + src/shared/keybindings.ts | 355 +++++ src/shared/types/extensionId.test.ts | 34 + src/shared/types/extensionId.ts | 28 + src/shared/types/extensionInput.ts | 10 + src/shared/types/extensionJson.ts | 37 + src/shared/types/extensionRuntime.test.ts | 49 + src/shared/types/extensionRuntime.ts | 64 + src/shared/types/extensionServices.ts | 42 + src/shared/types/extensionThemes.ts | 26 + src/shared/types/extensions.ts | 201 +++ src/shared/types/providerConfig.ts | 11 +- src/shared/types/providerKind.test.ts | 73 +- src/shared/types/providerKind.ts | 75 +- testing/README.md | 28 + testing/unit/testTierRouting.test.ts | 21 +- vitest.config.ts | 34 +- 149 files changed, 15868 insertions(+), 648 deletions(-) create mode 100644 docs/extensions/authoring.md create mode 100644 docs/superpowers/plans/2026-07-20-extension-platform-stage1.md create mode 100644 docs/superpowers/plans/2026-07-20-extension-platform.md create mode 100644 docs/superpowers/plans/2026-07-22-extension-contributions-into-merged-systems.md create mode 100644 docs/superpowers/plans/2026-07-22-full-extension-platform-buildout.md create mode 100644 docs/superpowers/plans/2026-09-12-extension-platform-completion.md create mode 100644 docs/superpowers/specs/2026-07-20-extension-platform-investigation.md create mode 160000 packages/agent-code-extension-api create mode 100644 scripts/check-extension-frames.mjs create mode 100644 src/main/extensions/bundleHash.system.test.ts create mode 100644 src/main/extensions/bundleHash.ts create mode 100644 src/main/extensions/bundleLimits.ts create mode 100644 src/main/extensions/capabilityService.system.test.ts create mode 100644 src/main/extensions/capabilityService.ts create mode 100644 src/main/extensions/electron.system.test.ts create mode 100644 src/main/extensions/frameDocument.test.ts create mode 100644 src/main/extensions/frameDocument.ts create mode 100644 src/main/extensions/grants.system.test.ts create mode 100644 src/main/extensions/grants.ts create mode 100644 src/main/extensions/install.test.ts create mode 100644 src/main/extensions/install.ts create mode 100644 src/main/extensions/installLifecycle.system.test.ts create mode 100644 src/main/extensions/installTree.system.test.ts create mode 100644 src/main/extensions/ledger.ts create mode 100644 src/main/extensions/manifest.test.ts create mode 100644 src/main/extensions/manifest.ts create mode 100644 src/main/extensions/nativeInput.ts create mode 100644 src/main/extensions/runtimeDocument.ts create mode 100644 src/main/extensions/runtimeIpc.ts create mode 100644 src/main/extensions/runtimeService.ts create mode 100644 src/main/extensions/runtimeViews.ts create mode 100644 src/main/extensions/scheme.ts create mode 100644 src/main/extensions/storage.system.test.ts create mode 100644 src/main/extensions/storage.ts create mode 100644 src/main/extensions/testing/electronHarness.ts create mode 100644 src/main/extensions/testing/electronPreload.ts create mode 100644 src/main/extensions/testing/runtimeHarness.ts create mode 100644 src/main/ipc/extensions.ts create mode 100644 src/preload/api/extensions.ts create mode 100644 src/preload/extensionRuntime.ts create mode 100644 src/renderer/src/app-state/settings/extensionThemes.renderer.test.tsx create mode 100644 src/renderer/src/app-state/settings/extensionThemes.ts create mode 100644 src/renderer/src/apps/api/createAppHostApi.ts create mode 100644 src/renderer/src/apps/api/types.ts create mode 100644 src/renderer/src/apps/host/InstalledExtensionsLoader.tsx create mode 100644 src/renderer/src/apps/host/derive.ts create mode 100644 src/renderer/src/apps/host/frameHost.ts create mode 100644 src/renderer/src/apps/host/frameProtocol.ts create mode 100644 src/renderer/src/apps/host/frameRegistry.ts create mode 100644 src/renderer/src/apps/host/installedExtensionsState.renderer.test.tsx create mode 100644 src/renderer/src/apps/host/installedExtensionsState.ts create mode 100644 src/renderer/src/apps/host/nativeInput.renderer.test.ts create mode 100644 src/renderer/src/apps/host/nativeInput.ts create mode 100644 src/renderer/src/apps/host/testing/electronHarness.tsx create mode 100644 src/renderer/src/apps/host/viewBridge.tsx create mode 100644 src/renderer/src/apps/surfaces/AppHostSurface.tsx create mode 100644 src/renderer/src/apps/types.ts create mode 100644 src/renderer/src/apps/ui/AppsSettingsRow.tsx create mode 100644 src/renderer/src/apps/ui/ExtensionSettingRow.tsx create mode 100644 src/renderer/src/workspace/extensionPaneOwnership.test.ts create mode 100644 src/renderer/src/workspace/hook/actions/extensionPlacement.renderer.test.tsx create mode 100644 src/renderer/src/workspace/tile-tree/ExtensionViewLeaf.tsx create mode 100644 src/shared/appearanceColors.ts create mode 100644 src/shared/keybindings.ts create mode 100644 src/shared/types/extensionId.test.ts create mode 100644 src/shared/types/extensionId.ts create mode 100644 src/shared/types/extensionInput.ts create mode 100644 src/shared/types/extensionJson.ts create mode 100644 src/shared/types/extensionRuntime.test.ts create mode 100644 src/shared/types/extensionRuntime.ts create mode 100644 src/shared/types/extensionServices.ts create mode 100644 src/shared/types/extensionThemes.ts create mode 100644 src/shared/types/extensions.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38d92980d..48362add8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,11 @@ jobs: - name: Install dependencies # --include=dev guards the NODE_ENV=production trap: a production # install strips electron-vite/vitest and every gate below with it. - run: npm ci --include=dev + run: | + npm ci --include=dev + # Electron 43 downloads lazily on first import. Keep that network + # activity in dependency setup, before the offline system tests. + node node_modules/electron/install.js - name: Validate testing contract run: npm run test:contract diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83518a0d5..49afc5405 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,7 @@ jobs: fi # WHY no hard-fail when both secrets are absent: every submodule - # repo (the five Juliusolsson05 packages/* repos and openai/codex) + # repo (every Juliusolsson05 packages/* repo and openai/codex) # is PUBLIC, so the plain HTTPS URLs in .gitmodules clone fine # anonymously. The exit-1 here predated the repos going public and # contradicted README.md's "no special access needed" (#506 @@ -198,7 +198,7 @@ jobs: fi # WHY no hard-fail when both secrets are absent: every submodule - # repo (the five Juliusolsson05 packages/* repos and openai/codex) + # repo (every Juliusolsson05 packages/* repo and openai/codex) # is PUBLIC, so the plain HTTPS URLs in .gitmodules clone fine # anonymously. The exit-1 here predated the repos going public and # contradicted README.md's "no special access needed" (#506 diff --git a/.gitmodules b/.gitmodules index e07969ae0..b5ab9aa09 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,6 +19,9 @@ [submodule "packages/workflow-mcp"] path = packages/workflow-mcp url = https://github.com/Juliusolsson05/workflow-mcp.git +[submodule "packages/agent-code-extension-api"] + path = packages/agent-code-extension-api + url = https://github.com/Juliusolsson05/agent-code-extension-api.git [submodule "packages/opencode-terminal-headless"] path = packages/opencode-terminal-headless url = https://github.com/Juliusolsson05/opencode-terminal-headless.git diff --git a/docs/extensions/authoring.md b/docs/extensions/authoring.md new file mode 100644 index 000000000..226dfb871 --- /dev/null +++ b/docs/extensions/authoring.md @@ -0,0 +1,516 @@ +# Writing an Agent Code extension + +> **Status:** this branch implements API versions **1 and 2**. API v2 requires +> the host and SDK changes in PR #577 and companion SDK PR #1. +> +> This document is the contract. Where it and the code disagree, the code wins and +> this document is the bug — it has been wrong before, in the way described in §3. + +An extension is a GitHub repository or local folder containing a manifest and +built ES modules. Agent Code validates and snapshots the bundle, then runs its +code in isolated browser contexts. Nothing is compiled into the app. + +``` +Settings → Extensions → owner/repo → Install +``` + +--- + +## 0. Runtime and view ownership + +API v2 separates two browser contexts. One sandboxed, hidden runtime owns an +extension's commands and shared state. Every open view runs a separate module in +its own sandboxed iframe at `agent-code-ext:///`. Closing the last +view leaves the runtime alive. A command can activate it without opening a view. + +A view owns DOM, theme and window-specific UI; a runtime has no implicit focused +window or project. Functions never cross this boundary. Views send named JSON +requests to the runtime and subscribe to published JSON state. Both contexts have +namespaced durable storage; module variables and browser localStorage are not a +persistence contract across app restart or replacement. + +Bundle your own dependencies, including React. There is no host React instance, +Node API or generic application preload available to extension code. Cross-origin +DOM access, external network requests, popups, top-level navigation, nested frames +and permission prompts are denied by the host's sandbox and policy. + +API v1 remains supported with its original per-view `activate/registerView` +contract. It does not gain a shared background runtime; see compatibility below. + +## 1. Manifest and activation + +Put `agent-code.extension.json` at the repository root: + +```json +{ + "id": "counter", + "name": "Counter", + "description": "Shared state across independent views.", + "version": "0.1.0", + "apiVersion": 2, + "entry": "dist/runtime.js", + "activationEvents": ["onCommand:counter.increment", "onView:counter.main"], + "contributes": { + "commands": [ + { "id": "counter.open", "title": "Counter: Open" }, + { "id": "counter.increment", "title": "Counter: Increment" } + ], + "views": [ + { "id": "counter.main", "title": "Counter", "mount": "panel", "entry": "dist/view.js" } + ], + "settings": [ + { "id": "counter.step", "title": "Increment amount", "type": "number", "default": 1 } + ] + } +} +``` + +| Field | Rules | +| --- | --- | +| `id` | `/^[a-z][a-z0-9-]{0,63}$/`; permanent storage namespace and origin host. | +| `name`, `description` | Required, nonempty display metadata. | +| `version` | Display text, recorded without version ordering. | +| `apiVersion` | This host accepts `1` and `2`; checked at install and load. | +| `entry` | Built runtime ES module for v2; legacy activation module for v1. | +| `contributes.views[].entry` | Required for v2. Built view ES module, independent of runtime. | +| `contributes.themes` | Up to 16 declarative palettes; see §7. No runtime activation or permission. | +| Entry paths | Relative `.js`/`.mjs` paths within the bundle; no `..`, backslashes or absolute paths. Installer validates actual files and symlink containment. | +| Contribution ids | Must start with `.`. | +| `permissions` | Only implemented capabilities are accepted; see §6. | + +Contributions are discoverable before any author code executes. A v2 cold runtime +starts only for an explicit event: `onStartupFinished` or `*` starts it after host +startup and after installation/update; `onCommand:` or +`onView:` permits that command or view to start it lazily. Absent +activation events never start a cold runtime. Once active, registered commands +execute without requiring another matching activation event. + +A command matching a view id opens that view. `.open` also opens the +view of a single-view extension. Other v2 commands run in the shared runtime and +return an acknowledged result/error; they do not open a view. Declare startup or +command activation for command-only extensions. + +## 2. Independent modules + +The SDK's `defineRuntime` and `defineView` helpers return their module objects. +Named `activate`/`mount` exports also work. See the +[SDK counter fixture](../../packages/agent-code-extension-api/examples/counter) +for a typed example built and installed by the real Electron integration test. + +```ts +// src/runtime.ts +import { defineRuntime } from 'agent-code-extension-api' + +export default defineRuntime({ + async activate(context) { + let count = (await context.api.storage.get('count')) ?? 0 + context.registerCommand('counter.increment', async () => { + const step = (await context.api.storage.get('counter.step')) ?? 1 + const next = count += step + // Shutdown revokes write authority and has a deadline; persist on mutation. + await context.api.storage.set('count', next) + await context.views.publish('counter.main', { count: next }) + return next + }) + context.registerRequest('readCount', (_input, view) => ({ count, instanceId: view.instanceId })) + await context.views.publish('counter.main', { count }) + }, +}) +``` + +```ts +// src/view.ts +import { defineView } from 'agent-code-extension-api' + +export default defineView<{ count: number }>({ + mount(element, context) { + const render = (state: { count: number } | undefined) => { + element.textContent = `Count: ${state?.count ?? 0}` + } + render(context.runtime.state()) + return context.runtime.subscribe(render) + }, +}) +``` + +`views.publish(viewId, json)` replaces that view type's latest state for all its +attached instances. `runtime.state()` reads the current snapshot, possibly +undefined before the first publication; `subscribe` receives subsequent changes. +`runtime.request(name, input)` invokes a registered request handler with validated +JSON and a host-issued `{ id, instanceId }`. Validate your application's input +shape in the handler; transport validation only establishes bounded JSON. + +Runtime messages allow at most 4096 JSON values, depth 32 and 128 Ki characters. +Startup has a 10-second deadline; an invocation has 30 seconds, with at most 32 +pending calls per extension. Keep published state compact and coalesce rapid UI +changes instead of treating the bridge as an unbounded event stream. + +Closing a view rejects pending replies and prevents calls still awaiting startup +from dispatching. An already dispatched action may finish. Requests and commands +are never automatically replayed; a command timeout reports an unknown outcome. + +View `mount` and returned cleanup are synchronous. Mount runs after runtime +attachment and theme delivery. Runtime `deactivate` and subscriptions may be async, +but normal retirement revokes API authority immediately and allows at most 500 ms +for cleanup. Forced termination may skip cleanup entirely. Use subscriptions to +release local timers/listeners; save durable state when it changes. + +### View placement and keyboard behavior + +`mount: "modal"` opens an automatically sized floating dialog; `"panel"` opens a +workspace pane. Restored panes retain their extension/view identity and display a +missing-extension state when the bundle is unavailable. + +Application shortcuts inside cross-origin views use the host's configured +keybindings. Genuine keyboard input selects the focused extension pane before +routing its command, so closing a pane targets that view. In a modal, palette +access and the configured close command remain available; close dismisses the +modal, while native editing stays in the view. Extension-dispatched DOM events +and messages cannot invoke this application shortcut route. + +Escape is forwarded to the host unless the view calls +`event.preventDefault()`; unhandled Escape closes the view. Provide visible +controls where needed, and use `context.api.ui.close()` to close your own view. + +### API v1 compatibility + +A v1 entry exports `activate(context)` and optionally `deactivate()`, or uses +`defineExtension`. Its context supports `registerCommand` and +`registerView(id, element => cleanup)`. That module activates separately in every +visible view. Its activationEvents are validated metadata, with no background +execution. A cold non-view command opens the sole declared view to dispatch; a +viewless v1 extension cannot execute an action cold. + +Legacy view cleanup, deactivate and subscriptions run synchronously on document +teardown, with subscriptions visited in reverse order. Migrate shared command/state +ownership to a v2 runtime, move DOM work to view modules, declare each view's entry, +and choose explicit activation events. Changing only `apiVersion` is insufficient. + +--- + +## 3. React (and every other dependency): bundle it + +**Bundle your own React. Normally. Like any web app.** + +If you have seen an older version of this document telling you to alias `react` to +`globalThis.__agentCodeHost` — that guidance was for a previous architecture where +extensions ran inside Agent Code's own renderer and would have collided with the +host's React instance. **It is wrong now and will break your extension**, because a +cross-origin frame cannot see the host's globals at all: the shim would read +`undefined` at module-evaluation time and your extension would fail before +`activate()` ran. + +Your frame is its own document with its own realm. Two React instances in two +documents are not a conflict; they never meet. + +The SDK ships types and a Vite preset that produces the right output. It is **not +on npm** — install it from GitHub, which works because its `dist/` is committed: + +```bash +npm i -D github:Juliusolsson05/agent-code-extension-api +``` + +```ts +// vite.config.ts +import { extensionViteConfig } from 'agent-code-extension-api' +export default extensionViteConfig({ + entries: { runtime: 'src/runtime.ts', view: 'src/view.ts' }, +}) +``` + +The SDK is optional. It gives you `ExtensionManifest`, `ExtensionContext` and +`AgentCodeApiV1` types plus the build preset; an extension is a plain ES module and +nothing in the host requires you to depend on it. + +--- + +## 4. Build output + +Two hard requirements: + +1. **Built ES modules** at the runtime and every v2 view entry path. Commit all + imported shared chunks too; the installer serves contained bundle assets. +2. **Committed to the repository.** The installer downloads your repo's source + tarball, not a release asset, so a `dist/` that only exists in CI means every + install fails with *"Manifest points at … which does not exist"*. + +For a legacy v1 single-entry build without the preset: + +```ts +build: { + lib: { entry: 'src/index.ts', formats: ['es'], fileName: () => 'index.js' }, + rollupOptions: { output: { inlineDynamicImports: true } }, + cssCodeSplit: false, +}, + +// REQUIRED if you depend on anything from the React ecosystem. +define: { 'process.env.NODE_ENV': JSON.stringify('production') }, +``` + +### ⚠️ `process is not defined` — read this before you debug it + +**Vite's library mode does not replace `process.env.NODE_ENV`.** A normal app build +does; a library is expected to leave it for the consuming bundler. There is no +consuming bundler here — your output is loaded straight into a browser document +where `process` does not exist. + +Nearly every React-ecosystem package guards its dev warnings with +`process.env.NODE_ENV !== "production"`, so the first one to execute throws and +your extension fails during `activate()` — **after** installing and importing +cleanly, which makes it look like your code rather than your build. + +The `define` above is the fix. Do **not** shim a global `process` object instead: +libraries would then take Node code paths inside a browser realm, which fails later +and far less obviously. + +**CSS must be loaded by your view.** The host does not automatically request +emitted sibling stylesheets. Import CSS with `?inline` and inject a `', + '', + '', + '
', + // The config island. `<` is escaped to \u003c so no value can close this element + // (or open another) regardless of what the manifest or query string contained. + // JSON.parse reads \u003c back as `<`, so values round-trip exactly. + ``, + ``, + '', + '', + ].join('\n') +} diff --git a/src/main/extensions/grants.system.test.ts b/src/main/extensions/grants.system.test.ts new file mode 100644 index 000000000..278ec4014 --- /dev/null +++ b/src/main/extensions/grants.system.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +// STATE_DIR is resolved at module load from homedir(), so the grant store's file +// location is fixed before any test can influence it. Mocking the paths module is +// the narrowest seam that keeps these tests off the developer's real +// ~/.config/agent-code — which they must be, because they write grants. +const stateRoot = await mkdtemp(join(tmpdir(), 'agent-code-grants-')) +vi.mock('@main/storage/paths.js', () => ({ + STATE_DIR: stateRoot, + EXTENSIONS_DIR: join(stateRoot, 'extensions'), + EXTENSIONS_LOCKFILE: join(stateRoot, 'extensions.json'), + EXTENSION_STATE_DIR: join(stateRoot, 'extension-state'), +})) + +const { grantedCapabilities, recordGrant, revokeGrant } = await import( + '@main/extensions/grants.js' +) + +const HASH_A = 'a'.repeat(64) +const HASH_B = 'b'.repeat(64) + +afterEach(async () => { + await rm(join(stateRoot, 'extension-grants.json'), { force: true }) +}) + +describe('grant binding', () => { + it('returns the granted capabilities for the hash they were approved at', async () => { + await recordGrant('timer', HASH_A, ['workspace.observe', 'sessions.observe']) + const granted = await grantedCapabilities('timer', HASH_A) + expect([...granted].sort()).toEqual(['sessions.observe', 'workspace.observe']) + }) + + // ── THE REGRESSION THIS FILE EXISTS FOR ── + // The grant used to be checked against a hash READ BACK OUT OF THE LEDGER, which + // the same install had just written — so the comparison compared two copies of + // one value and could never fail. The documented rule ("different bytes, no + // grant") did not exist. This is that rule, stated as a test. + it('returns NOTHING for a different hash', async () => { + await recordGrant('timer', HASH_A, ['workspace.observe']) + expect([...(await grantedCapabilities('timer', HASH_B))]).toEqual([]) + }) + + it('returns nothing for an extension that was never granted anything', async () => { + expect([...(await grantedCapabilities('never-installed', HASH_A))]).toEqual([]) + }) + + it('replaces the previous grant rather than accumulating', async () => { + // An update that asks for fewer capabilities must not inherit the old set. + await recordGrant('timer', HASH_A, ['workspace.observe', 'sessions.observe']) + await recordGrant('timer', HASH_B, ['workspace.observe']) + expect([...(await grantedCapabilities('timer', HASH_B))]).toEqual(['workspace.observe']) + // And the superseded hash grants nothing at all. + expect([...(await grantedCapabilities('timer', HASH_A))]).toEqual([]) + }) + + it('revokes', async () => { + await recordGrant('timer', HASH_A, ['workspace.observe']) + await revokeGrant('timer') + expect([...(await grantedCapabilities('timer', HASH_A))]).toEqual([]) + }) + + it('keeps other extensions untouched when one is revoked', async () => { + await recordGrant('timer', HASH_A, ['workspace.observe']) + await recordGrant('notes', HASH_A, ['panes.observe']) + await revokeGrant('timer') + expect([...(await grantedCapabilities('notes', HASH_A))]).toEqual(['panes.observe']) + }) +}) + +describe('grant store hostile-file handling', () => { + async function writeGrantsFile(contents: string): Promise { + await mkdir(stateRoot, { recursive: true }) + await writeFile(join(stateRoot, 'extension-grants.json'), contents, 'utf8') + } + + it('drops a capability the host does not implement', async () => { + // The seven Tier 2/3 capabilities were removed from the manifest schema. A + // grants file written by an older build still names them, and the store used to + // parse capabilities as `z.array(z.string())` — carrying them forward verbatim + // into the Set that frameHost checks against. Parsing against the real enum + // makes the migration automatic and means the store cannot hold an authorisation + // for a power that does not exist. + await writeGrantsFile( + JSON.stringify([ + { + extensionId: 'timer', + sha256: HASH_A, + capabilities: ['workspace.observe', 'fs.write', 'network.fetch'], + grantedAt: 1, + }, + ]), + ) + expect([...(await grantedCapabilities('timer', HASH_A))]).toEqual(['workspace.observe']) + }) + + it('survives a corrupt file rather than throwing', async () => { + // A throw here would propagate into the capability check, and the check's + // callers treat a throw as an error rather than as "no capabilities". Degrading + // to an empty store is both fail-closed and recoverable. + await writeGrantsFile('{ not json at all') + expect([...(await grantedCapabilities('timer', HASH_A))]).toEqual([]) + }) + + it('drops a malformed row without discarding valid siblings', async () => { + await writeGrantsFile( + JSON.stringify([ + { extensionId: 'broken', sha256: 'not-a-hash', capabilities: [], grantedAt: 1 }, + { extensionId: 'timer', sha256: HASH_A, capabilities: ['panes.observe'], grantedAt: 2 }, + ]), + ) + expect([...(await grantedCapabilities('timer', HASH_A))]).toEqual(['panes.observe']) + expect([...(await grantedCapabilities('broken', HASH_A))]).toEqual([]) + }) +}) diff --git a/src/main/extensions/grants.ts b/src/main/extensions/grants.ts new file mode 100644 index 000000000..5a0458de1 --- /dev/null +++ b/src/main/extensions/grants.ts @@ -0,0 +1,160 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises' + +import { computeBundleHash } from './bundleHash.js' +import { extensionBundleDirectory, readLedger, withLedgerLock } from './ledger.js' +import { join } from 'path' + +import { z } from 'zod' + +import { STATE_DIR } from '@main/storage/paths.js' +import { EXTENSION_CAPABILITIES } from '@shared/types/extensions.js' +import type { ExtensionCapability } from '@shared/types/extensions.js' +import { extensionRevision } from '@shared/types/extensions.js' + +// Legacy grants are retained for installations written before immutable bundle +// generations. New installs publish approval with the bundle identity in the +// ledger, avoiding a second independently committed file. Only the fallback in +// installedExtensionCapabilities reads this store for runtime authorization. +// Both formats compare approval against a digest freshly computed from disk; +// provenance hashes cannot detect changed installed files. + +const GRANTS_FILE = join(STATE_DIR, 'extension-grants.json') + +const grantSchema = z.object({ + extensionId: z.string().min(1), + /** computeBundleHash() of the installed bundle at the moment consent was given. + * A different hash means the code changed, which means re-consent. */ + sha256: z.string().regex(/^[a-f0-9]{64}$/), + // ── VALIDATED AGAINST THE IMPLEMENTED SET, NOT `z.array(z.string())` ── + // The looser shape round-tripped ANY string into a Set that + // `frameHost.requireGrant` then does `.includes()` against. Two consequences: + // a grants file naming a capability this build does not implement was carried + // forward verbatim rather than dropped, and — once the seven unimplemented + // capabilities were removed from the manifest schema — every grant recorded for + // them under an older build stayed in the file as a live-looking authorisation + // for a power that no longer exists. Parsing against the real enum means the + // store cannot hold a capability the host cannot perform, and the migration + // away from the removed ones is automatic: unknown entries are dropped on read. + // Filtered PER ELEMENT, not validated wholesale. `z.array(z.enum(...))` rejects + // the entire array when any one member is unknown, which would turn "this grant + // mentions a retired capability" into "this extension has no grant at all" — + // silently revoking the capabilities the user did approve and still holds. + capabilities: z + .array(z.string()) + .transform(values => values.filter(isKnownCapability)), + grantedAt: z.number().finite(), +}) + +function isKnownCapability(value: string): value is ExtensionCapability { + return (EXTENSION_CAPABILITIES as readonly string[]).includes(value) +} + +type Grant = z.infer + +async function readGrants(): Promise { + let raw: string + try { + raw = await readFile(GRANTS_FILE, 'utf8') + } catch { + return [] + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + const rows: Grant[] = [] + for (const candidate of parsed) { + const result = grantSchema.safeParse(candidate) + if (result.success) rows.push(result.data) + } + return rows +} + +async function writeGrants(rows: Grant[]): Promise { + await mkdir(STATE_DIR, { recursive: true }) + const tmp = `${GRANTS_FILE}.tmp-${randomUUID()}` + try { + await writeFile(tmp, `${JSON.stringify(rows, null, 2)}\n`, 'utf8') + await rename(tmp, GRANTS_FILE) + } finally { + await rm(tmp, { force: true }).catch(() => {}) + } +} + +/** + * Record the user's approval of `capabilities` for one extension at one content + * hash. One row per extension id — a re-grant (a new install/update) replaces the + * previous row, so a downgrade in requested permissions cannot leave stale ones. + */ +export async function recordGrant( + extensionId: string, + bundleSha256: string, + capabilities: readonly ExtensionCapability[], +): Promise { + await withLedgerLock(async () => { + const rows = await readGrants() + await writeGrants([ + ...rows.filter(row => row.extensionId !== extensionId), + { extensionId, sha256: bundleSha256, capabilities: [...capabilities], grantedAt: Date.now() }, + ]) + }) +} + +/** + * The capabilities currently granted to an extension, but ONLY if the grant was + * given for exactly the bytes now installed. A grant for different bytes returns + * nothing — the capabilities were approved for code that is no longer what is + * running, so they must not carry over silently. + * + * `bundleSha256` MUST be a hash the caller just computed FROM DISK, not one read + * back out of the ledger. Passing a stored value makes this function compare the + * install record against itself, which is exactly how the check came to be a + * tautology in the first place. + */ +export async function grantedCapabilities( + extensionId: string, + bundleSha256: string, +): Promise> { + const rows = await readGrants() + const row = rows.find(candidate => candidate.extensionId === extensionId) + if (!row || row.sha256 !== bundleSha256) return new Set() + return new Set(row.capabilities) +} + +/** Drop legacy approval. New installations carry approval in their ledger row. */ +export async function revokeGrant(extensionId: string): Promise { + await withLedgerLock(async () => { + const rows = await readGrants() + const next = rows.filter(row => row.extensionId !== extensionId) + if (next.length !== rows.length) await writeGrants(next) + }) +} + +/** + * Resolve consent and actual code from one committed installation snapshot. + * New records publish their manifest permissions and content hash atomically; + * only old records consult the legacy grants file. Re-hashing remains mandatory: + * matching two values read from the ledger would authorize hand-modified code. + */ +export async function installedExtensionCapabilities(extensionId: string, expectedRevision?: string): Promise { + return withLedgerLock(async () => { + const row = (await readLedger()).find(entry => entry.manifest.id === extensionId) + if (!row) return [] + // A slow frame start may cross an update. Never give that old frame the new + // generation's approval just because it still has the same extension ID. + if (expectedRevision !== undefined && extensionRevision(row) !== expectedRevision) return [] + try { + const actual = await computeBundleHash(extensionBundleDirectory(row)) + if (row.installation) { + return actual === row.installation.bundleSha256 ? [...(row.manifest.permissions ?? [])] : [] + } + return [...await grantedCapabilities(extensionId, actual)] + } catch { + return [] + } + }) +} diff --git a/src/main/extensions/install.test.ts b/src/main/extensions/install.test.ts new file mode 100644 index 000000000..0f4ad69a4 --- /dev/null +++ b/src/main/extensions/install.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' + +import { InstallError, normalizeRepo } from '@main/extensions/install.js' + +// `normalizeRepo` is the only pure, network-free part of the installer, and it is +// the part whose output is interpolated into two URLs and then persisted. The rest +// of install.ts needs a real filesystem and a real network and is covered by +// install.system.test.ts. + +describe('normalizeRepo — accepted forms', () => { + it.each([ + ['bare', 'owner/repo'], + ['https', 'https://github.com/owner/repo'], + ['https with www', 'https://www.github.com/owner/repo'], + ['http', 'http://github.com/owner/repo'], + ['ssh', 'git@github.com:owner/repo'], + ['trailing .git', 'https://github.com/owner/repo.git'], + ['trailing slash', 'https://github.com/owner/repo/'], + ['surrounding whitespace', ' owner/repo '], + ])('normalizes the %s form to owner/repo', (_label, input) => { + expect(normalizeRepo(input)).toBe('owner/repo') + }) + + it('keeps dots and dashes inside a real name', () => { + // Real repositories are named this way (`user.name/my-repo.js`), so the + // dot-rejection below must not be a blanket ban on the character. + expect(normalizeRepo('user.name/my-repo.js')).toBe('user.name/my-repo.js') + }) +}) + +describe('normalizeRepo — rejected forms', () => { + // ── THE REGRESSION THIS BLOCK EXISTS FOR ── + // The character class `[\w.-]+` matches `.` and `..`, so `../x` parsed cleanly + // into owner `..`. The result is interpolated into + // `https://api.github.com/repos/${repo}` and into the codeload URL, where the URL + // parser NORMALIZES the `..` away — so the request addressed a different GitHub + // endpoint than the code believed it was calling, and the bogus value was then + // written to the ledger and rendered in Settings. + it.each([ + ['parent traversal as owner', '../repo'], + ['parent traversal as repo', 'owner/..'], + ['both segments traversal', '../..'], + ['single dot as owner', './repo'], + ['single dot as repo', 'owner/.'], + ['triple dot', '.../repo'], + ])('rejects %s', (_label, input) => { + expect(() => normalizeRepo(input)).toThrow(InstallError) + }) + + it.each([ + ['a non-GitHub host', 'https://gitlab.com/owner/repo'], + ['three segments', 'owner/repo/extra'], + ['one segment', 'owner'], + ['empty', ''], + ['a space inside', 'own er/repo'], + ['a query string', 'https://github.com/owner/repo?x=1'], + ['a URL fragment', 'https://github.com/owner/repo#readme'], + ])('rejects %s', (_label, input) => { + expect(() => normalizeRepo(input)).toThrow(InstallError) + }) + + it('names the offending input in the message so the user can fix it', () => { + // The user typed this string; an error that does not quote it back is not + // actionable in a Settings row that shows nothing else. + expect(() => normalizeRepo('not a repo')).toThrow(/"not a repo"/) + }) +}) diff --git a/src/main/extensions/install.ts b/src/main/extensions/install.ts new file mode 100644 index 000000000..9f70b07ea --- /dev/null +++ b/src/main/extensions/install.ts @@ -0,0 +1,763 @@ +import { spawn } from 'child_process' +import { createHash, randomUUID } from 'node:crypto' +import { + access, + constants as fsConstants, + cp, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rename, + rm, + writeFile, +} from 'fs/promises' +import { join, relative, resolve as resolvePath, sep } from 'path' + +import { EXTENSIONS_DIR } from '@main/storage/paths.js' +import { computeBundleHash } from '@main/extensions/bundleHash.js' +import { BUNDLE_MAX_BYTES, BUNDLE_MAX_DEPTH, BUNDLE_MAX_ENTRIES } from './bundleLimits.js' +import { ManifestError, parseExtensionManifest } from '@main/extensions/manifest.js' +import { discardExtensionBundle, extensionBundleDirectory, readLedger, withLedgerLock, writeLedger } from '@main/extensions/ledger.js' +import type { ExtensionManifest, InstalledExtension } from '@shared/types/extensions.js' + +/** + * Asked to approve an extension's requested capabilities before it is installed. + * Returns true to proceed. Injected (rather than calling an Electron dialog here) + * so install stays a pure pipeline — the IPC layer supplies the real prompt. + */ +export type ConsentPrompt = (manifest: ExtensionManifest) => Promise + +const MANIFEST_FILENAME = 'agent-code.extension.json' + +// 32 MB. An extension is a built JS bundle plus assets; anything larger is either a +// mistake (someone committed node_modules) or hostile. The cap exists because the +// download is buffered in memory to hash it — see downloadTarball. +const MAX_TARBALL_BYTES = 32 * 1024 * 1024 + +// How long a request may make NO progress before it is abandoned. Applied as a +// total deadline to the two small metadata calls, and as an IDLE budget to the +// download — see downloadTarball for why a total deadline is wrong there. +const NETWORK_TIMEOUT_MS = 30_000 + +// The EXTRACTED ceiling, which is a different quantity from MAX_TARBALL_BYTES and +// the one that actually protects the disk. 256 MB is ~8x the compressed cap and +// orders of magnitude above any real extension; anything past it is a bomb or a +// mistake, and either way is not something to unpack into the user's home +// directory. +const MAX_EXTRACTED_BYTES = 256 * 1024 * 1024 + +// A file-COUNT ceiling. Neither byte cap implies one — half a million empty files +// compress to nothing — and every file costs a stat at install plus a read on every +// bundle hash. See assertBundleTreeIsSafe. +const MAX_BUNDLE_FILES = BUNDLE_MAX_ENTRIES + +export class InstallError extends Error { + constructor(message: string) { + super(message) + this.name = 'InstallError' + } +} + +/** + * Accepts what a user is likely to paste and normalizes to `owner/repo`. + * + * Deliberately permissive about the input format and strict about the output: every + * later step (API URL construction, ledger key) assumes `owner/repo` with no path + * traversal or query string in it. + */ +export function normalizeRepo(input: string): string { + const trimmed = input.trim().replace(/\.git$/, '').replace(/\/+$/, '') + // https://github.com/owner/repo, git@github.com:owner/repo, or owner/repo + const match = + /^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:)?([\w.-]+)\/([\w.-]+)$/.exec(trimmed) + if (!match) { + throw new InstallError( + `"${input}" is not a GitHub repository. Use owner/repo or a github.com URL.`, + ) + } + + // ── A SEGMENT OF ONLY DOTS IS NOT A REPOSITORY NAME, IT IS PATH TRAVERSAL ── + // `[\w.-]+` matches `.` and `..`, so `../x` parsed happily into owner `..`. The + // result is interpolated into `https://api.github.com/repos/${repo}` and into the + // codeload URL, where the URL parser NORMALIZES the `..` away — so the request + // silently addressed a different GitHub API endpoint than the one this code + // believes it is calling, and the bogus value was then recorded in the ledger and + // rendered in Settings. GitHub allows neither name, so nothing legitimate is lost + // by rejecting them here rather than discovering it from a confusing 404. + const [, owner, repo] = match + for (const segment of [owner, repo]) { + if (/^\.+$/.test(segment)) { + throw new InstallError( + `"${input}" is not a GitHub repository. Use owner/repo or a github.com URL.`, + ) + } + } + return `${owner}/${repo}` +} + +type ResolvedSource = { ref: string; tarballUrl: string } + +/** + * Pick which ref to install. + * + * Prefers the latest release, because a release is the author saying "this is + * ready" — installing the default branch means installing whatever was pushed + * thirty seconds ago. Falls back to the default branch so an extension without + * releases is still installable, which matters a lot early on when the author and + * the user are the same person. + */ +async function resolveSource(repo: string): Promise { + const headers = { + accept: 'application/vnd.github+json', + // GitHub rejects API requests without a User-Agent. + 'user-agent': 'agent-code', + } + + try { + const res = await fetchWithDeadline(`https://api.github.com/repos/${repo}/releases/latest`, headers) + if (res.ok) { + const body = (await res.json()) as { tag_name?: string; tarball_url?: string } + if (body.tag_name && body.tarball_url) { + return { ref: body.tag_name, tarballUrl: body.tarball_url } + } + } + } catch { + // Network failure here is not fatal — fall through to the default branch, which + // uses a different host (codeload) and may still succeed. A hard failure will + // surface there with a better message. + } + + const res = await fetchWithDeadline(`https://api.github.com/repos/${repo}`, headers) + if (res.status === 404) { + throw new InstallError(`Repository ${repo} not found, or it is private.`) + } + if (!res.ok) { + throw new InstallError(`GitHub returned ${res.status} for ${repo}.`) + } + const body = (await res.json()) as { default_branch?: string } + const branch = body.default_branch + if (!branch) throw new InstallError(`Could not determine the default branch of ${repo}.`) + + return { + ref: branch, + tarballUrl: `https://codeload.github.com/${repo}/tar.gz/refs/heads/${branch}`, + } +} + +/** + * Every network call in this module goes through here. + * + * WHY A DEADLINE IS NOT OPTIONAL: these are fetches to a host the USER named, made + * from the main process. Without one, a server that accepts the connection and then + * never responds leaves the install promise pending forever — the Settings row stays + * "Installing…", its button stays disabled, and there is no cancel affordance + * anywhere in the UI. A hung install is indistinguishable from a slow one and the + * only escape is restarting the app. + */ +function isAbort(error: unknown): boolean { + return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError') +} + +async function fetchWithDeadline( + url: string, + headers: Record, + signal?: AbortSignal, +): Promise { + try { + return await fetch(url, { headers, signal: signal ?? AbortSignal.timeout(NETWORK_TIMEOUT_MS) }) + } catch (error) { + // AbortSignal rejects with a TimeoutError/AbortError DOMException, which reaches + // the user as "The operation was aborted" — true and useless. Name the host. + if (isAbort(error)) { + throw new InstallError(`${new URL(url).host} did not respond in time.`) + } + throw error + } +} + +async function downloadTarball(url: string): Promise<{ bytes: Buffer; sha256: string }> { + // ── THE BODY GETS AN IDLE BUDGET, NOT A TOTAL ONE ── + // A single AbortSignal.timeout() covering the whole request is wrong here in two + // ways. First the arithmetic: 32 MB inside 30 s demands a sustained 8.7 Mbps, so + // a legitimate large-but-legal extension fails on an ordinary connection. Second + // the error handling: when that signal fires during the body read it throws out + // of the `for await` below, which is OUTSIDE fetchWithDeadline's try/catch — so + // the translation never ran and the user got exactly the "The operation was + // aborted" string the translation exists to prevent. + // + // An idle timer measures what actually matters — the connection has stopped + // making progress — and rearms on every chunk, so a slow but live download + // completes however long it legitimately takes. + const controller = new AbortController() + let idleTimer: ReturnType | null = null + const armIdleTimer = (): void => { + if (idleTimer) clearTimeout(idleTimer) + idleTimer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS) + } + armIdleTimer() + + try { + return await readTarball(url, controller.signal, armIdleTimer) + } catch (error) { + if (isAbort(error)) { + throw new InstallError(`${new URL(url).host} stopped responding during the download.`) + } + throw error + } finally { + if (idleTimer) clearTimeout(idleTimer) + } +} + +async function readTarball( + url: string, + signal: AbortSignal, + onProgress: () => void, +): Promise<{ bytes: Buffer; sha256: string }> { + const res = await fetchWithDeadline(url, { 'user-agent': 'agent-code' }, signal) + if (!res.ok) throw new InstallError(`Download failed with HTTP ${res.status}.`) + + // ── THE CAP IS ENFORCED WHILE READING, NOT AFTER ── + // This used to check `content-length` and then call `res.arrayBuffer()`, which are + // two different things. The header is supplied by the server, so it is a HINT, not + // a bound: omit it (chunked encoding is enough) or lie about it, and arrayBuffer() + // allocates the entire body first — the size check then runs on an allocation that + // has already happened. A hostile or merely misconfigured host could therefore + // drive the MAIN PROCESS, the one holding every agent session, to an + // out-of-memory kill from a URL the user only pasted a repo name for. + // + // Counting while reading makes the limit real: the abort happens at the moment the + // cap is crossed, so peak allocation is bounded by MAX_TARBALL_BYTES plus one + // chunk no matter what the server claims. + const declared = Number(res.headers.get('content-length') ?? '0') + if (Number.isFinite(declared) && declared > MAX_TARBALL_BYTES) { + // A cheap early exit WHEN the header is present — which on the primary path it + // usually is not, because codeload generates the tarball on the fly and responds + // chunked. So this is a bonus, never the bound; the counter below is the bound. + throw new InstallError(`Archive is ${Math.round(declared / 1e6)} MB; the limit is 32 MB.`) + } + if (!res.body) throw new InstallError('Download returned an empty response.') + + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of res.body as unknown as AsyncIterable) { + onProgress() + total += chunk.byteLength + if (total > MAX_TARBALL_BYTES) throw new InstallError('Archive exceeds the 32 MB limit.') + chunks.push(Buffer.from(chunk)) + } + + const bytes = Buffer.concat(chunks) + return { bytes, sha256: createHash('sha256').update(bytes).digest('hex') } +} + +async function resolveTarBinary(): Promise { + try { + await access('/usr/bin/tar', fsConstants.X_OK) + return '/usr/bin/tar' + } catch { + // Bare name → spawn resolves through PATH. Mirrors setup/runtimeTools.ts, which + // is deliberately not imported: extension install must not depend on the + // bundled-runtime-tools subsystem, and the duplication is twenty lines. + return 'tar' + } +} + +async function extractTarball(archivePath: string, destDir: string): Promise { + const tar = await resolveTarBinary() + await new Promise((resolveExtract, reject) => { + // --strip-components=1 removes GitHub's `-/` wrapper directory, so + // the manifest lands at destDir/agent-code.extension.json rather than one level + // down under a name that changes with every commit. + // + // --no-same-owner / --no-same-permissions: the archive is attacker-controlled, + // and its entries carry a uid/gid and a mode. Honouring either lets the archive + // decide what the extracted files look like on disk — a setuid bit, a + // group-writable directory, an owner that is not the running user. Neither is + // ever wanted for what is supposed to be a bundle of JavaScript. Both flags are + // accepted by bsdtar (macOS /usr/bin/tar) and GNU tar. + // + // NOT relied upon: the extractor's own traversal defences. bsdtar refuses `..` + // members and refuses to write THROUGH a symlink by default, and that was + // verified empirically — but `resolveTarBinary` can fall back to whatever `tar` + // is on PATH, which may be a different implementation with different defaults. + // assertBundleTreeIsSafe below is what actually holds the guarantee, because it + // does not depend on which binary ran. + const child = spawn( + tar, + [ + '-xzf', + archivePath, + '-C', + destDir, + '--strip-components=1', + '--no-same-owner', + '--no-same-permissions', + ], + { + stdio: ['ignore', 'ignore', 'pipe'], + }, + ) + + // ── THE DOWNLOAD CAP DOES NOT BOUND THE EXTRACTION ── + // MAX_TARBALL_BYTES bounds COMPRESSED bytes. gzip on repetitive content runs + // 1000:1 and better, so a 32 MB archive that passes every check upstream can + // write tens of gigabytes here. EXTENSIONS_DIR is under $HOME, i.e. the boot + // volume, so filling it degrades the whole machine rather than just this app — + // and a Tier-0 manifest (which is what such a repository would ship) reaches + // this point with NO dialog of any kind, from a pasted `owner/repo`. + // + // Polled rather than streamed through a counting transform: the polling version + // is a dozen lines against a rewrite of the extraction path, it needs no + // in-process gunzip, and 500 ms of overshoot on a disk write is immaterial next + // to a cap this generous. On breach the child is SIGKILLed and the caller's + // `finally` removes the partial tree. + let settled = false + const finish = (run: () => void) => { + if (settled) return + settled = true + clearInterval(sizeTimer) + run() + } + + const sizeTimer = setInterval(() => { + void directorySize(destDir).then(bytes => { + if (bytes <= MAX_EXTRACTED_BYTES) return + child.kill('SIGKILL') + finish(() => + reject( + new InstallError( + `Archive expands to more than ${Math.round(MAX_EXTRACTED_BYTES / 1e6)} MB; ` + + `refusing to install it.`, + ), + ), + ) + }).catch(() => { + // A stat failure mid-extraction is not evidence of a bomb (files come and + // go while tar writes); the next tick re-measures. + }) + }, 500) + + // Capped: stderr is attacker-influenced (tar echoes member names), and an + // archive with a million bad members would otherwise grow this string without + // bound in the main process while the extraction it is reporting on fails. + let stderr = '' + child.stderr.on('data', chunk => { + if (stderr.length < 4096) stderr += String(chunk) + }) + child.once('error', error => finish(() => reject(error))) + child.once('exit', code => { + finish(() => { + if (code === 0) resolveExtract() + else + reject( + new InstallError(`Could not unpack the archive (tar exit ${code}): ${stderr.trim()}`), + ) + }) + }) + }) +} + +/** Total size of a directory tree, following nothing. Used only as a bomb guard. */ +async function directorySize(dir: string): Promise { + let total = 0 + const walk = async (current: string): Promise => { + for (const dirent of await readdir(current, { withFileTypes: true })) { + const absolute = join(current, dirent.name) + const stats = await lstat(absolute) + if (stats.isDirectory()) await walk(absolute) + else total += stats.size + } + } + await walk(dir) + return total +} + +/** + * Reject a staged bundle that contains anything pointing outside itself. + * + * ── WHY THIS EXISTS EVEN THOUGH THREE OTHER CHECKS LOOK SIMILAR ── + * The scheme handler realpath-checks each file it SERVES, and + * verifyEntryInsideBundle realpath-checks the ENTRY. Both are per-file checks made + * later, and neither says anything about the rest of the tree. This one is about + * the bundle as a whole, and it runs at the only moment where refusing is still + * free: before the bundle is moved into place, before consent is asked, before a + * grant exists. + * + * What it stops: + * - An escaping symlink surviving into the installed directory. Serving it is + * already blocked, but its mere presence means `computeBundleHash` is hashing a + * link whose target the extension does not own, and every future reader of that + * directory (a backup, a sync client, a future feature that walks the bundle) + * inherits a pointer out of the sandbox that nobody put there deliberately. + * - The extractor's defences differing by implementation. bsdtar blocks `..` + * members and symlink write-through by default; `resolveTarBinary` may fall back + * to a `tar` on PATH that does not. Checking the RESULT rather than trusting the + * tool makes the guarantee independent of which binary ran. + * - Entry types that have no business in a JavaScript bundle at all — sockets, + * FIFOs, devices — which would otherwise be copied into place and then hashed. + * + * A dangling symlink is fine and stays allowed: it points nowhere, so it can leak + * nothing, and rejecting it would break bundles that ship optional artefacts. + */ +async function assertBundleTreeIsSafe(bundleDir: string): Promise { + const rootReal = await realpath(bundleDir) + let seen = 0 + + const walk = async (dir: string): Promise => { + for (const dirent of await readdir(dir, { withFileTypes: true })) { + // ── A FILE-COUNT CAP, BECAUSE THE BYTE CAPS DO NOT IMPLY ONE ── + // 500,000 empty files compress to a couple of megabytes and extract to almost + // nothing, so they pass both the download and the extraction limits. They + // would then cost a readdir+lstat here, a readFile each in computeBundleHash + // at install, and another full hash on every frame open — all on the main + // process, which already has a documented saturation problem. 10,000 is ~50x + // any real bundle. + if (++seen > MAX_BUNDLE_FILES) { + throw new InstallError( + `The repository contains more than ${MAX_BUNDLE_FILES} files. An extension is a ` + + `built bundle; this looks like an unbuilt source tree.`, + ) + } + const absolute = join(dir, dirent.name) + const stats = await lstat(absolute) + + if (stats.isSymbolicLink()) { + let targetReal: string + try { + targetReal = await realpath(absolute) + } catch { + // Dangling. Points at nothing, so it leaks nothing. + continue + } + if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) { + throw new InstallError( + `The repository contains a symlink ("${relative(rootReal, absolute)}") that points ` + + `outside the extension directory. Refusing to install it.`, + ) + } + continue + } + + if (stats.isDirectory()) { + await walk(absolute) + continue + } + + if (!stats.isFile()) { + throw new InstallError( + `The repository contains a special file ("${relative(rootReal, absolute)}") that is not ` + + `a regular file, directory or symlink. Refusing to install it.`, + ) + } + } + } + + await walk(rootReal) +} + +/** + * Verify the manifest's `entry` resolves to a real file INSIDE the bundle. + * + * The schema already rejects absolute paths and `..` segments, but this is the + * check that actually matters: a symlink committed to the repository can point + * anywhere, and tar will happily recreate it. Resolving the realpath and requiring + * it to stay under the bundle root is the only way to catch that. Without it, a + * manifest saying `entry: "link.js"` where `link.js` symlinks to `~/.ssh/id_rsa` + * would hand that file to a scheme handler that serves extension code. + */ +async function verifyEntryInsideBundle(bundleDir: string, entry: string): Promise { + const bundleReal = await realpath(bundleDir) + const target = resolvePath(bundleReal, entry) + + let targetReal: string + try { + targetReal = await realpath(target) + } catch { + throw new InstallError(`Manifest points at "${entry}", which does not exist in the repository.`) + } + + if (targetReal !== bundleReal && !targetReal.startsWith(bundleReal + sep)) { + throw new InstallError(`Manifest entry "${entry}" resolves outside the extension directory.`) + } +} + +async function readManifestFrom(dir: string): Promise { + const manifestPath = join(dir, MANIFEST_FILENAME) + let raw: string + try { + raw = await readFile(manifestPath, 'utf8') + } catch { + throw new InstallError( + `Repository has no ${MANIFEST_FILENAME} at its root — it is not an Agent Code extension.`, + ) + } + try { + return parseExtensionManifest(raw) + } catch (error) { + // ManifestError messages are already user-facing; rewrap so callers only have + // one error type to present. + throw new InstallError(error instanceof ManifestError ? error.message : String(error)) + } +} + +/** + * Stage on the same filesystem as immutable generations so publication can use + * rename. The dot prefix cannot be an extension ID and is never served as code. + * The live set also prevents a cleanup call during consent from deleting work + * still owned by an installer; startup normally runs before any windows exist. + */ +const activeStagingDirectories = new Set() + +async function makeStagingDir(): Promise { + return withLedgerLock(async () => { + await mkdir(EXTENSIONS_DIR, { recursive: true }) + const path = await mkdtemp(join(EXTENSIONS_DIR, '.staging-')) + activeStagingDirectories.add(path) + return path + }) +} + +async function finalizeInstall( + manifest: ExtensionManifest, + bundleDir: string, + provenance: { origin: 'github' | 'local'; repo: string; ref: string; sha256: string }, + promptConsent?: ConsentPrompt, +): Promise { + // Consent gate. If the extension requests capabilities beyond Tier 0, the user + // must approve them BEFORE the bundle moves into place — declining aborts the + // install, so nothing is left behind. A Tier-0-only extension installs with no + // prompt, matching the "repo name is the trust decision" stance. + const permissions = manifest.permissions ?? [] + if (permissions.length > 0) { + const approved = promptConsent ? await promptConsent(manifest) : false + if (!approved) { + throw new InstallError( + `Installation of ${manifest.name} was declined — its requested capabilities were not granted.`, + ) + } + } + + // Hash every served file before publication. Provenance is useful for source + // diagnostics, but the grant must be bound to bytes we can re-check on disk. + const bundleSha256 = await computeBundleHash(bundleDir) + const record: InstalledExtension = { + manifest, + ...provenance, + installation: { id: randomUUID(), bundleSha256 }, + installedAt: Date.now(), + } + + await withLedgerLock(async () => { + const rows = await readLedger() + const previous = rows.find(row => row.manifest.id === manifest.id) + const finalDir = extensionBundleDirectory(record) + await mkdir(join(finalDir, '..'), { recursive: true }) + // Immutable generations make the ledger rename the ONLY commit point. + // Neither the previous bytes nor their approval is changed before it. A + // process crash before the rename leaves an unreferenced new directory; + // after it, a complete bundle and its consent are already published together. + // There is no backup to guess about and no second grant-file write to lose. + await rename(bundleDir, finalDir) + try { + await writeLedger(rows.filter(row => row.manifest.id !== manifest.id).concat(record)) + } catch (error) { + await discardExtensionBundle(record) + throw error + } + if (previous) await discardExtensionBundle(previous) + }) + + return record +} + +/** + * Collect only unpublished staging and unreferenced immutable generations. + * Called before windows start, under the same lock that publishes installations. + * Old `.replacing-*` backups are intentionally preserved: that installer did not + * record a commit phase, so deleting one could delete its last recoverable copy. + */ +export async function sweepAbandonedInstallDirectories(): Promise { + await withLedgerLock(async () => { + let entries: string[] + try { entries = await readdir(EXTENSIONS_DIR) } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + for (const name of entries) { + const path = join(EXTENSIONS_DIR, name) + if (name.startsWith('.staging-') && !activeStagingDirectories.has(path)) { + await rm(path, { recursive: true, force: true }) + } + } + const referenced = new Set((await readLedger()).map(extensionBundleDirectory)) + const bundlesRoot = join(EXTENSIONS_DIR, '.bundles') + let extensionDirs + try { extensionDirs = await readdir(bundlesRoot, { withFileTypes: true }) } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + for (const extension of extensionDirs) { + // Never follow a hand-created symlink during recursive housekeeping. + if (!extension.isDirectory()) continue + const parent = join(bundlesRoot, extension.name) + for (const generation of await readdir(parent, { withFileTypes: true })) { + const path = join(parent, generation.name) + if (generation.isDirectory() && !referenced.has(path)) { + await rm(path, { recursive: true, force: true }) + } + } + } + }) +} + +/** + * Install (or reinstall) an extension from a GitHub repository. + * + * Sequence matters: everything that can fail happens in a temp directory, and the + * bundle only moves into place once the manifest has validated and the entry has + * been proven to exist inside it. A failed install therefore leaves no partial + * directory for the loader to find — the failure mode the runtime-tools extractor + * documents at length, reached here by the same route. + */ +export async function installExtension( + repoInput: string, + promptConsent?: ConsentPrompt, +): Promise { + const repo = normalizeRepo(repoInput) + const source = await resolveSource(repo) + const { bytes, sha256 } = await downloadTarball(source.tarballUrl) + + const work = await makeStagingDir() + try { + const archivePath = join(work, 'bundle.tar.gz') + const staging = join(work, 'unpacked') + await writeFile(archivePath, bytes) + await mkdir(staging, { recursive: true }) + await extractTarball(archivePath, staging) + await assertBundleTreeIsSafe(staging) + + const manifest = await readManifestFrom(staging) + await verifyEntryInsideBundle(staging, manifest.entry) + for (const view of manifest.contributes?.views ?? []) { + if (manifest.apiVersion === 2 && view.entry) await verifyEntryInsideBundle(staging, view.entry) + } + + return await finalizeInstall( + manifest, + staging, + { origin: 'github', repo, ref: source.ref, sha256 }, + promptConsent, + ) + } finally { + // .catch: `force` only suppresses ENOENT. A staging tree containing a + // mode-0000 directory (reachable through Load folder…) makes this throw + // EACCES, and a throw in a `finally` REPLACES whatever the try threw — so a + // precise InstallError became a raw errno string and the real cause was lost. + await rm(work, { recursive: true, force: true }).catch(() => {}) + activeStagingDirectories.delete(work) + } +} + +/** + * Install an extension from a LOCAL folder — the "load unpacked" path. + * + * WHY this exists: the GitHub installer resolves `releases/latest`, so iterating on + * an unpublished extension otherwise means cutting a release for every change. This + * lets an author point at their built folder and reinstall in one click. It is a + * SNAPSHOT (a copy), not a live mount: a rebuild + reinstall is the loop, which is + * still vastly cheaper than a release. A live-reference mode (serve straight from + * the folder) is a larger change to the scheme handler and is deliberately left for + * later — a copy reuses the exact same containment guarantees as the tarball path. + */ +export async function installExtensionFromPath( + sourceDir: string, + promptConsent?: ConsentPrompt, +): Promise { + let sourceReal: string + try { + sourceReal = await realpath(sourceDir) + } catch { + throw new InstallError(`Folder "${sourceDir}" does not exist.`) + } + + const work = await makeStagingDir() + try { + const staging = join(work, 'unpacked') + await mkdir(staging, { recursive: true }) + + // Copy the folder, skipping node_modules and .git: the built bundle plus assets + // is what ships; a dev folder's dependencies and history are neither needed nor + // small (node_modules would blow past the mental model of "an extension is a JS + // bundle"). This is the tarball path minus the download + strip-components. + let copiedEntries = 0 + let copiedBytes = 0 + await cp(sourceReal, staging, { + recursive: true, + // ── verbatimSymlinks: COPY THE LINK TEXT, DO NOT RESOLVE IT ── + // Node's default is `verbatimSymlinks: false`, which RESOLVES each symlink's + // target to an absolute path while copying. That silently rewrites a bundle's + // internal relative link (`alias.js -> dist/index.js`) into an absolute link + // into the AUTHOR'S source folder — so a perfectly ordinary bundle came out of + // the snapshot pointing at a directory outside itself, which the containment + // check below then correctly refuses. The install failed and the message + // blamed the author for a link they had written correctly. + // + // Copying verbatim is also the safer default, not just the working one: an + // escaping link stays exactly as escaping as the author wrote it, so + // assertBundleTreeIsSafe judges what the repository actually contains rather + // than an absolutised rewrite of it. + verbatimSymlinks: true, + filter: async src => { + const rel = relative(sourceReal, src) + const parts = rel.split(sep) + if (parts.some(part => part === 'node_modules' || part === '.git')) return false + if (rel === '') return true + // Bound before cp copies the next file. The post-copy hash remains the + // authoritative integrity check, but cannot undo disk exhaustion from + // an enormous local source or a source that grows during development. + if (++copiedEntries > BUNDLE_MAX_ENTRIES || parts.length > BUNDLE_MAX_DEPTH + 1) { + throw new InstallError('Extension source has too many files or nested directories.') + } + const stats = await lstat(src) + if (stats.isFile() || stats.isSymbolicLink()) copiedBytes += stats.size + else if (!stats.isDirectory()) throw new InstallError('Extension source contains a special file.') + if (copiedBytes > BUNDLE_MAX_BYTES) throw new InstallError('Extension source exceeds 64 MiB. Choose a built bundle.') + return true + }, + }) + await assertBundleTreeIsSafe(staging) + + const manifest = await readManifestFrom(staging) + await verifyEntryInsideBundle(staging, manifest.entry) + for (const view of manifest.contributes?.views ?? []) { + if (manifest.apiVersion === 2 && view.entry) await verifyEntryInsideBundle(staging, view.entry) + } + + // PROVENANCE ONLY. There is no tarball, so the entry digest answers "which + // bytes did the source hand me" for a local install. It does NOT bind the + // grant — finalizeInstall binds that to computeBundleHash over the whole + // installed directory, identically on both paths. An earlier comment here said + // the opposite, describing entry-scoped binding as the design; entry-scoped + // hashing was in fact half of the original defect, because it let a sibling + // chunk be swapped while the grant still matched. + const entryBytes = await readFile(join(staging, manifest.entry)) + const sha256 = createHash('sha256').update(entryBytes).digest('hex') + + return await finalizeInstall( + manifest, + staging, + { origin: 'local', repo: sourceReal, ref: 'local', sha256 }, + promptConsent, + ) + } finally { + // .catch: `force` only suppresses ENOENT. A staging tree containing a + // mode-0000 directory (reachable through Load folder…) makes this throw + // EACCES, and a throw in a `finally` REPLACES whatever the try threw — so a + // precise InstallError became a raw errno string and the real cause was lost. + await rm(work, { recursive: true, force: true }).catch(() => {}) + activeStagingDirectories.delete(work) + } +} diff --git a/src/main/extensions/installLifecycle.system.test.ts b/src/main/extensions/installLifecycle.system.test.ts new file mode 100644 index 000000000..3c205041a --- /dev/null +++ b/src/main/extensions/installLifecycle.system.test.ts @@ -0,0 +1,226 @@ +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +// Filesystem operations remain real. The only injected failure is the atomic +// publication rename, where a full disk or process death used to separate code +// from its grant. No test can write to the maintainer's application state. +const root = await mkdtemp(join(tmpdir(), 'agent-code-install-lifecycle-')) +const stateRoot = join(root, 'state') +const ledgerPath = join(stateRoot, 'extensions.json') +let rejectPublication = false +let capturePublication = false +let schemeHandler: (request: Request) => Promise + +vi.mock('@main/storage/paths.js', () => ({ + STATE_DIR: stateRoot, + EXTENSIONS_DIR: join(stateRoot, 'extensions'), + EXTENSIONS_LOCKFILE: ledgerPath, + EXTENSION_STATE_DIR: join(stateRoot, 'extension-state'), +})) +vi.mock('fs/promises', async importOriginal => { + const fs = await importOriginal() + return { + ...fs, + rename: async (...args: Parameters) => { + const publishes = String(args[1]) === ledgerPath + if (publishes && rejectPublication) { + rejectPublication = false + throw Object.assign(new Error('fixture disk full'), { code: 'ENOSPC' }) + } + // Capture the real disk state on both sides of the commit boundary. + // Restoring these snapshots models process death (no finally/rollback), + // without adding a production crash hook or duplicating journal logic. + if (publishes && capturePublication) await fs.cp(stateRoot, join(root, 'before-publish'), { recursive: true }) + await fs.rename(...args) + if (publishes && capturePublication) await fs.cp(stateRoot, join(root, 'after-publish'), { recursive: true }) + }, + } +}) +vi.mock('electron', () => ({ + protocol: { handle: (_scheme: string, handler: typeof schemeHandler) => { schemeHandler = handler } }, + net: { fetch: async (url: string) => new Response(await readFile(new URL(url))) }, +})) + +const { installExtensionFromPath, sweepAbandonedInstallDirectories } = await import('./install.js') +const { extensionBundleDirectory, readLedger, listInstalledExtensions, removeExtension, writeLedger } = await import('./ledger.js') +const { installedExtensionCapabilities, recordGrant } = await import('./grants.js') +const { computeBundleHash } = await import('./bundleHash.js') +const { handleExtensionScheme } = await import('./scheme.js') +handleExtensionScheme() + +async function source(id = 'timer', permission = 'workspace.observe'): Promise { + const folder = join(root, 'sources', id) + await mkdir(join(folder, 'dist'), { recursive: true }) + await writeFile(join(folder, 'agent-code.extension.json'), JSON.stringify({ + id, name: id, description: 'Lifecycle fixture', version: '1', apiVersion: 1, + entry: 'dist/index.js', permissions: permission ? [permission] : [], + contributes: { views: [{ id: `${id}.main`, title: 'Main', mount: 'panel' }] }, + })) + await writeFile(join(folder, 'dist/index.js'), 'export function activate() {}') + await writeFile(join(folder, 'dist/asset.json'), '{"value":1}') + return folder +} + +afterEach(async () => { + rejectPublication = capturePublication = false + for (const entry of await readdir(root)) await rm(join(root, entry), { recursive: true, force: true }) +}) +afterAll(async () => { await rm(root, { recursive: true, force: true }) }) + +describe('atomic extension publication', () => { + it('updates asset-only changes and binds consent to the whole installed bundle', async () => { + const folder = await source() + const first = await installExtensionFromPath(folder, async () => true) + await writeFile(join(folder, 'dist/asset.json'), '{"value":2}') + const second = await installExtensionFromPath(folder, async () => true) + expect(second.sha256).toBe(first.sha256) // the entry module did not change + expect(second.installation!.id).not.toBe(first.installation!.id) + expect(second.installation!.bundleSha256).not.toBe(first.installation!.bundleSha256) + expect(await installedExtensionCapabilities('timer', first.installation!.id)).toEqual([]) + expect(await installedExtensionCapabilities('timer')).toEqual(['workspace.observe']) + await writeFile(join(extensionBundleDirectory(second), 'dist/asset.json'), '{"value":3}') + expect(await installedExtensionCapabilities('timer')).toEqual([]) + }) + + it('keeps the old bundle and grant when publication fails', async () => { + const folder = await source() + const first = await installExtensionFromPath(folder, async () => true) + await source('timer', 'panes.observe') + rejectPublication = true + await expect(installExtensionFromPath(folder, async () => true)).rejects.toThrow('fixture disk full') + expect(await readLedger()).toEqual([first]) + expect(await installedExtensionCapabilities('timer')).toEqual(['workspace.observe']) + expect(await computeBundleHash(extensionBundleDirectory(first))).toBe(first.installation!.bundleSha256) + }) + + it.each(['before-publish', 'after-publish'])('recovers a process crash %s', async boundary => { + const folder = await source() + const first = await installExtensionFromPath(folder, async () => true) + await source('timer', 'panes.observe') + capturePublication = true + const second = await installExtensionFromPath(folder, async () => true) + capturePublication = false + await rm(stateRoot, { recursive: true, force: true }) + await cp(join(root, boundary), stateRoot, { recursive: true }) + await sweepAbandonedInstallDirectories() + const expected = boundary === 'before-publish' ? first : second + expect(await readLedger()).toEqual([expected]) + expect(await listInstalledExtensions()).toEqual([{ ...expected, present: true }]) + expect(await installedExtensionCapabilities('timer')).toEqual(expected.manifest.permissions) + expect(await readdir(join(stateRoot, 'extensions/.bundles/timer'))).toEqual([expected.installation!.id]) + }) + + it('retains both concurrent installations and their independent permissions', async () => { + const folders = await Promise.all([source('timer'), source('notes', 'panes.observe')]) + await Promise.all(folders.map(folder => installExtensionFromPath(folder, async () => true))) + expect((await readLedger()).map(row => row.manifest.id).sort()).toEqual(['notes', 'timer']) + expect(await installedExtensionCapabilities('timer')).toEqual(['workspace.observe']) + expect(await installedExtensionCapabilities('notes')).toEqual(['panes.observe']) + }) + + it('never sweeps staging owned by an install waiting for consent', async () => { + const folder = await source() + let release!: (approved: boolean) => void + let entered!: () => void + const pending = new Promise(resolve => { entered = resolve }) + const install = installExtensionFromPath(folder, () => { + entered() + return new Promise(resolve => { release = resolve }) + }) + await pending + await sweepAbandonedInstallDirectories() + release(true) + await expect(install).resolves.toMatchObject({ manifest: { id: 'timer' } }) + }) + + it('drops permissions on a tier-zero update, even when a legacy grant exists', async () => { + const folder = await source() + const first = await installExtensionFromPath(folder, async () => true) + await recordGrant('timer', first.installation!.bundleSha256, ['workspace.observe']) + await source('timer', '') + await installExtensionFromPath(folder) + expect(await installedExtensionCapabilities('timer')).toEqual([]) + }) + + it('uninstalls authority and bundles while preserving saved extension data', async () => { + const installed = await installExtensionFromPath(await source(), async () => true) + const saved = join(stateRoot, 'extension-state/timer.json') + await mkdir(join(saved, '..'), { recursive: true }) + await writeFile(saved, '{"saved":true}') + await removeExtension('timer') + expect(await readLedger()).toEqual([]) + expect(await installedExtensionCapabilities('timer')).toEqual([]) + await expect(readFile(join(extensionBundleDirectory(installed), 'dist/index.js'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(saved, 'utf8')).toBe('{"saved":true}') + }) + + it('preserves legacy bytes and consent until the first generation update', async () => { + const folder = await source() + const installed = await installExtensionFromPath(folder, async () => true) + const { installation: _, ...legacy } = installed + await cp(extensionBundleDirectory(installed), extensionBundleDirectory(legacy), { recursive: true }) + await writeLedger([legacy]) + await recordGrant('timer', await computeBundleHash(extensionBundleDirectory(legacy)), ['workspace.observe']) + expect(await installedExtensionCapabilities('timer')).toEqual(['workspace.observe']) + await sweepAbandonedInstallDirectories() + expect(await listInstalledExtensions()).toEqual([{ ...legacy, present: true }]) + const next = await installExtensionFromPath(folder, async () => true) + expect(next.installation).toBeDefined() + expect(await installedExtensionCapabilities('timer')).toEqual(['workspace.observe']) + }) + + it('preserves all bundle copies when the ledger is corrupt', async () => { + const installed = await installExtensionFromPath(await source(), async () => true) + await writeFile(ledgerPath, '{invalid') + await expect(sweepAbandonedInstallDirectories()).rejects.toThrow('invalid JSON') + expect(await computeBundleHash(extensionBundleDirectory(installed))).toBe(installed.installation!.bundleSha256) + expect(await readFile(ledgerPath, 'utf8')).toBe('{invalid') + }) + + it('preserves ambiguous backups from the old in-place installer', async () => { + const backup = join(stateRoot, 'extensions/timer.replacing-00000000-0000-0000-0000-000000000000') + await mkdir(backup, { recursive: true }) + await writeFile(join(backup, 'recovery.txt'), 'last copy') + await sweepAbandonedInstallDirectories() + expect(await readFile(join(backup, 'recovery.txt'), 'utf8')).toBe('last copy') + }) +}) + +describe('scheme serves only the committed generation', () => { + it('rejects superseded generation URLs instead of mixing old and new modules', async () => { + const folder = await source() + const first = await installExtensionFromPath(folder, async () => true) + const assetUrl = (id: string) => `agent-code-ext://timer/__bundle/${id}/dist/asset.json` + const firstResponse = await schemeHandler(new Request(assetUrl(first.installation!.id))) + expect(firstResponse.status).toBe(200) + expect(await firstResponse.json()).toEqual({ value: 1 }) + const second = await installExtensionFromPath(folder, async () => true) + expect((await schemeHandler(new Request(assetUrl(first.installation!.id)))).status).toBe(404) + expect((await schemeHandler(new Request(assetUrl(second.installation!.id)))).status).toBe(200) + expect((await schemeHandler(new Request(`agent-code-ext://timer/__bundle/${first.installation!.id}/__agent-code-frame__.html?view=timer.main&rev=${first.installation!.id}`))).status).toBe(404) + const frame = await schemeHandler(new Request(`agent-code-ext://timer/__bundle/${second.installation!.id}/__agent-code-frame__.html?view=timer.main&rev=${second.installation!.id}`)) + expect(frame.status).toBe(200) + expect(await frame.text()).toContain('dist/index.js') + await removeExtension('timer') + expect((await schemeHandler(new Request(assetUrl(second.installation!.id)))).status).toBe(404) + }) +}) + +it('refuses a missing v2 view module before replacing an installed generation', async () => { + const folder = await source('timer', '') + const previous = await installExtensionFromPath(folder) + const manifestPath = join(folder, 'agent-code.extension.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + manifest.apiVersion = 2 + manifest.contributes.views[0].entry = 'dist/view.js' + await writeFile(manifestPath, JSON.stringify(manifest)) + await expect(installExtensionFromPath(folder)).rejects.toThrow() + expect((await readLedger())[0]?.installation?.id).toBe(previous.installation?.id) + await writeFile(join(folder, 'dist/view.js'), 'export function mount(element) { element.textContent = "ready" }') + const replacement = await installExtensionFromPath(folder) + expect(replacement.manifest.apiVersion).toBe(2) + expect(replacement.installation?.id).not.toBe(previous.installation?.id) + expect(await readFile(join(extensionBundleDirectory(replacement), 'dist/view.js'), 'utf8')).toContain('ready') +}) diff --git a/src/main/extensions/installTree.system.test.ts b/src/main/extensions/installTree.system.test.ts new file mode 100644 index 000000000..fcaefd031 --- /dev/null +++ b/src/main/extensions/installTree.system.test.ts @@ -0,0 +1,170 @@ +import { mkdtemp, mkdir, open, rm, symlink, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +// Only the state LOCATION is replaced; copy, realpath and validation remain +// real filesystem operations. Denying consent is not an isolation boundary: +// tier-zero fixtures never ask for consent, and staging happens before it. +// A regression must therefore be unable to install into the user's real state. +const stateRoot = await mkdtemp(join(tmpdir(), 'agent-code-install-state-')) +vi.mock('@main/storage/paths.js', () => ({ + STATE_DIR: stateRoot, + EXTENSIONS_DIR: join(stateRoot, 'extensions'), + EXTENSIONS_LOCKFILE: join(stateRoot, 'extensions.json'), + EXTENSION_STATE_DIR: join(stateRoot, 'extension-state'), +})) +const { installExtensionFromPath, InstallError } = await import('@main/extensions/install.js') + +// `.system.` because the guarantee under test IS a filesystem guarantee: what the +// installer refuses to move into place after a real copy of a real directory tree. +// A mocked fs would be testing the mock's idea of a symlink. +// +// These drive the LOCAL-folder installer rather than the tarball one, because it is +// the path that needs no network and no `tar` binary while running the identical +// validation tail (assertBundleTreeIsSafe → readManifestFrom → verifyEntryInsideBundle +// → finalizeInstall). The tarball path's extra step is extraction, whose output is +// then handed to exactly these checks. + +const temporaryRoots: string[] = [] + +async function makeSourceFolder( + files: Record, + manifest?: Record, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'agent-code-install-tree-')) + temporaryRoots.push(root) + const source = join(root, 'source') + await mkdir(source, { recursive: true }) + if (manifest !== undefined) { + await writeFile(join(source, 'agent-code.extension.json'), JSON.stringify(manifest), 'utf8') + } + for (const [relative, contents] of Object.entries(files)) { + const absolute = join(source, relative) + await mkdir(join(absolute, '..'), { recursive: true }) + await writeFile(absolute, contents, 'utf8') + } + return source +} + +/** A manifest that is valid in every respect the tree checks do not care about. */ +function validManifest(): Record { + return { + id: 'treetest', + name: 'Tree Test', + description: 'Fixture extension.', + version: '0.0.1', + apiVersion: 1, + entry: 'dist/index.js', + } +} + +// Some valid-tree cases deliberately reach consent. Declining distinguishes a +// successfully validated bundle from a tree rejected before that stage. +const denyConsent = async () => false + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + await rm(stateRoot, { recursive: true, force: true }) +}) + +afterAll(async () => { await rm(stateRoot, { recursive: true, force: true }) }) + +describe('install refuses a bundle that points outside itself', () => { + it('rejects a symlink whose target escapes the bundle', async () => { + // ── THE REGRESSION THIS EXISTS FOR ── + // A repository can commit a symlink, and `tar`/`cp` recreate it. Whether the + // extractor blocks traversal is implementation-dependent — bsdtar refuses `..` + // members and symlink write-through, a `tar` found on PATH may not — so the + // guarantee has to come from checking the RESULT, not from trusting the tool. + const source = await makeSourceFolder({ 'dist/index.js': 'export function activate() {}' }, validManifest()) + await symlink('/etc', join(source, 'escape')) + + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow(InstallError) + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow(/points outside/) + }) + + it('names the offending path so the failure is diagnosable', async () => { + const source = await makeSourceFolder({ 'dist/index.js': 'export function activate() {}' }, validManifest()) + await mkdir(join(source, 'nested'), { recursive: true }) + await symlink('/etc', join(source, 'nested/escape')) + + // Without the path, an author with a large repository has no way to find it. + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow(/nested\/escape/) + }) + + it('allows a RELATIVE symlink that stays inside the bundle', async () => { + // Legitimate: bundles do alias files internally. Rejecting these would be a + // blanket ban on symlinks, which is a bigger rule than the threat requires. + // This reaches the consent prompt and is then declined, which is how the test + // asserts it got PAST the tree check without writing to the real state dir. + const source = await makeSourceFolder( + { 'dist/index.js': 'export function activate() {}' }, + { ...validManifest(), permissions: ['workspace.observe'] }, + ) + await symlink('dist/index.js', join(source, 'alias.js')) + + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow(/declined/) + }) + + it('rejects an ABSOLUTE symlink into the author\'s own source tree', async () => { + // Subtle and worth pinning, because it looks like a false positive and is not. + // The local installer SNAPSHOTS the folder: `cp` preserves symlinks verbatim, so + // an absolute link to `/dist/index.js` still points at the AUTHOR'S + // FOLDER once the bundle has been copied under EXTENSIONS_DIR. At that moment it + // is a genuine escape — the installed extension reaches a path outside itself, + // the scheme handler would refuse to serve it, and computeBundleHash would be + // hashing a link whose target the bundle does not own. + // + // A relative link (the case above) survives the copy intact and is allowed. So + // the rule an author needs is "link relatively", which the message below has to + // be good enough to convey. + const source = await makeSourceFolder( + { 'dist/index.js': 'export function activate() {}' }, + { ...validManifest(), permissions: ['workspace.observe'] }, + ) + await symlink(join(source, 'dist/index.js'), join(source, 'absolute-alias.js')) + + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow(/points outside/) + }) + + it('allows a dangling symlink, which can leak nothing', async () => { + const source = await makeSourceFolder( + { 'dist/index.js': 'export function activate() {}' }, + { ...validManifest(), permissions: ['workspace.observe'] }, + ) + await symlink(join(source, 'does-not-exist'), join(source, 'dangling.js')) + + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow(/declined/) + }) +}) + +describe('install validates the manifest before anything is moved into place', () => { + it('refuses an oversized local source before copying its large file', async () => { + const source = await makeSourceFolder({ 'dist/index.js': 'export {}' }, validManifest()) + const file = await open(join(source, 'large.bin'), 'w') + try { await file.truncate(65 * 1024 * 1024) } finally { await file.close() } + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow('Extension source exceeds 64 MiB') + }) + + it('rejects a folder with no manifest', async () => { + const source = await makeSourceFolder({ 'dist/index.js': 'export function activate() {}' }) + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow( + /not an Agent Code extension/, + ) + }) + + it('rejects a manifest whose entry does not exist', async () => { + const source = await makeSourceFolder({ 'other.js': 'export {}' }, validManifest()) + await expect(installExtensionFromPath(source, denyConsent)).rejects.toThrow( + /does not exist in the repository/, + ) + }) + + it('rejects a missing source folder without creating anything', async () => { + await expect( + installExtensionFromPath(join(tmpdir(), 'agent-code-install-tree-absent'), denyConsent), + ).rejects.toThrow(/does not exist/) + }) +}) diff --git a/src/main/extensions/ledger.ts b/src/main/extensions/ledger.ts new file mode 100644 index 000000000..d36b84fd2 --- /dev/null +++ b/src/main/extensions/ledger.ts @@ -0,0 +1,226 @@ +import { randomUUID } from 'node:crypto' +import { access, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' +import { join } from 'path' + +import { z } from 'zod' + +import { EXTENSIONS_DIR, EXTENSIONS_LOCKFILE, STATE_DIR } from '@main/storage/paths.js' +import { isValidExtensionId } from '@shared/types/extensionId.js' +import type { ExtensionListEntry, InstalledExtension } from '@shared/types/extensions.js' + +import { apiVersionMismatch, extensionManifestSchema } from './manifest.js' + +// The install ledger. +// +// WHY a ledger separate from "whatever directories exist under EXTENSIONS_DIR": +// scanning the directory would make the filesystem the source of truth, and a +// half-extracted or hand-copied folder would then look installed. The ledger +// records what the app *decided* to install, with the repo, ref and hash that +// produced it — questions the directory cannot answer. The directory is the +// artifact; this is the record. + +// Row shape validation. WHY re-validate a file only writeLedger writes: the +// `manifest.id` and `manifest.entry` of every row are interpolated into a path +// (`join(EXTENSIONS_DIR, id, entry)` below) and into the import() URL the host +// loads code from. A hand-edited extensions.json is the one way an unvalidated +// id/entry could reach those sinks. The manifest schema already enforces the +// path-safety refinements (id regex, entry rejects `..`/absolute/backslash), so +// running each row through it turns "trust the file" into "trust the schema". +// Invalid records fail closed. Treating corruption as an empty or partial +// ledger would let the next install overwrite lost rows and let housekeeping +// delete their bundles. Preserve the file and surface a repairable error instead. +const installedExtensionSchema = z.object({ + manifest: extensionManifestSchema, + installation: z.object({ + id: z.string().uuid(), + bundleSha256: z.string().regex(/^[a-f0-9]{64}$/), + }).optional(), + // Optional, and MIGRATED BELOW rather than defaulted here. + // + // An earlier version defaulted this to 'github' on the reasoning that any row + // predating the field came from the GitHub installer. That reasoning was simply + // false: `installExtensionFromPath` shipped on this same branch and has been + // writing rows with an absolute path in `repo` ever since. Defaulting them to + // 'github' sent every one of them down the GitHub Update path, straight into + // `normalizeRepo('/Users/…')` — reproducing the exact failure the `origin` field + // was added to fix, for precisely the users who had been using Load folder. + origin: z.enum(['github', 'local']).optional(), + repo: z.string().min(1), + ref: z.string().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + installedAt: z.number().finite(), +}) + +export async function readLedger(): Promise { + let raw: string + try { + raw = await readFile(EXTENSIONS_LOCKFILE, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('The extension install record is invalid JSON. Existing bundles were preserved.') + } + if (!Array.isArray(parsed)) throw new Error('The extension install record must be an array.') + const rows: InstalledExtension[] = [] + for (const candidate of parsed) { + const result = installedExtensionSchema.safeParse(candidate) + if (result.success) { + // The ABI gate, applied at LOAD and not only at install. The schema accepts + // any positive apiVersion (it is shared with the install path, which wants + // to report a mismatch with a better message than a field error), so a row + // written under a host that implemented v1 would otherwise keep loading + // against a host that implements v2. Version skew arrives by UPGRADING + // AGENT CODE, which involves no install — so an install-time-only check can + // never see it. + const mismatch = apiVersionMismatch(result.data.manifest.apiVersion) + if (mismatch) { + throw new Error(`${result.data.manifest.id}: ${mismatch}. Existing bundles were preserved.`) + } + // Migrate a pre-`origin` row by the one field that actually distinguishes the + // two installers: `ref`. The local installer writes the literal 'local' there + // (it has no git ref to record), and the GitHub installer writes a tag or a + // branch name. A repository whose branch is literally named `local` would be + // misread — and would then simply take the Update path it already took before + // this field existed, so the migration is never worse than not having it. + rows.push({ + ...result.data, + origin: result.data.origin ?? (result.data.ref === 'local' ? 'local' : 'github'), + }) + } else { + // Never turn a bad row into permission to destroy its on-disk generation. + throw new Error(`Invalid extension install record: ${result.error.issues[0]?.message ?? 'unknown'}. Existing bundles were preserved.`) + } + } + return rows +} + +/** + * Serialises every read-modify-write of the ledger. + * + * ── WHY temp+rename IS NOT ENOUGH ── + * `writeLedger` renames a temp file into place, which makes a write ATOMIC — an + * interrupted write leaves the previous ledger, never a truncated one. It does + * nothing about a LOST UPDATE, and the two were being conflated. + * + * Both `finalizeInstall` and `removeExtension` do read → mutate → write with no + * lock between them, so: two concurrent installs of different extensions both read + * `[]`, both write a one-row ledger, and the first extension's row is gone while + * its bundle sits on disk with nothing referencing it. An install racing a remove + * of the same id resurrects the extension the user just uninstalled — with a stale + * grant, because revokeGrant ran before the reinstall wrote a new row. + * + * A single module-scope chain is the right size for this: ledger writes are rare, + * they are all in main, and the contention window is a few milliseconds. A file + * lock would additionally guard a second Agent Code process, which the app's + * single-instance lock already prevents. + */ +let ledgerQueue: Promise = Promise.resolve() +const publicationListeners = new Set<(rows: InstalledExtension[]) => void>() + +/** Main's window bridge subscribes once. Metadata changes must reach every + * window, including windows with a live frame and no Settings panel open. */ +export function onExtensionPublication(listener: (rows: InstalledExtension[]) => void): () => void { + publicationListeners.add(listener) + return () => { publicationListeners.delete(listener) } +} + +export function withLedgerLock(operation: () => Promise): Promise { + // `.then(op, op)` rather than `.then(op)`: a previous operation's REJECTION must + // not skip this one. The failure still reaches its own caller through the promise + // returned here; it just does not poison the queue. + const next = ledgerQueue.then(operation, operation) + ledgerQueue = next.catch(() => {}) + return next +} + +export async function writeLedger(rows: InstalledExtension[]): Promise { + await mkdir(STATE_DIR, { recursive: true }) + // temp+rename in the same directory, matching workspace.json: an interrupted + // write must leave the previous ledger intact rather than a truncated file that + // reads as "nothing installed" and orphans every bundle on disk. + const tmp = `${EXTENSIONS_LOCKFILE}.tmp-${randomUUID()}` + try { + await writeFile(tmp, `${JSON.stringify(rows, null, 2)}\n`, 'utf8') + await rename(tmp, EXTENSIONS_LOCKFILE) + // The rename already committed. A closed window's send failure must never + // turn this into a reported install failure or delete the published bundle. + for (const listener of publicationListeners) { + try { listener(rows) } catch (error) { console.warn('[extensions] publication listener failed:', error) } + } + } finally { + await rm(tmp, { force: true }).catch(() => {}) + } +} + +/** + * The ledger, annotated with whether each bundle is actually on disk and loadable. + * + * `present: false` is surfaced rather than filtered because the two states need + * different user actions: a missing bundle is reinstallable from the recorded repo, + * whereas silently hiding the row would leave the user wondering where their + * extension went. + */ +export async function listInstalledExtensions(): Promise { + return withLedgerLock(async () => { + const rows = await readLedger() + return Promise.all( + rows.map(async row => { + let present = false + try { + await access(join(extensionBundleDirectory(row), row.manifest.entry)) + present = true + } catch { + present = false + } + return { ...row, present } + }), + ) + }) +} + +/** + * Remove an extension's bundle and ledger row. + * + * Deliberately does NOT delete the extension's state under EXTENSION_STATE_DIR. + * Uninstall-then-reinstall is a normal troubleshooting move, and silently + * destroying saved data as a side effect of it would be hostile. Orphaned state is + * a few KB of JSON; lost state is the user's data. + */ +export async function removeExtension(id: string): Promise { + // VALIDATE BEFORE THE RECURSIVE DELETE. `id` arrives from IPC, and this is a + // `rm(..., { recursive: true })` — a value like `../../something` would escape + // EXTENSIONS_DIR entirely and delete an unrelated tree. Every other path-handling + // site in this subsystem validates; this one did not, which is the whole reason the + // shared validator now exists rather than a fifth copy of the regex. + if (!isValidExtensionId(id)) throw new Error(`invalid extension id: ${id}`) + + // Removing the row revokes authority and discovery in the same commit. A + // failed file deletion after that point is garbage collection, not a failed + // uninstall: reporting failure would encourage retrying an already-removed + // extension. Saved extension state lives elsewhere and is never touched. + await withLedgerLock(async () => { + const rows = await readLedger() + const previous = rows.find(row => row.manifest.id === id) + await writeLedger(rows.filter(row => row.manifest.id !== id)) + if (previous) await discardExtensionBundle(previous) + }) +} + +/** Construct paths only from validated ledger fields, never a caller's path. */ +export function extensionBundleDirectory(row: InstalledExtension): string { + return row.installation + ? join(EXTENSIONS_DIR, '.bundles', row.manifest.id, row.installation.id) + : join(EXTENSIONS_DIR, row.manifest.id) +} + +/** Call only after a ledger commit has made this generation unreachable. */ +export async function discardExtensionBundle(row: InstalledExtension): Promise { + await rm(extensionBundleDirectory(row), { recursive: true, force: true }).catch(error => { + console.warn(`[extensions] could not reclaim ${row.manifest.id} bundle:`, error) + }) +} diff --git a/src/main/extensions/manifest.test.ts b/src/main/extensions/manifest.test.ts new file mode 100644 index 000000000..13e54ba11 --- /dev/null +++ b/src/main/extensions/manifest.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest' + +import { EXTENSION_CAPABILITIES } from '@shared/types/extensions.js' +import { CUSTOM_APPEARANCE_COLOR_KEYS } from '@shared/appearanceColors.js' +import { EXTENSION_THEME_COLOR_KEYS } from '../../../packages/agent-code-extension-api/dist/themes.js' +import { + ManifestError, + SUPPORTED_API_VERSION, + apiVersionMismatch, + parseExtensionManifest, +} from '@main/extensions/manifest.js' + +// The manifest is the least-trusted input in the application: JSON from a +// repository whose name a user pasted, which then decides a directory name, a +// module URL the renderer executes, a set of global command ids, and a +// permission prompt. Everything here protects a rule that, if it stopped +// holding, would be invisible until something hostile or badly-built arrived. + +function manifest(overrides: Record = {}): string { + return JSON.stringify({ + id: 'timer', + name: 'Timer', + description: 'A focus timer.', + version: '0.1.0', + apiVersion: 1, + entry: 'dist/index.js', + ...overrides, + }) +} + +describe('declarative theme manifest boundary', () => { + const theme = { id: 'timer.night', title: 'Timer Night', colors: { canvas: '#123456', overlayScrim: '#1234', ink: '#abcdef90', accent: '#abc', rowBg: 'transparent' } } + it('retains literal colors as metadata with no activation or permission', () => { + const parsed = parseExtensionManifest(manifest({ contributes: { themes: [theme] } })) + expect(parsed.contributes?.themes).toEqual([theme]) + expect(parsed.activationEvents).toBeUndefined() + expect(parsed.permissions).toBeUndefined() + }) + it.each([ + ['another namespace', { ...theme, id: 'other.night' }], + ['host mode id', { ...theme, id: 'dark' }], + ['empty title', { ...theme, title: ' ' }], + ['unknown token', { ...theme, colors: { font: '#123456' } }], + ['empty palette', { ...theme, colors: {} }], + ['URL', { ...theme, colors: { canvas: 'url(https://example.test/track)' } }], + ['escaped URL', { ...theme, colors: { canvas: 'rgb(0 0 0) u\\72l(https://example.test)' } }], + ['variable', { ...theme, colors: { canvas: 'var(--secret)' } }], + ['declaration', { ...theme, colors: { canvas: '#123; display:none' } }], + ['malformed hex length', { ...theme, colors: { canvas: '#12345' } }], + ])('rejects %s before publication', (_label, invalid) => { + expect(() => parseExtensionManifest(manifest({ contributes: { themes: [invalid] } }))).toThrow(ManifestError) + }) + it('rejects duplicate themes and bounds contribution count', () => { + expect(() => parseExtensionManifest(manifest({ contributes: { themes: [theme, theme] } }))).toThrow(/duplicate theme/) + const themes = Array.from({ length: 17 }, (_, index) => ({ ...theme, id: `timer.theme${index}` })) + expect(() => parseExtensionManifest(manifest({ contributes: { themes } }))).toThrow(ManifestError) + }) + it('accepts every appearance token authors can style', () => { + expect(EXTENSION_THEME_COLOR_KEYS).toEqual(CUSTOM_APPEARANCE_COLOR_KEYS) + const colors = Object.fromEntries(CUSTOM_APPEARANCE_COLOR_KEYS.map(key => [key, '#123456'])) + expect(parseExtensionManifest(manifest({ contributes: { themes: [{ ...theme, colors }] } })).contributes?.themes?.[0]?.colors).toEqual(colors) + }) +}) + +describe('parseExtensionManifest — path safety of `entry`', () => { + // `entry` is joined onto the bundle directory and then loaded AS CODE over a + // scheme the renderer is allowed to execute from. Each of these shapes turns + // that field into arbitrary-file access if the refinement is lost. + it.each([ + ['absolute', '/etc/passwd.js'], + ['parent traversal', '../../../etc/passwd.js'], + ['traversal mid-path', 'dist/../../secrets.js'], + ['backslash separator', 'dist\\index.js'], + ['not a module', 'dist/index.txt'], + ])('rejects an %s entry', (_label, entry) => { + expect(() => parseExtensionManifest(manifest({ entry }))).toThrow(ManifestError) + }) + + it('accepts an ordinary nested module path', () => { + expect(parseExtensionManifest(manifest({ entry: 'dist/index.js' })).entry).toBe('dist/index.js') + }) +}) + +describe('parseExtensionManifest — id grammar', () => { + // The id becomes a directory name under EXTENSIONS_DIR, the storage namespace, + // and the HOST of the extension's own origin. A value that escapes any of + // those is a path-traversal primitive. + it.each([ + ['traversal', '../evil'], + ['slash', 'a/b'], + ['leading digit', '1timer'], + ['uppercase', 'Timer'], + ['empty', ''], + ['too long', 'a'.repeat(65)], + ])('rejects a %s id', (_label, id) => { + expect(() => parseExtensionManifest(manifest({ id }))).toThrow(ManifestError) + }) +}) + +describe('parseExtensionManifest — contribution namespacing', () => { + it('rejects a command id outside the extension namespace', () => { + // Contributed ids land in one registry beside ~95 first-party commands. + // `session.kill` from a third party would collide with a real command and + // resolve arbitrarily; install is the last moment a user can act on it. + expect(() => + parseExtensionManifest( + manifest({ contributes: { commands: [{ id: 'session.kill', title: 'Kill' }] } }), + ), + ).toThrow(/must start with "timer\."/) + }) + + it('rejects a keybinding pointing at a command it does not contribute', () => { + expect(() => + parseExtensionManifest( + manifest({ + contributes: { + commands: [{ id: 'timer.start', title: 'Start' }], + keybindings: [{ command: 'timer.nope', key: 'cmd+t' }], + }, + }), + ), + ).toThrow(/does not contribute/) + }) + + it('rejects an activation event naming a contribution that does not exist', () => { + // A dead activation event is the hardest authoring mistake to diagnose: the + // extension simply never activates, with no error anywhere. + expect(() => + parseExtensionManifest( + manifest({ + activationEvents: ['onCommand:timer.ghost'], + contributes: { commands: [{ id: 'timer.start', title: 'Start' }] }, + }), + ), + ).toThrow(/does not contribute/) + }) + + it('rejects duplicate ids within one manifest', () => { + expect(() => + parseExtensionManifest( + manifest({ + contributes: { + commands: [ + { id: 'timer.start', title: 'Start' }, + { id: 'timer.start', title: 'Start again' }, + ], + }, + }), + ), + ).toThrow(/duplicate command id/) + }) +}) + +describe('parseExtensionManifest — capabilities', () => { + it('accepts every capability the host actually implements', () => { + const parsed = parseExtensionManifest(manifest({ apiVersion: 2, permissions: [...EXTENSION_CAPABILITIES] })) + expect(parsed.permissions).toEqual([...EXTENSION_CAPABILITIES]) + }) + + it('keeps filesystem reads on the v2 service contract', () => { + expect(() => parseExtensionManifest(manifest({ permissions: ['fs.read'] }))).toThrow(/requires Agent Code API v2/) + }) + + // ── THE REGRESSION THIS BLOCK EXISTS FOR ── + // The schema used to accept seven Tier 2/3 names that nothing implemented: + // there was no frameProtocol request member, no frameHost arm, no API surface. + // A manifest could ask for filesystem write and git commit, the user got a + // blocking OS warning dialog naming those powers, approved it, and a permanent + // grant was written for capabilities that did nothing. Refusing the install is + // the honest outcome — it tells the author their extension needs a newer host + // instead of silently granting them nothing. + it.each(['fs.write', 'git.read', 'git.commit', 'transcript.read', 'sessions.prompt', 'network.fetch'])( + 'refuses to install a manifest requesting the unimplemented capability %s', + capability => { + expect(() => parseExtensionManifest(manifest({ permissions: [capability] }))).toThrow( + ManifestError, + ) + }, + ) + + it('names the accepted set in the failure message rather than reading as a typo report', () => { + expect(() => parseExtensionManifest(manifest({ permissions: ['fs.write'] }))).toThrow( + /not available yet/, + ) + }) +}) + +describe('apiVersionMismatch', () => { + // Extracted from parseExtensionManifest precisely so the ledger can apply it on + // every read. Version skew arrives by UPGRADING AGENT CODE, which involves no + // install, so an install-time-only check can never observe it. + it('passes the supported version', () => { + expect(apiVersionMismatch(SUPPORTED_API_VERSION)).toBeNull() + }) + + it('reports both versions so the message says who is out of date', () => { + const message = apiVersionMismatch(SUPPORTED_API_VERSION + 1) + expect(message).toContain(`v${SUPPORTED_API_VERSION + 1}`) + expect(message).toContain(`v${SUPPORTED_API_VERSION}`) + }) + + it('keeps the explicit v1 adapter and rejects unsupported old versions', () => { + // A downgrade is just as much a contract mismatch: the host would hand a v1 + // object to code written against v0's shape. + expect(apiVersionMismatch(1)).toBeNull() + expect(apiVersionMismatch(0)).not.toBeNull() + }) +}) + +it('requires a separately contained view module for API v2 while retaining v1 manifests', () => { + const view = { id: 'timer.main', title: 'Timer', mount: 'panel' } + expect(() => parseExtensionManifest(manifest({ apiVersion: 2, contributes: { views: [view] } }))).toThrow('separate mount module') + expect(() => parseExtensionManifest(manifest({ apiVersion: 2, contributes: { views: [{ ...view, entry: '../escape.js' }] } }))).toThrow('..') + const parsed = parseExtensionManifest(manifest({ apiVersion: 2, contributes: { views: [{ ...view, entry: 'dist/view.js' }] } })) + expect(parsed.contributes?.views?.[0]?.entry).toBe('dist/view.js') + expect(parseExtensionManifest(manifest({ contributes: { views: [view] } })).apiVersion).toBe(1) +}) diff --git a/src/main/extensions/manifest.ts b/src/main/extensions/manifest.ts new file mode 100644 index 000000000..85dd9ddb9 --- /dev/null +++ b/src/main/extensions/manifest.ts @@ -0,0 +1,326 @@ +import { z } from 'zod' + +import { EXTENSION_CAPABILITIES } from '@shared/types/extensions.js' +import { extensionThemeColorsSchema } from '@shared/types/extensionThemes.js' +import type { + ExtensionActivationEvent, + ExtensionManifest, +} from '@shared/types/extensions.js' + +// Manifest validation. +// +// WHY zod and not hand-written checks: this JSON comes from a repository the user +// pasted a name for. It is the least trusted input in the app. The remote protocol +// (main/remote/protocol/messages.ts) already established the principle here — the +// schema IS the allow-list, and anything not expressible in it is unrepresentable +// rather than denied later. Same reasoning, same library, already a dependency. + +/** Newest API major; v1 retains its explicit per-view compatibility adapter. */ +export const SUPPORTED_API_VERSION = 2 + +// Duplicated from main/extensions/storage.ts on purpose, with the duplication +// called out in both places: storage owns the rule because there the id becomes a +// filesystem path, and this schema owns it because here is where a hostile manifest +// first arrives. A single shared const would be tidier but would let a future edit +// to one call site silently relax the other's guarantee. +const EXTENSION_ID = z + .string() + .regex(/^[a-z][a-z0-9-]{0,63}$/, 'id must match /^[a-z][a-z0-9-]{0,63}$/') + +// WHY `entry` is this restrictive: it is joined onto the bundle directory and then +// loaded as code. `../../../etc/passwd`, an absolute path, or a URL would each turn +// a manifest field into arbitrary-file access. Rejecting the shape here means the +// containment check in install.ts is a second line of defence rather than the only +// one. Backslashes are rejected too — a Windows-style separator that survives to a +// POSIX join is a normalization bug waiting to happen. +const ENTRY_PATH = z + .string() + .min(1) + .max(256) + .refine(value => !value.startsWith('/'), 'entry must be a relative path') + .refine(value => !value.includes('\\'), 'entry must use forward slashes') + .refine( + value => !value.split('/').some(segment => segment === '..'), + 'entry must not contain ".." segments', + ) + .refine(value => /\.m?js$/.test(value), 'entry must be a .js or .mjs module') + +// Contribution ids are `.`, optionally with further dotted +// segments (`timer.ui.accent`). The namespace half is checked against the +// manifest's own id after parsing, where the id is available. +// +// WHY camelCase is allowed after the first dot while the extension id itself is +// kebab-only: the extension id becomes a DIRECTORY NAME, so it is constrained by +// the filesystem and by case-insensitive volumes. A contribution id is just a +// registry key, and `timer.inheritTheme` is the idiomatic spelling everywhere +// this convention exists. An earlier version of this regex demanded kebab-case +// throughout and rejected the first real manifest written against it. +const CONTRIBUTION_ID = z + .string() + .min(3) + .max(96) + .regex( + /^[a-z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*)+$/, + 'must look like "."', + ) + +const commandContribution = z.object({ + id: CONTRIBUTION_ID, + title: z.string().min(1).max(80), + description: z.string().max(400).optional(), + keywords: z.array(z.string().min(1).max(40)).max(24).optional(), +}) + +const viewContribution = z.object({ + id: CONTRIBUTION_ID, + title: z.string().min(1).max(80), + // Closed union rather than a free string: the host owns the chrome, and an + // unknown mount kind must fail at install with a message rather than resolve + // to nothing at render time. 'panel'/'tab' get added here when their host + // shells exist. + mount: z.enum(['modal', 'panel']), + entry: ENTRY_PATH.optional(), +}) + +const settingContribution = z.discriminatedUnion('type', [ + z.object({ + id: CONTRIBUTION_ID, + title: z.string().min(1).max(80), + description: z.string().max(400).optional(), + type: z.literal('boolean'), + default: z.boolean(), + }), + z.object({ + id: CONTRIBUTION_ID, + title: z.string().min(1).max(80), + description: z.string().max(400).optional(), + type: z.literal('number'), + // Finite only — a NaN default would be written straight into extension + // storage, where JSON.stringify turns it into null. + default: z.number().finite(), + }), + z.object({ + id: CONTRIBUTION_ID, + title: z.string().min(1).max(80), + description: z.string().max(400).optional(), + type: z.literal('string'), + default: z.string().max(400), + }), +]) + +const keybindingContribution = z.object({ + command: CONTRIBUTION_ID, + key: z.string().min(1).max(64), +}) + +// A closed capability set — like activationEvent, an unknown capability must fail +// install with a message, not resolve to nothing. Kept in lockstep with +// EXTENSION_CAPABILITIES in @shared/types/extensions (the schema wins on drift). +// +// ONLY IMPLEMENTED CAPABILITIES BELONG HERE. The Tier 2/3 names this enum used to +// accept had no request method, broker arm or API surface — approving them granted +// nothing while showing the user a warning dialog that said otherwise. `fs.read` +// returned only after all three layers shipped together. See the +// ExtensionCapability doc comment for the rule that replaced the old vocabulary. +const capabilityName = z.enum(EXTENSION_CAPABILITIES, { + // zod's default enum message lists the accepted values, which reads as a typo + // report. An author who wrote `fs.write` did not make a typo — they wrote + // against a capability this build does not have — so the message says that, + // in the same shape as the apiVersion mismatch below. + message: + `unknown capability — this build implements only ${EXTENSION_CAPABILITIES.join(', ')}. ` + + `Filesystem writes, transcript, git, prompt and network capabilities are not available yet.`, +}) + +// `.refine` validates but does not narrow, so the parsed type would be `string` +// and would not satisfy ExtensionActivationEvent. The transform is the narrowing +// step, and it is safe precisely because the refine above already rejected +// everything outside the union — if that predicate and this cast ever disagree, +// the predicate is the bug. +const activationEvent = z + .string() + .max(96) + .refine( + value => + value === 'onStartupFinished' || + value === '*' || + value.startsWith('onCommand:') || + value.startsWith('onView:'), + 'unknown activation event', + ) + .transform(value => value as ExtensionActivationEvent) + +export const extensionManifestSchema = z.object({ + id: EXTENSION_ID, + name: z.string().min(1).max(80), + description: z.string().min(1).max(400), + version: z.string().min(1).max(40), + apiVersion: z.number().int().positive(), + entry: ENTRY_PATH, + keywords: z.array(z.string().min(1).max(40)).max(24).optional(), + activationEvents: z.array(activationEvent).max(32).optional(), + contributes: z + .object({ + commands: z.array(commandContribution).max(64).optional(), + views: z.array(viewContribution).max(16).optional(), + settings: z.array(settingContribution).max(64).optional(), + keybindings: z.array(keybindingContribution).max(32).optional(), + themes: z.array(z.object({ + id: CONTRIBUTION_ID, + title: z.string().trim().min(1).max(80), + colors: extensionThemeColorsSchema, + }).strict()).max(16).optional(), + }) + .optional(), + permissions: z.array(capabilityName).max(16).optional(), +}).superRefine((manifest, context) => { + if (manifest.apiVersion === 1 && manifest.permissions?.includes('fs.read')) { + // v1 owns one isolated view-local activation and its public API is frozen. + // Advertising a capability that only the v2 runtime/view contract exposes + // would recreate the consent-with-no-call-path bug this schema prevents. + context.addIssue({ code: 'custom', path: ['permissions'], message: 'fs.read requires Agent Code API v2' }) + } + if (manifest.apiVersion !== 2) return + for (const [index, view] of (manifest.contributes?.views ?? []).entries()) { + if (!view.entry) context.addIssue({ code: 'custom', path: ['contributes', 'views', index, 'entry'], message: 'API v2 views require a separate mount module entry' }) + } +}) + +export class ManifestError extends Error { + constructor(message: string) { + super(message) + this.name = 'ManifestError' + } +} + +/** + * Parse and validate a manifest's raw text. + * + * Throws ManifestError with a message intended to be shown to the user — they are + * the one who chose the repository, so "this repo's manifest is missing `entry`" is + * actionable to them in a way a stack trace is not. + */ +export function parseExtensionManifest(raw: string): ExtensionManifest { + let json: unknown + try { + json = JSON.parse(raw) + } catch { + throw new ManifestError('agent-code.extension.json is not valid JSON') + } + + const result = extensionManifestSchema.safeParse(json) + if (!result.success) { + const detail = result.error.issues + .map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; ') + throw new ManifestError(`agent-code.extension.json is invalid — ${detail}`) + } + + // Checked after shape validation rather than inside the schema so the user gets + // "needs API v3, this build has v2" instead of a generic field error. This is the + // only failure here that is not the author's mistake — it means Agent Code is out + // of date relative to the extension, and the message should say so. + const versionError = apiVersionMismatch(result.data.apiVersion) + if (versionError) throw new ManifestError(versionError) + + assertContributionsAreCoherent(result.data) + return result.data +} + +/** + * The API-version gate, as a REUSABLE check rather than a step inside install. + * + * ── WHY THIS IS NOT ONLY AN INSTALL-TIME CONCERN ── + * It used to live solely in parseExtensionManifest, so it ran when a bundle was + * installed and never again. The ledger is re-read on every launch and validates + * rows with `extensionManifestSchema` directly, which accepts any positive + * integer — so a row recorded under one host version kept loading forever under + * the next one. The whole point of versioning the ABI is to refuse to hand a + * newer host object to an extension written against an older contract, and a + * check that only fires at install cannot do that: the host is what changes, + * and it changes without any install happening. + * + * Returns the user-facing message, or null when the version is supported. + */ +export function apiVersionMismatch(apiVersion: number): string | null { + if (apiVersion === 1 || apiVersion === SUPPORTED_API_VERSION) return null + return `extension targets Agent Code API v${apiVersion}, this build implements v${SUPPORTED_API_VERSION}` +} + +/** + * Cross-field checks the schema cannot express, because each needs the + * manifest's own `id` or a view of the whole `contributes` block. + */ +function assertContributionsAreCoherent(manifest: ExtensionManifest): void { + const prefix = `${manifest.id}.` + const commands = manifest.contributes?.commands ?? [] + const views = manifest.contributes?.views ?? [] + const settings = manifest.contributes?.settings ?? [] + const keybindings = manifest.contributes?.keybindings ?? [] + const themes = manifest.contributes?.themes ?? [] + + // WHY namespacing is ENFORCED and not merely conventional: contributed command + // ids land in one global registry beside ~95 first-party commands. An + // extension declaring `session.kill` would collide with a real one, and the + // resolution would be arbitrary. Install is the only moment where the user can + // still act on it, so it fails here rather than resolving oddly forever. + const namespaced = [...commands, ...views, ...settings, ...themes] + for (const contribution of namespaced) { + if (!contribution.id.startsWith(prefix)) { + throw new ManifestError( + `contribution id "${contribution.id}" must start with "${prefix}" — ` + + `extensions may only contribute inside their own namespace`, + ) + } + } + + // Duplicates WITHIN one manifest are always an authoring mistake and are + // rejected. Duplicates ACROSS extensions are a different problem — they are + // resolved at load time (first wins, second reported) because neither author + // can see the other's manifest and failing the install would punish whoever + // happened to install second. + assertUnique(commands.map(c => c.id), 'command') + assertUnique(views.map(v => v.id), 'view') + assertUnique(settings.map(s => s.id), 'setting') + assertUnique(themes.map(theme => theme.id), 'theme') + + const commandIds = new Set(commands.map(command => command.id)) + for (const binding of keybindings) { + if (!commandIds.has(binding.command)) { + throw new ManifestError( + `keybinding "${binding.key}" targets command "${binding.command}", ` + + `which this extension does not contribute`, + ) + } + } + + // An activation event naming a contribution that does not exist is silently + // dead — the extension would simply never activate, with no error anywhere. + // That is the single hardest authoring mistake to diagnose, so it is rejected. + const viewIds = new Set(views.map(view => view.id)) + for (const event of manifest.activationEvents ?? []) { + if (event.startsWith('onCommand:')) { + const target = event.slice('onCommand:'.length) + if (!commandIds.has(target)) { + throw new ManifestError( + `activation event "${event}" names a command this extension does not contribute`, + ) + } + } else if (event.startsWith('onView:')) { + const target = event.slice('onView:'.length) + if (!viewIds.has(target)) { + throw new ManifestError( + `activation event "${event}" names a view this extension does not contribute`, + ) + } + } + } +} + +function assertUnique(ids: string[], kind: string): void { + const seen = new Set() + for (const id of ids) { + if (seen.has(id)) throw new ManifestError(`duplicate ${kind} id "${id}"`) + seen.add(id) + } +} diff --git a/src/main/extensions/nativeInput.ts b/src/main/extensions/nativeInput.ts new file mode 100644 index 000000000..604c56a1d --- /dev/null +++ b/src/main/extensions/nativeInput.ts @@ -0,0 +1,86 @@ +import { ipcMain, type WebContents } from 'electron' +import { keybindingFromEvent, tryNormalizeKeybinding } from '@shared/keybindings.js' +import type { ExtensionInputBindings, ExtensionNativeInput } from '@shared/types/extensionInput.js' + +// Main only captures chords the trusted application router says it owns. It +// cannot ask that renderer asynchronously whether to preventDefault: Chromium +// and native menu actions would already have run (Cmd+W can close the window). +// Actual command selection stays in the existing context-aware renderer router. +export function registerExtensionInputIpc(isApplicationContents: (contents: WebContents) => boolean, onBindings?: (owner: number, bindings: readonly string[]) => void): () => void { + const owners = new Map; modalBindings: Set; dispose(): void }>() + ipcMain.handle('extensions:input-bindings', (event, raw: unknown) => { + const contents = event.sender + if (!isApplicationContents(contents) || event.senderFrame !== contents.mainFrame) throw new Error('Native extension input requires an application main frame.') + const config = raw as ExtensionInputBindings | null + const valid = (values: unknown): values is string[] => Array.isArray(values) && values.length <= 4096 && values.every(value => typeof value === 'string' && value.length <= 96 && tryNormalizeKeybinding(value) === value) + if (!config || typeof config !== 'object' || Object.keys(config).some(key => !['pane', 'modal'].includes(key)) || !valid(config.pane) || !valid(config.modal)) { + throw new Error('Invalid extension input bindings.') + } + let owner = owners.get(contents.id) + if (!owner) { + const pressed = new Set() + const focusedUrl = () => { + if (contents.isDestroyed() || !isApplicationContents(contents)) return null + const frame = contents.focusedFrame + if (!frame || frame.parent !== contents.mainFrame) return null + try { + const url = new URL(frame.url) + if (url.protocol !== 'agent-code-ext:' || !url.pathname.endsWith('/__agent-code-frame__.html') || !url.searchParams.get('instance')) return null + return frame.url + } catch { return null } + } + const send = (message: ExtensionNativeInput) => contents.send('extensions:native-input', message) + const key = (event: Electron.Event, input: Electron.Input) => { + if (event.defaultPrevented || input.isComposing) return + const url = focusedUrl() + if (!url) { if (input.type === 'keyUp') pressed.delete(input.code); return } + const value = { key: input.key, code: input.code, metaKey: input.meta, ctrlKey: input.control, + altKey: input.alt, shiftKey: input.shift, repeat: !!input.isAutoRepeat, isComposing: !!input.isComposing } + const binding = keybindingFromEvent(value) + // A modal owns editing and blocks hidden workspace mutations. Capture + // only its palette/close access; Option composition and native editing + // remain in the author's document. The parent verifies this exact URL. + const bindings = new URL(url).searchParams.get('shell') === 'modal' ? owner!.modalBindings : owner!.bindings + const release = input.type === 'keyUp' && (pressed.has(input.code) || (pressed.size > 0 && ['Meta', 'Control', 'Alt', 'Shift'].includes(input.key))) + if (input.type === 'keyDown' ? !binding || !bindings.has(binding) : !release) return + if (input.type === 'keyDown') pressed.add(input.code) + else pressed.delete(input.code) + // DOM-dispatched KeyboardEvents do not enter this hook. Neither child + // postMessage nor a claimed isTrusted flag can manufacture this authority. + event.preventDefault() + send({ kind: 'key', url, type: input.type === 'keyDown' ? 'keydown' : 'keyup', input: value }) + } + const mouse = (_event: Electron.Event, input: Electron.MouseInputEvent) => { + if (input.type !== 'mouseDown') return + // A repeated click within an already focused iframe does not blur the + // parent again. Correct workspace selection even in that case; first + // entry is also covered by the parent's activeElement/blur observation. + const url = focusedUrl() + if (url) send({ kind: 'focus', url }) + } + const navigate = (_event: Electron.Event, _url: string, sameDocument: boolean, main: boolean) => { + if (main && !sameDocument) { owner!.bindings.clear(); owner!.modalBindings.clear(); pressed.clear() } + } + const close = () => owner!.dispose() + owner = { bindings: new Set(), modalBindings: new Set(), dispose() { + contents.removeListener('before-input-event', key) + contents.removeListener('before-mouse-event', mouse) + contents.removeListener('did-start-navigation', navigate) + contents.removeListener('destroyed', close) + owners.delete(contents.id) + } } + owners.set(contents.id, owner) + contents.on('before-input-event', key) + contents.on('before-mouse-event', mouse) + contents.on('did-start-navigation', navigate) + contents.once('destroyed', close) + } + owner.bindings = new Set(config.pane) + owner.modalBindings = new Set(config.modal) + onBindings?.(contents.id, config.pane) + }) + return () => { + ipcMain.removeHandler('extensions:input-bindings') + for (const owner of owners.values()) owner.dispose() + } +} diff --git a/src/main/extensions/runtimeDocument.ts b/src/main/extensions/runtimeDocument.ts new file mode 100644 index 000000000..34b690da5 --- /dev/null +++ b/src/main/extensions/runtimeDocument.ts @@ -0,0 +1,106 @@ +import type { ExtensionManifest } from '@shared/types/extensions.js' +import { childFrameCsp } from './frameDocument.js' + +export const RUNTIME_DOCUMENT = '__agent-code-runtime__.html' + +// The runtime owns commands and extension-wide state, while separate view +// documents own DOM mounts. No function crosses that boundary: a view sends a +// named request and receives bounded JSON. This is the key distinction from the +// legacy activate/registerView closure ABI, which remains in frameDocument.ts. +export function buildRuntimeDocument(manifest: ExtensionManifest, nonce: string): string { + const config = JSON.stringify({ id: manifest.id, apiVersion: manifest.apiVersion, entry: manifest.entry, + commands: manifest.contributes?.commands?.map(command => command.id) ?? [], + views: manifest.contributes?.views?.map(view => view.id) ?? [], + }).replace(/ String(error && error.message || error).slice(0, 2000); +const api = { + extension: { id: cfg.id, apiVersion: cfg.apiVersion }, + storage: { + get: key => transport.request({ method: 'storage.get', key }), + set: (key, value) => transport.request({ method: 'storage.set', key, value }), + delete: key => transport.request({ method: 'storage.delete', key }), + keys: () => transport.request({ method: 'storage.keys' }), + }, + // A background engine has no focused project. Requiring the session on every + // call keeps authority stable when windows or pane focus change underneath it; + // main resolves that id to its own spawn cwd and rejects arbitrary roots. + files: { + readText: ({ sessionId, path }) => transport.request({ method: 'fs.readText', sessionId, path }), + }, +}; +function register(map, id, handler) { + if (typeof handler !== 'function') throw new Error('A runtime handler must be a function.'); + if (map.has(id)) throw new Error('A handler is already registered for ' + id); + map.set(id, handler); + return { dispose() { if (map.get(id) === handler) map.delete(id); } }; +} +const context = { + api, subscriptions, + registerCommand(id, handler) { + if (!cfg.commands.includes(id)) throw new Error('Command is not declared: ' + id); + return register(commands, id, handler); + }, + registerRequest(name, handler) { + if (typeof name !== 'string' || !/^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/.test(name)) throw new Error('Invalid request name.'); + return register(requests, name, handler); + }, + views: { publish(viewId, state) { + if (!cfg.views.includes(viewId)) throw new Error('View is not declared: ' + viewId); + return transport.request({ method: 'views.publish', viewId, state }); + } }, +}; +// BrowserWindow.destroy() does not promise unload events. Main sends an explicit +// shutdown and independently enforces its deadline. Stop admitting invocations +// before awaiting author cleanup; all authority was already revoked in main. +function dispose() { + if (cleanup) return cleanup; + disposed = true; + cleanup = (async () => { + try { await extension?.deactivate?.(); } catch {} + await Promise.allSettled(subscriptions.slice().reverse().map(async subscription => { + try { await subscription.dispose(); } catch {} + })); + })(); + return cleanup; +} +transport.subscribe(async message => { + if (message.kind === 'shutdown') { + await dispose(); + transport.send({ kind: 'stopped', id: message.id }); + return; + } + if (disposed) return; + try { + const handler = message.kind === 'command' ? commands.get(message.commandId) : requests.get(message.name); + if (!handler) throw new Error('No runtime handler registered for ' + (message.commandId || message.name)); + const value = message.kind === 'command' ? await handler() : await handler(message.input, message.view); + if (!disposed) transport.send({ kind: 'result', id: message.id, ok: true, ...(value === undefined ? {} : { value }) }); + } catch (error) { + if (!disposed) transport.send({ kind: 'result', id: message.id, ok: false, error: errorText(error) }); + } +}); +import('./' + cfg.entry).then(module => { + extension = typeof module.activate === 'function' ? module : module.default; + if (!extension || typeof extension.activate !== 'function') throw new Error('Extension entry must export activate(context).'); + if (!disposed) return extension.activate(context); +}).then(() => { + if (!disposed) transport.send({ kind: 'ready' }); +}).catch(error => { if (!disposed) transport.send({ kind: 'failed', error: errorText(error) }); }); +// Teardown never carries persistence guarantees: state must be saved when it +// changes. The host revokes authority before destroying a retired runtime, so an +// async storage write from deactivate cannot sneak past an uninstall/update. +window.addEventListener('pagehide', () => { void dispose(); }); + +` + return `` +} diff --git a/src/main/extensions/runtimeIpc.ts b/src/main/extensions/runtimeIpc.ts new file mode 100644 index 000000000..d3bff99a1 --- /dev/null +++ b/src/main/extensions/runtimeIpc.ts @@ -0,0 +1,75 @@ +import { ipcMain, webContents, type IpcMainInvokeEvent, type WebContents } from 'electron' +import { isExtensionJson, runtimeHostRequestSchema } from '@shared/types/extensionRuntime.js' +import { extensionRevision } from '@shared/types/extensions.js' +import { readLedger } from './ledger.js' +import type { ExtensionRuntimeService } from './runtimeService.js' +import type { ExtensionCapabilityService } from './capabilityService.js' +import { ExtensionRuntimeViews } from './runtimeViews.js' + +export function registerExtensionRuntimeIpc( + service: ExtensionRuntimeService, + capabilities: ExtensionCapabilityService, + isApplicationContents: (contents: WebContents) => boolean, +): () => void { + const owners = new Map() + const views = new ExtensionRuntimeViews(service, (owner, event) => { + const contents = webContents.fromId(owner) + if (!contents || contents.isDestroyed() || !isApplicationContents(contents)) throw new Error('Extension view owner disappeared.') + contents.send('extensions:runtime-view', event) + }) + + function authenticate(event: IpcMainInvokeEvent): { owner: number; assertCurrent(): void } { + if (!isApplicationContents(event.sender) || event.senderFrame !== event.sender.mainFrame) throw new Error('Extension host requests require a registered application main frame.') + const owner = event.sender.id + if (!owners.has(owner)) { + const close = () => { views.detachOwner(owner); owners.get(owner)?.dispose() } + // A reload keeps the WebContents id but replaces every document and broker. + // Drop old ownership before navigation, not only when the native window + // closes, so old pending calls cannot feed the replacement renderer. + const navigate = (_event: Electron.Event, _url: string, sameDocument: boolean, isMainFrame: boolean) => { + if (isMainFrame && !sameDocument) { + views.detachOwner(owner) + const current = owners.get(owner) + if (current) current.generation += 1 + } + } + event.sender.once('destroyed', close) + event.sender.on('did-start-navigation', navigate) + owners.set(owner, { generation: 0, dispose() { + event.sender.removeListener('destroyed', close) + event.sender.removeListener('did-start-navigation', navigate) + owners.delete(owner) + } }) + } + const record = owners.get(owner)! + const generation = record.generation + return { owner, assertCurrent() { + if (owners.get(owner) !== record || record.generation !== generation || event.sender.isDestroyed() || !isApplicationContents(event.sender)) throw new Error('Extension view owner document changed.') + } } + } + + ipcMain.handle('extensions:runtime-host', async (event, raw: unknown) => { + const { owner, assertCurrent } = authenticate(event) + if (!isExtensionJson(raw)) throw new Error('Extension host request exceeds the JSON limits.') + const request = runtimeHostRequestSchema.parse(raw) + if (request.method === 'command' || request.method === 'view.attach' || request.method === 'service') { + const entry = (await readLedger()).find(candidate => candidate.manifest.id === request.extensionId) + if (!entry || extensionRevision(entry) !== request.revision || entry.manifest.apiVersion !== 2) throw new Error('This installation does not support the managed extension runtime.') + // ensure() rechecks the same generation under the publication lock. This + // earlier version check is only an ABI gate, never the authority snapshot. + } + assertCurrent() + switch (request.method) { + case 'command': return service.invokeCommand(request.extensionId, request.revision, request.commandId) + case 'view.attach': return views.attach(owner, request.connectionId, request.extensionId, request.revision, request.viewId) + case 'view.request': return views.request(owner, request.connectionId, request.name, request.input) + case 'view.detach': views.detach(owner, request.connectionId); return + case 'service': return capabilities.invoke(request.extensionId, request.revision, request.request) + } + }) + return () => { + ipcMain.removeHandler('extensions:runtime-host') + views.dispose() + for (const owner of owners.values()) owner.dispose() + } +} diff --git a/src/main/extensions/runtimeService.ts b/src/main/extensions/runtimeService.ts new file mode 100644 index 000000000..eec4eaeff --- /dev/null +++ b/src/main/extensions/runtimeService.ts @@ -0,0 +1,466 @@ +import { randomUUID } from 'node:crypto' +import { BrowserWindow, ipcMain, session, type IpcMainEvent, type IpcMainInvokeEvent, type Session } from 'electron' +import { extensionRevision, type InstalledExtension, type ExtensionActivationEvent } from '@shared/types/extensions.js' +import { + isExtensionJson, runtimeApiRequestSchema, runtimeEventSchema, + type ExtensionJson, type RuntimeInvocation, type RuntimeStatus, type RuntimeChange, type RuntimeViewSnapshot, +} from '@shared/types/extensionRuntime.js' +import { onExtensionPublication, readLedger, withLedgerLock } from './ledger.js' +import { extensionStorageDelete, extensionStorageGet, extensionStorageKeys, extensionStorageSet } from './storage.js' +import { EXTENSION_SCHEME, handleExtensionScheme } from './scheme.js' +import { RUNTIME_DOCUMENT } from './runtimeDocument.js' +import type { ExtensionCapabilityService } from './capabilityService.js' + +type PendingInvocation = { + resolve(value: ExtensionJson | undefined): void + reject(error: Error): void + timer: ReturnType +} +type Retirement = { runtime: ManagedRuntime; token: string; done: Promise; finish(): void } +type ManagedRuntime = { + installation: InstalledExtension + revision: string + url: string + window: BrowserWindow + senderId: number + session: Session + state: RuntimeStatus['state'] + ready: Promise + resolveReady(): void + rejectReady(error: Error): void + startupTimer: ReturnType + pending: Map + viewStates: Map + apiInFlight: number + credits: number + creditedAt: number +} + +export type ExtensionRuntimeOptions = { + preload: string + capabilities: ExtensionCapabilityService + onStatus?(status: RuntimeStatus): void + onViewState?(extensionId: string, revision: string, viewId: string, state: ExtensionJson): void + /** Deadline injection keeps real-process failure tests short; production uses the defaults. */ + startupTimeoutMs?: number + invocationTimeoutMs?: number + shutdownTimeoutMs?: number +} + +// Main owns this registry, not an application window. A command and two views +// all rendezvous on the same installation generation. Application window closes +// therefore cannot destroy extension-wide state. No extension receives the host +// preload or a generic IPC channel: the dedicated runtime preload exposes only +// this module's schema-validated, sender-bound transport. +export class ExtensionRuntimeService { + private readonly runtimes = new Map() + private readonly senders = new Map() + private readonly retiring = new Map() + private disposal?: Promise + private readonly listeners = new Set<(event: RuntimeChange) => void>() + private readonly preparations = new Map>() + private readonly callers = new Map() + private readonly unsubscribe: () => void + private closed = false + private paused = false + private startupEnabled = false + private readonly publishedRevisions = new Map() + + constructor(private readonly options: ExtensionRuntimeOptions) { + ipcMain.handle('extensions:runtime-api', this.onApi) + ipcMain.on('extensions:runtime-event', this.onEvent) + this.unsubscribe = onExtensionPublication(rows => { + for (const runtime of this.runtimes.values()) { + const next = rows.find(row => row.manifest.id === runtime.installation.manifest.id) + if (!next || extensionRevision(next) !== runtime.revision) { + // Revocation is synchronous with publication. Waiting for a React + // unmount (or a graceful author callback) would leave the superseded + // runtime able to call APIs after its permission record was replaced. + this.retire(runtime, 'Extension was updated or removed.', 'stopped') + } + } + const changed = rows.filter(row => this.publishedRevisions.get(row.manifest.id) !== extensionRevision(row)) + this.publishedRevisions.clear() + for (const row of rows) this.publishedRevisions.set(row.manifest.id, extensionRevision(row)) + if (this.startupEnabled && !this.paused) for (const row of changed) void this.startRequested(row) + + }) + } + + private async startRequested(row: InstalledExtension): Promise { + if (row.manifest.apiVersion !== 2 || !row.manifest.activationEvents?.some(event => event === '*' || event === 'onStartupFinished')) return + try { await this.start(row.manifest.id, extensionRevision(row)) } + catch (error) { console.error(`[extensions] startup failed for ${row.manifest.id}:`, error) } + } + + async activateStartupExtensions(): Promise { + if (this.closed) return + this.startupEnabled = true + const rows = await withLedgerLock(async () => { + if (this.closed || this.paused) return [] + const current = await readLedger() + // Arm publication while holding the same lock as this snapshot. Otherwise + // an install between read and subscribe could lose its startup activation. + this.publishedRevisions.clear() + for (const row of current) this.publishedRevisions.set(row.manifest.id, extensionRevision(row)) + return current + }) + await Promise.all(rows.map(row => this.startRequested(row))) + } + + async pause(): Promise { + // A native unsaved-file sheet can cancel app quit. Keep this service and its + // IPC registrations reusable until will-quit is admitted; only the runtimes + // are drained during the cancellable phase. Saved state remains durable. + this.paused = true + for (const runtime of this.runtimes.values()) this.retire(runtime, 'Agent Code is closing.', 'stopped') + await Promise.all([...this.retiring.values()].map(retirement => retirement.done)) + } + + async resume(): Promise { + if (this.closed) return + this.paused = false + if (this.startupEnabled) await this.activateStartupExtensions() + } + + async start(extensionId: string, revision: string): Promise { + await this.withCall(extensionId, async () => { + const runtime = await this.ensure(extensionId, revision) + await runtime.ready + }) + } + + subscribe(listener: (event: RuntimeChange) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + async startView(extensionId: string, revision: string, viewId: string): Promise { + await this.withCall(extensionId, async () => { + const runtime = await this.ensure(extensionId, revision, installation => { + if (!installation.manifest.contributes?.views?.some(view => view.id === viewId)) throw new Error('View is not declared by this extension.') + }, `onView:${viewId}`) + await runtime.ready + }) + } + + async invokeCommand(extensionId: string, revision: string, commandId: string): Promise { + return this.withCall(extensionId, async () => { + const runtime = await this.ensure(extensionId, revision, installation => { + if (!installation.manifest.contributes?.commands?.some(command => command.id === commandId)) throw new Error('Command is not declared by this extension.') + }, `onCommand:${commandId}`) + await runtime.ready + return this.invoke(runtime, { kind: 'command', id: randomUUID(), commandId }) + }) + } + + async requestFromView(extensionId: string, revision: string, view: { id: string; instanceId: string }, name: string, input: ExtensionJson, assertCallerCurrent?: () => void): Promise { + if (!/^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/.test(name) || !isExtensionJson(input) || !view.instanceId || view.instanceId.length > 128) throw new Error('Invalid extension view request.') + return this.withCall(extensionId, async () => { + const runtime = await this.ensure(extensionId, revision, installation => { + if (!installation.manifest.contributes?.views?.some(candidate => candidate.id === view.id)) throw new Error('View is not declared by this extension.') + }, `onView:${view.id}`) + await runtime.ready + // An owner may close while startup/ledger I/O is pending. Check its + // main-owned binding at the last boundary before dispatching author code. + assertCallerCurrent?.() + return this.invoke(runtime, { kind: 'request', id: randomUUID(), name, input, view }) + }) + } + + viewState(extensionId: string, revision: string, viewId: string): ExtensionJson | undefined { + const runtime = this.runtimes.get(extensionId) + if (!runtime || runtime.revision !== revision) return undefined + return structuredClone(runtime.viewStates.get(viewId)?.state) + } + + viewSnapshot(extensionId: string, revision: string, viewId: string): RuntimeViewSnapshot { + const runtime = this.runtimes.get(extensionId) + if (!runtime || runtime.revision !== revision || !this.active(runtime)) throw new Error('Extension runtime is no longer active.') + return structuredClone(runtime.viewStates.get(viewId) ?? { sequence: 0 }) + } + + dispose(): Promise { + if (this.disposal) return this.disposal + this.closed = true + this.unsubscribe() + ipcMain.removeHandler('extensions:runtime-api') + for (const runtime of this.runtimes.values()) this.retire(runtime, 'Extension host stopped.', 'stopped') + // Keep only the acknowledgement listener while draining. Revoked senders + // cannot make API calls or complete commands; normal shutdown may still + // acknowledge cleanup before the independent force-destroy deadline. + this.disposal = Promise.all([...this.retiring.values()].map(retirement => retirement.done)).then(() => { + ipcMain.removeListener('extensions:runtime-event', this.onEvent) + this.listeners.clear() + }) + return this.disposal + } + + private async withCall(extensionId: string, operation: () => Promise): Promise { + // Count before ledger I/O and activation, not only after the runtime becomes + // ready. Otherwise a hung startup could accumulate an unbounded list of + // callers waiting on the same promise while pending.size still read zero. + const count = this.callers.get(extensionId) ?? 0 + if (count >= 32) throw new Error('Too many extension calls are pending.') + this.callers.set(extensionId, count + 1) + try { return await operation() } + finally { + const remaining = (this.callers.get(extensionId) ?? 1) - 1 + if (remaining) this.callers.set(extensionId, remaining) + else this.callers.delete(extensionId) + } + } + + private async ensure(extensionId: string, revision: string, validate?: (installation: InstalledExtension) => void, reason: ExtensionActivationEvent = 'onStartupFinished'): Promise { + return withLedgerLock(async () => { + if (this.closed) throw new Error('Extension host is stopped.') + if (this.paused) throw new Error('Extension host is shutting down.') + const installation = (await readLedger()).find(row => row.manifest.id === extensionId) + if (!installation || extensionRevision(installation) !== revision) throw new Error('This extension installation is no longer active.') + validate?.(installation) + const existing = this.runtimes.get(extensionId) + if (existing?.revision === revision) return existing + if (installation.manifest.apiVersion === 2 && !installation.manifest.activationEvents?.some(event => event === '*' || event === reason)) { + throw new Error(`This extension does not declare activation for ${reason}.`) + } + if (existing) this.retire(existing, 'Extension generation changed.', 'stopped') + // Serialize reuse of the private session through actual window teardown. + // Otherwise old cleanup/unhandle could race a newly created generation + // and remove its protocol handler beneath activation. + // This wait is bounded and acknowledgements never need the ledger lock. + await Promise.all([...this.retiring.values()] + .filter(retirement => retirement.runtime.installation.manifest.id === extensionId) + .map(retirement => retirement.done)) + if (this.closed) throw new Error('Extension host is stopped.') + if (this.paused) throw new Error('Extension host is shutting down.') + if (this.runtimes.size + this.retiring.size >= 16) throw new Error('At most 16 extension runtimes can run at once.') + // Insert under the publication lock BEFORE any navigation can begin. Two + // simultaneous cold commands must not each construct an engine, and an + // update must not slip between reading the ledger and registering its owner. + // Do not await loadURL here: scheme requests need this same ledger lock. + return this.create(installation) + }) + } + + private create(installation: InstalledExtension): ManagedRuntime { + const extensionId = installation.manifest.id + const revision = extensionRevision(installation) + const isolated = session.fromPartition(`extension-runtime:${extensionId}`) + handleExtensionScheme(isolated.protocol) + isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)) + isolated.setPermissionCheckHandler(() => false) + // CSP does not govern every navigation/egress channel. This session belongs + // only to this runtime, so blocking direct external/file requests cannot + // break application networking, editor files or microphone access. Future + // network capabilities must use the consent-gated main API, never raw fetch. + isolated.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*', 'file://*/*'] }, (_details, callback) => callback({ cancel: true })) + let window: BrowserWindow + try { + window = new BrowserWindow({ + // Background code must not gain a keyboard target or a taskbar surface + // by calling window.focus(). Views are the only visible extension UI. + show: false, focusable: false, skipTaskbar: true, width: 1, height: 1, + webPreferences: { preload: this.options.preload, session: isolated, sandbox: true, contextIsolation: true, nodeIntegration: false, nodeIntegrationInSubFrames: false, backgroundThrottling: false }, + }) + } catch (error) { + isolated.protocol.unhandle(EXTENSION_SCHEME) + throw error + } + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + window.webContents.on('will-navigate', event => event.preventDefault()) + window.webContents.on('will-frame-navigate', event => event.preventDefault()) + window.webContents.on('will-redirect', event => event.preventDefault()) + let resolveReady!: () => void + let rejectReady!: (error: Error) => void + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject }) + // A startup can fail between publication and its first caller awaiting ready. + // Keep the original rejecting promise for callers but mark it observed here. + void ready.catch(() => {}) + const runtime: ManagedRuntime = { + installation, revision, window, senderId: window.webContents.id, session: isolated, + url: `agent-code-ext://${extensionId}/__bundle/${encodeURIComponent(revision)}/${RUNTIME_DOCUMENT}`, + state: 'starting', ready, resolveReady, rejectReady, + startupTimer: setTimeout(() => this.retire(runtime, 'Extension runtime did not finish starting before the deadline.'), this.options.startupTimeoutMs ?? 10_000), + pending: new Map(), viewStates: new Map(), apiInFlight: 0, credits: 128, creditedAt: performance.now(), + } + this.runtimes.set(extensionId, runtime) + this.senders.set(window.webContents.id, runtime) + window.webContents.on('render-process-gone', (_event, details) => this.retire(runtime, `Extension runtime process ended: ${details.reason}.`)) + window.on('closed', () => { + this.retire(runtime, 'Extension runtime window closed.') + this.retiring.get(runtime.senderId)?.finish() + }) + this.publishStatus(runtime) + // Browser storage is disposable. The dedicated main storage API is the only + // durable contract; clearing before navigation prevents a new generation from + // inheriting ambient cookies/localStorage left by the previous engine. + // A generation can retire while its asynchronous browser-storage clear is + // still pending. Chain the next clear behind it, otherwise an older clear + // could finish after a replacement has already started using the session. + // Startup's deadline still bounds the caller if Chromium never completes it. + const preparation = (this.preparations.get(extensionId) ?? Promise.resolve()) + .catch(() => {}) + .then(() => isolated.clearStorageData()) + this.preparations.set(extensionId, preparation) + const forgetPreparation = () => { if (this.preparations.get(extensionId) === preparation) this.preparations.delete(extensionId) } + void preparation.then(forgetPreparation, forgetPreparation) + void preparation.then(() => { + if (!this.active(runtime)) return + return window.loadURL(runtime.url) + }).catch(error => this.retire(runtime, String(error))) + return runtime + } + + private active(runtime: ManagedRuntime): boolean { + return this.runtimes.get(runtime.installation.manifest.id) === runtime && (runtime.state === 'starting' || runtime.state === 'ready') + } + + private authenticate(event: IpcMainEvent | IpcMainInvokeEvent): ManagedRuntime { + const runtime = this.senders.get(event.sender.id) + if (!runtime || !this.active(runtime) || event.senderFrame !== event.sender.mainFrame || event.senderFrame?.url !== runtime.url) throw new Error('Request is not from an active extension runtime.') + // Per-runtime budget covers both successful and malformed messages. Counting + // only accepted requests would let invalid payloads monopolize the main loop. + const now = performance.now() + runtime.credits = Math.min(128, runtime.credits + (now - runtime.creditedAt) * 0.128) + runtime.creditedAt = now + if (runtime.credits < 1) throw new Error('Extension runtime message rate exceeded.') + runtime.credits -= 1 + return runtime + } + + private onApi = async (event: IpcMainInvokeEvent, raw: unknown): Promise => { + const runtime = this.authenticate(event) + if (!isExtensionJson(raw)) throw new Error('Extension request exceeds the JSON limits.') + const request = runtimeApiRequestSchema.parse(raw) + if (runtime.apiInFlight >= 32) throw new Error('Too many extension API requests are pending.') + runtime.apiInFlight += 1 + try { + const id = runtime.installation.manifest.id + let result: unknown + switch (request.method) { + case 'storage.get': result = await extensionStorageGet(id, request.key); break + case 'storage.set': await extensionStorageSet(id, request.key, request.value); break + case 'storage.delete': await extensionStorageDelete(id, request.key); break + case 'storage.keys': result = await extensionStorageKeys(id); break + case 'fs.readText': result = await this.options.capabilities.invoke(id, runtime.revision, request); break + case 'views.publish': { + if (!runtime.installation.manifest.contributes?.views?.some(view => view.id === request.viewId)) throw new Error('View is not declared by this extension.') + const snapshot = { sequence: (runtime.viewStates.get(request.viewId)?.sequence ?? 0) + 1, state: request.state } + runtime.viewStates.set(request.viewId, snapshot) + this.emit({ kind: 'view', extensionId: id, revision: runtime.revision, viewId: request.viewId, snapshot }) + try { this.options.onViewState?.(id, runtime.revision, request.viewId, structuredClone(request.state)) } + catch (error) { console.error('[extensions] view state subscriber failed:', error) } + break + } + default: { + const unhandled: never = request + throw new Error(`Unhandled extension runtime API request: ${String(unhandled)}`) + } + } + if (!this.active(runtime)) throw new Error('Extension runtime ended during the request.') + if (result !== undefined && !isExtensionJson(result)) throw new Error('Extension API result exceeds the JSON limits.') + return result + } finally { runtime.apiInFlight -= 1 } + } + + private onEvent = (event: IpcMainEvent, raw: unknown): void => { + const retirement = this.retiring.get(event.sender.id) + if (retirement) { + // Teardown has a separate, single-purpose identity: no ready/result/API + // message can restore a revoked runtime. A guessed acknowledgement from a + // sibling frame or another process must not cut cleanup short either. + if (event.senderFrame !== event.sender.mainFrame || event.senderFrame?.url !== retirement.runtime.url || !isExtensionJson(raw)) return + const parsed = runtimeEventSchema.safeParse(raw) + if (parsed.success && parsed.data.kind === 'stopped' && parsed.data.id === retirement.token) retirement.finish() + return + } + let runtime: ManagedRuntime + try { runtime = this.authenticate(event) } catch { return } + if (!isExtensionJson(raw)) { this.retire(runtime, 'Extension runtime response exceeds the JSON limits.'); return } + const parsed = runtimeEventSchema.safeParse(raw) + if (!parsed.success) { this.retire(runtime, 'Extension runtime sent an invalid response.'); return } + const message = parsed.data + if (message.kind === 'ready') { + if (runtime.state !== 'starting') return + clearTimeout(runtime.startupTimer) + runtime.state = 'ready' + runtime.resolveReady() + this.publishStatus(runtime) + } else if (message.kind === 'failed') this.retire(runtime, message.error) + else if (message.kind === 'result') { + const pending = runtime.pending.get(message.id) + if (!pending) return + runtime.pending.delete(message.id) + clearTimeout(pending.timer) + if (message.ok) pending.resolve(message.value) + else pending.reject(new Error(message.error)) + } + } + + private invoke(runtime: ManagedRuntime, message: RuntimeInvocation): Promise { + if (!this.active(runtime)) return Promise.reject(new Error('Extension runtime is no longer active.')) + if (runtime.pending.size >= 32) return Promise.reject(new Error('Too many extension commands or view requests are pending.')) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // Never replay an action with an unknown outcome. Stop its execution + // context and reject all waiters; a user may explicitly retry afterward. + this.retire(runtime, 'Extension invocation timed out; its result is unknown.') + }, this.options.invocationTimeoutMs ?? 30_000) + runtime.pending.set(message.id, { resolve, reject, timer }) + try { runtime.window.webContents.send('extensions:runtime-invoke', message) } + catch (error) { this.retire(runtime, String(error)) } + }) + } + + private retire(runtime: ManagedRuntime, error: string, state: 'failed' | 'stopped' = 'failed'): void { + if (!this.active(runtime)) return + runtime.state = state + this.runtimes.delete(runtime.installation.manifest.id) + this.senders.delete(runtime.senderId) + clearTimeout(runtime.startupTimer) + runtime.rejectReady(new Error(error)) + for (const pending of runtime.pending.values()) { + clearTimeout(pending.timer) + pending.reject(new Error(error)) + } + runtime.pending.clear() + runtime.viewStates.clear() + this.publishStatus(runtime, error) + let resolveDone!: () => void + const done = new Promise(resolve => { resolveDone = resolve }) + let finished = false + let timer: ReturnType | undefined + const finish = () => { + if (finished) return + finished = true + clearTimeout(timer) + runtime.session.protocol.unhandle(EXTENSION_SCHEME) + if (!runtime.window.isDestroyed()) runtime.window.destroy() + this.retiring.delete(runtime.senderId) + resolveDone() + } + const token = randomUUID() + this.retiring.set(runtime.senderId, { runtime, token, done, finish }) + // Unknown command outcomes and failed startups are terminated immediately. + // Only a normal update/remove/app shutdown gives cooperative code a short + // cleanup opportunity; it has no durable API authority during that grace. + if (state === 'failed' || runtime.window.isDestroyed()) { finish(); return } + timer = setTimeout(finish, this.options.shutdownTimeoutMs ?? 500) + try { runtime.window.webContents.send('extensions:runtime-invoke', { kind: 'shutdown', id: token } satisfies RuntimeInvocation) } + catch { finish() } + } + + private emit(event: RuntimeChange): void { + for (const listener of [...this.listeners]) { + try { listener(structuredClone(event)) } + catch (failure) { console.error('[extensions] runtime subscriber failed:', failure) } + } + } + + private publishStatus(runtime: ManagedRuntime, error?: string): void { + const status: RuntimeStatus = { extensionId: runtime.installation.manifest.id, revision: runtime.revision, state: runtime.state, ...(error ? { error } : {}) } + this.emit({ kind: 'status', status }) + try { this.options.onStatus?.(status) } + catch (failure) { console.error('[extensions] runtime status subscriber failed:', failure) } + } +} diff --git a/src/main/extensions/runtimeViews.ts b/src/main/extensions/runtimeViews.ts new file mode 100644 index 000000000..647e33f10 --- /dev/null +++ b/src/main/extensions/runtimeViews.ts @@ -0,0 +1,106 @@ +import { randomUUID } from 'node:crypto' +import type { ExtensionJson, RuntimeChange, RuntimeViewEvent, RuntimeViewSnapshot } from '@shared/types/extensionRuntime.js' +import type { ExtensionRuntimeService } from './runtimeService.js' + +type Connection = { + owner: number + connectionId: string + instanceId: string + extensionId: string + revision: string + viewId: string +} + +// This registry is the main-side half of the view broker. The IPC adapter must +// derive owner from an authenticated application WebContents; extension code +// never supplies it. A connection fixes extension/generation/view authority once, +// so subsequent requests cannot switch namespace by changing payload fields. +export class ExtensionRuntimeViews { + private readonly connections = new Map() + private readonly unsubscribe: () => void + private closed = false + + constructor(private readonly service: ExtensionRuntimeService, private readonly send: (owner: number, event: RuntimeViewEvent) => void) { + this.unsubscribe = service.subscribe(this.onChange) + } + + async attach(owner: number, connectionId: string, extensionId: string, revision: string, viewId: string): Promise<{ instanceId: string; snapshot: RuntimeViewSnapshot }> { + if (this.closed) throw new Error('Extension view host is stopped.') + if (!connectionId || connectionId.length > 128) throw new Error('Invalid extension view connection.') + const key = this.key(owner, connectionId) + if (this.connections.has(key)) throw new Error('Extension view connection already exists.') + if (this.connections.size >= 256 || [...this.connections.values()].filter(connection => connection.owner === owner).length >= 32) { + throw new Error('Too many extension views are attached.') + } + const connection: Connection = { owner, connectionId, instanceId: randomUUID(), extensionId, revision, viewId } + // Publish ownership BEFORE asynchronous activation. Closing a pane or its + // whole window while activate() awaits storage must cancel this attachment, + // and state published during activation must reach the pending subscriber. + this.connections.set(key, connection) + try { + await this.service.startView(extensionId, revision, viewId) + this.assertCurrent(connection) + return { instanceId: connection.instanceId, snapshot: this.service.viewSnapshot(extensionId, revision, viewId) } + } catch (error) { + if (this.connections.get(key) === connection) this.connections.delete(key) + throw error + } + } + + async request(owner: number, connectionId: string, name: string, input: ExtensionJson): Promise { + const connection = this.connections.get(this.key(owner, connectionId)) + if (!connection) throw new Error('Extension view connection is closed.') + const { extensionId, revision, viewId, instanceId } = connection + const result = await this.service.requestFromView(extensionId, revision, { id: viewId, instanceId }, name, input, () => this.assertCurrent(connection)) + // A completed action is never replayed, but its reply belongs to the document + // that issued it. It must not reach a replacement document using the same + // WindowProxy after a close/retry or an installation publication. + this.assertCurrent(connection) + return result + } + + detach(owner: number, connectionId: string): void { + this.connections.delete(this.key(owner, connectionId)) + // Deliberately no runtime stop: views are consumers of extension-wide state, + // and closing the final view must not stop commands or background work. + } + + detachOwner(owner: number): void { + for (const connection of this.connections.values()) { + if (connection.owner === owner) this.detach(owner, connection.connectionId) + } + } + + dispose(): void { + if (this.closed) return + this.closed = true + this.unsubscribe() + this.connections.clear() + } + + private key(owner: number, connectionId: string): string { return `${owner}:${connectionId}` } + + private assertCurrent(connection: Connection): void { + if (this.connections.get(this.key(connection.owner, connection.connectionId)) !== connection) throw new Error('Extension view connection is closed.') + } + + private deliver(connection: Connection, event: RuntimeViewEvent): void { + try { this.send(connection.owner, event) } + catch { this.detach(connection.owner, connection.connectionId) } + } + + private onChange = (event: RuntimeChange): void => { + for (const connection of this.connections.values()) { + if (event.kind === 'view') { + if (connection.extensionId === event.extensionId && connection.revision === event.revision && connection.viewId === event.viewId) { + this.deliver(connection, { kind: 'state', connectionId: connection.connectionId, snapshot: event.snapshot }) + } + } else if (event.status.state === 'failed' || event.status.state === 'stopped') { + if (connection.extensionId === event.status.extensionId && connection.revision === event.status.revision) { + this.detach(connection.owner, connection.connectionId) + this.deliver(connection, { kind: 'closed', connectionId: connection.connectionId, error: event.status.error ?? 'Extension runtime stopped.' }) + } + } + } + } +} diff --git a/src/main/extensions/scheme.ts b/src/main/extensions/scheme.ts new file mode 100644 index 000000000..df4b7eb49 --- /dev/null +++ b/src/main/extensions/scheme.ts @@ -0,0 +1,307 @@ +import { randomBytes } from 'crypto' +import { net, protocol, type Protocol } from 'electron' +import { realpath } from 'fs/promises' +import { extname, resolve as resolvePath, sep } from 'path' +import { pathToFileURL } from 'url' + +import { isValidExtensionId } from '@shared/types/extensionId.js' +import { extensionRevision } from '@shared/types/extensions.js' +import { buildFrameDocument, childFrameCsp } from '@main/extensions/frameDocument.js' +import { extensionBundleDirectory, readLedger, withLedgerLock } from '@main/extensions/ledger.js' +import { buildRuntimeDocument, RUNTIME_DOCUMENT } from './runtimeDocument.js' + +export const EXTENSION_SCHEME = 'agent-code-ext' + +// The reserved path that serves the sandbox frame HTML document rather than a +// bundle asset. An extension cannot ship a file at this path — it collides with +// nothing real because the `.` in a normal entry can never produce this exact +// name, and even if it did, this branch is checked first and serves the host's +// document, not the extension's file. +const RESERVED_FRAME_PATH = '__agent-code-frame__.html' + +// Serving installed extension bundles to the renderer. +// +// WHY a custom scheme at all, rather than importing the file path directly: +// the renderer's CSP is `script-src 'self'` and its document origin differs +// between dev (http://localhost via ELECTRON_RENDERER_URL) and production +// (file:// via loadFile). A raw filesystem path is blocked in dev by the +// localhost origin and is a cross-origin file:// fetch in production — it +// cannot be made to work identically in both, which is exactly the trap this +// design exists to avoid. A registered scheme has ONE spelling that resolves +// the same way in both modes, and adding it to script-src is a single, narrow, +// auditable concession rather than relaxing the policy for everything. + +/** + * MUST be called at module scope, before `app.whenReady()`. + * + * Electron silently treats a scheme registered after ready as opaque — no + * origin semantics, no secure context, no CORS handling. The resulting failure + * surfaces in the renderer as a CSP or CORS error, which sends you looking at + * index.html instead of at call ordering. There is no runtime warning for it, + * so the ordering constraint lives here in a comment and in the call site. + * + * The privileges are each load-bearing: + * + * standard Gives the scheme real origin semantics, so a relative + * specifier inside a bundle (`./util.js` beside `index.js`) + * resolves. Without it every intra-extension import would + * have to be an absolute agent-code-ext:// URL, which no + * normal bundler emits. + * secure Module scripts and most web APIs require a secure context. + * Omitting this makes `import()` fail with an error that + * does not mention security. + * supportFetchAPI Lets an extension fetch its own assets — JSON config, CSS, + * an SVG sprite — through the same origin it was loaded from. + * corsEnabled Module scripts are ALWAYS fetched in CORS mode, and + * `standard: true` makes this a distinct origin from the + * document. Without CORS handling every import fails. + * stream Lets the handler return a streaming body instead of + * buffering each asset in main. + */ +export function registerExtensionScheme(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: EXTENSION_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + }, + }, + ]) +} + +// Deliberately small and explicit rather than a mime lookup dependency. An +// extension bundle is JS plus a handful of asset kinds; anything unrecognised +// is served as a byte stream rather than guessed at, because a wrong +// `text/javascript` on a non-module would be worse than an honest download. +const CONTENT_TYPES: Record = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.json': 'application/json', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.map': 'application/json', +} + +function contentTypeFor(path: string): string { + return CONTENT_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream' +} + +/** + * Call after `app.whenReady()`, before the first window is created. + * + * URL shape: `agent-code-ext:///` + */ +/** + * CORS for a bundle asset: the extension's own origin, or nothing. + * + * Returning an empty object (rather than a wildcard) is deliberate — absence of the + * header is a closed door, and it keeps cross-extension reads from being a one-word + * regression away. + */ +function corsHeadersFor(origin: string | null, extensionId: string): Record { + return origin && origin === `${EXTENSION_SCHEME}://${extensionId}` + ? { 'access-control-allow-origin': origin } + : {} +} + +export function handleExtensionScheme(target: Protocol = protocol): void { + // A managed background runtime has its own browser session. Scheme handlers + // belong to sessions, not to the application as a whole; omitting this adapter + // would make modules work in views but fail in the isolated runtime partition. + target.handle(EXTENSION_SCHEME, async request => { + let url: URL + try { + url = new URL(request.url) + } catch { + return new Response('bad request', { status: 400 }) + } + + // ── VALIDATE THE ID BEFORE IT IS USED AS A PATH SEGMENT ── + // `url.hostname` is attacker-chosen. It is joined onto EXTENSIONS_DIR below, and + // the containment check further down resolves against a root DERIVED FROM THIS + // VALUE — so if the id escapes, the check certifies the wrong root and approves + // everything beneath it. A request for `agent-code-ext://../extension-grants.json` + // yielded hostname `..`, rooting the handler at the whole state directory: grants, + // the ledger, workspace.json, and the proxy dumps that contain provider + // Authorization headers. Reachable from a Tier-0 extension that triggered no + // consent dialog, since `connect-src agent-code-ext:` permits the request. + // + // This must be the FIRST thing the handler does. The shared validator is imported + // rather than re-written: this file having no copy of the pattern, while four other + // files had one, is precisely how the gap happened. + const extensionId = url.hostname + if (!isValidExtensionId(extensionId)) return new Response('not found', { status: 404 }) + + // decodeURIComponent BEFORE the containment check, never after. A check + // performed on the encoded form would pass `..%2f..%2f.ssh` — the segments + // only look like traversal once decoded, and decoding afterwards would + // reintroduce exactly what the check was meant to stop. + let relative: string + try { + relative = decodeURIComponent(url.pathname).replace(/^\/+/, '') + } catch { + return new Response('bad request', { status: 400 }) + } + + // Every request needs an installed owner, including assets. A directory + // left by a failed install or an uninstall is not authority to serve code. + const record = await withLedgerLock(async () => + (await readLedger()).find(row => row.manifest.id === extensionId)) + if (!record) return new Response('not found', { status: 404 }) + const assetPrefix = `__bundle/${extensionRevision(record)}/` + // Put the document itself under the generation prefix too. Relative assets + // in CSS or fetch('./asset.json') then resolve in the same bundle as import + // specifiers, without a tag that would weaken the child's CSP. + if (!relative.startsWith(assetPrefix)) return new Response('not found', { status: 404 }) + relative = relative.slice(assetPrefix.length) + + if (relative === RUNTIME_DOCUMENT) { + const nonce = randomBytes(16).toString('base64') + return new Response(buildRuntimeDocument(record.manifest, nonce), { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'content-security-policy': childFrameCsp(nonce, extensionId), + 'cache-control': 'no-cache', + }, + }) + } + + // The sandbox frame document. Served BEFORE any file resolution, because it + // is not a bundle file — it is the host's own locked-down HTML shell that + // frames the extension. It embeds the extension's validated `entry` and a + // per-load nonce, and carries a far stricter CSP than the host document. + if (relative === RESERVED_FRAME_PATH) { + const viewId = url.searchParams.get('view') + if (!viewId) return new Response('bad request', { status: 400 }) + + if (url.searchParams.get('rev') !== extensionRevision(record)) { + return new Response('extension was updated; reload the view', { status: 409 }) + } + + // The view id must be one this extension actually declares. It was previously + // taken from the query string verbatim and interpolated into the frame document, + // where it is an injection sink — and a persisted SessionMeta.extensionViewId is + // attacker-influenced on the rehydrate path. Checking it against the manifest we + // already hold costs one `.some()` and removes the sink's most reachable source. + const declaresView = (record.manifest.contributes?.views ?? []).find(v => v.id === viewId) + if (!declaresView) return new Response('bad request', { status: 400 }) + + const nonce = randomBytes(16).toString('base64') + const html = buildFrameDocument({ + extensionId, + viewId, + entry: record.manifest.apiVersion === 2 ? declaresView.entry! : record.manifest.entry, + viewModule: record.manifest.apiVersion === 2, + // From the manifest this handler already validated, so the frame can + // enforce the declared-contributions contract without asking anyone. + declaredCommands: (record.manifest.contributes?.commands ?? []).map(c => c.id), + declaredViews: (record.manifest.contributes?.views ?? []).map(v => v.id), + nonce, + }) + return new Response(html, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + // Both the meta tag inside the document AND this header carry the CSP, + // with the same nonce. The header cannot be undone by a document.write, + // so it is the authoritative copy. + 'content-security-policy': childFrameCsp(nonce, extensionId), + 'cache-control': 'no-cache', + }, + }) + } + + // The id in the URL is a NAME, not authority — the same invariant + // EditorFsRootRegistry enforces for filesystem roots. It names a candidate + // directory; whether that directory is legitimate is decided by resolving + // it against the install root and proving the result stayed inside. + // Imports and lazy chunks carry their immutable generation in the URL. + // Without it an old frame could import a new chunk after update, mixing two + // releases in one runtime while still holding the old approval snapshot. + const root = extensionBundleDirectory(record) + + let rootReal: string + try { + rootReal = await realpath(root) + } catch { + // Not installed, or the bundle directory was removed by hand. 404 rather + // than 403: nothing was forbidden, there is simply nothing there. + return new Response('not found', { status: 404 }) + } + + let targetReal: string + try { + targetReal = await realpath(resolvePath(rootReal, relative)) + } catch { + return new Response('not found', { status: 404 }) + } + + // The containment check. This is the single most important line in the file. + // + // Install-time validation already proves the MANIFEST's entry stays inside + // the bundle, but this handler serves arbitrary paths on demand, so it needs + // its own check — and it must be realpath-based, because a symlink committed + // to the repository survives extraction and points wherever it likes. + // Without this, `agent-code-ext://x/../../../.ssh/id_rsa` reads an arbitrary + // file through an origin the renderer is permitted to load SCRIPTS from, + // which is the worst available combination in this app. + // + // The `+ sep` matters: a bare startsWith(rootReal) would accept a sibling + // directory whose name merely shares the prefix (`extensions/timer-evil` + // passes a startsWith check against `extensions/timer`). + if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) { + return new Response('forbidden', { status: 403 }) + } + + let fileResponse: Response + try { + fileResponse = await net.fetch(pathToFileURL(targetReal).toString()) + } catch { + return new Response('not found', { status: 404 }) + } + if (!fileResponse.ok || !fileResponse.body) { + return new Response('not found', { status: 404 }) + } + + return new Response(fileResponse.body, { + status: 200, + headers: { + 'content-type': contentTypeFor(targetReal), + // Required, and the single most likely thing to cost a day if omitted. + // Module scripts are fetched in CORS mode, and `standard: true` makes + // this a distinct origin from the document — so without an explicit + // allow-origin header every `import()` fails with an opaque CORS error + // that says nothing about the actual cause. + // + // ── NOT `*` ── + // The old value was justified as "there is no ambient authority to leak: this + // origin serves extension bundle files and nothing else". That is true of the + // HOST, and false of a sibling EXTENSION: with `*`, extension A could + // `fetch('agent-code-ext://b/index.js')` and read extension B's entire bundle. + // Combined with the scheme-wide CSP source it also let A execute B's code. + // + // Echo the request Origin only when it is this extension's own origin. Any other + // origin gets no CORS header at all, so the read fails closed. The host document + // itself never fetches bundle assets — it only frames them — so it needs nothing + // here. + ...corsHeadersFor(request.headers.get('origin'), extensionId), + // Bundles are replaced wholesale on update, and a stale cached module + // after an update is a confusing, hard-to-diagnose bug class. Extensions + // are local files; there is nothing to gain by caching them. + 'cache-control': 'no-cache', + }, + }) + }) +} diff --git a/src/main/extensions/storage.system.test.ts b/src/main/extensions/storage.system.test.ts new file mode 100644 index 000000000..2eb0a7411 --- /dev/null +++ b/src/main/extensions/storage.system.test.ts @@ -0,0 +1,132 @@ +import { mkdtemp, mkdir, open, readFile, readdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const stateRoot = await mkdtemp(join(tmpdir(), 'agent-code-extension-storage-')) +vi.mock('@main/storage/paths.js', () => ({ EXTENSION_STATE_DIR: stateRoot })) +const fault = vi.hoisted(() => ({ rename: false })) +vi.mock('fs/promises', async importOriginal => { + const fs = await importOriginal() + return { ...fs, rename: async (...args: Parameters) => { + if (fault.rename) { fault.rename = false; throw new Error('fixture publication failed') } + return fs.rename(...args) + } } +}) +const { extensionStorageGet: get, extensionStorageSet: set, extensionStorageDelete: remove, extensionStorageKeys: keys } = await import('./storage.js') +const stateFile = () => join(stateRoot, 'counter', 'state.json') +beforeEach(async () => { fault.rename = false; await rm(stateRoot, { recursive: true, force: true }); await mkdir(join(stateRoot, 'counter'), { recursive: true }) }) +afterAll(async () => { await rm(stateRoot, { recursive: true, force: true }) }) + +describe('durable extension storage', () => { + it('orders concurrent changes and reads without losing another key or namespace', async () => { + await Promise.all([set('counter', 'a', 1), set('counter', 'b', 2), set('other', 'a', 9)]) + const writing = set('counter', 'a', 3) + const [observed] = await Promise.all([get('counter', 'a'), writing]) + expect(observed).toBe(3) + await remove('counter', 'b') + expect(await keys('counter')).toEqual(['a']) + expect(await get('other', 'a')).toBe(9) + }) + + it('captures the caller value before a queued write can observe later mutation', async () => { + const value = { nested: [1] } + const writing = set('counter', 'value', value) + value.nested.push(2) + await writing + expect(await get('counter', 'value')).toEqual({ nested: [1] }) + }) + + it.each(['{broken', '[]', 'null', '{"value":1e999}'])('preserves malformed saved data instead of replacing it: %s', async raw => { + await writeFile(stateFile(), raw) + await expect(get('counter', 'value')).rejects.toThrow(/storage/i) + await expect(set('counter', 'new', 1)).rejects.toThrow(/storage/i) + await expect(remove('counter', 'old')).rejects.toThrow(/storage/i) + expect(await readFile(stateFile(), 'utf8')).toBe(raw) + }) + + it('treats only an absent file as empty; inaccessible/non-file state is an error', async () => { + expect(await get('counter', 'value')).toBeUndefined() + await mkdir(stateFile()) + await expect(set('counter', 'value', 1)).rejects.toThrow() + expect(await readdir(stateFile())).toEqual([]) + }) + + it('preserves invalid UTF-8 instead of silently persisting replacement characters', async () => { + const damaged = Buffer.concat([Buffer.from('{"value":"'), Buffer.from([0xff]), Buffer.from('"}')]) + await writeFile(stateFile(), damaged) + await expect(set('counter', 'next', 1)).rejects.toThrow(/storage.*UTF-8/) + expect(await readFile(stateFile())).toEqual(damaged) + }) + + it('rejects an oversized sparse state file without replacing it', async () => { + const file = await open(stateFile(), 'w') + try { await file.truncate(2 * 1024 * 1024) } finally { await file.close() } + await expect(get('counter', 'value')).rejects.toThrow(/storage.*limit/i) + await expect(set('counter', 'value', 1)).rejects.toThrow(/storage.*limit/i) + const unchanged = await open(stateFile(), 'r') + try { expect((await unchanged.stat()).size).toBe(2 * 1024 * 1024) } finally { await unchanged.close() } + }) + + it('refuses values outside bounded JSON and unsafe keys without changing saved bytes', async () => { + await set('counter', 'saved', 1) + const before = await readFile(stateFile(), 'utf8') + const cyclic: Record = {}; cyclic.self = cyclic + for (const value of [NaN, Infinity, undefined, () => {}, BigInt(1), new Date(), cyclic, 'x'.repeat(128 * 1024 + 1)]) { + await expect(set('counter', 'value', value)).rejects.toThrow(/JSON/) + } + for (const key of ['', '__proto__', 'constructor', 'prototype', 'x'.repeat(257)]) { + await expect(set('counter', key, 1)).rejects.toThrow(/key/) + await expect(get('counter', key)).rejects.toThrow(/key/) + await expect(remove('counter', key)).rejects.toThrow(/key/) + } + expect(await readFile(stateFile(), 'utf8')).toBe(before) + }) + + it('rejects total storage and key-count overflow while retaining the previous snapshot', async () => { + // Construct realistic saved snapshots directly: writing hundreds of keys one + // by one measures filesystem throughput rather than the admission boundary. + const full = Object.fromEntries(Array.from({ length: 256 }, (_, i) => [`key${i}`, i])) + await writeFile(stateFile(), JSON.stringify(full)) + await expect(set('counter', 'overflow', 1)).rejects.toThrow(/storage.*limit/i) + expect(await keys('counter')).toHaveLength(256) + await set('counter', 'key0', 99) + await remove('counter', 'key1') + await set('counter', 'replacement', 1) + const large = Object.fromEntries(Array.from({ length: 7 }, (_, i) => [`key${i}`, 'x'.repeat(128 * 1024)])) + await writeFile(stateFile(), JSON.stringify(large)) + const before = await readFile(stateFile(), 'utf8') + await expect(set('counter', 'overflow', 'x'.repeat(128 * 1024))).rejects.toThrow(/storage.*limit/i) + expect(await readFile(stateFile(), 'utf8')).toBe(before) + }) + + it('cleans failed publication residue, retains old data, and accepts the next operation', async () => { + await set('counter', 'saved', 1) + fault.rename = true + await expect(set('counter', 'saved', 2)).rejects.toThrow('fixture publication failed') + expect(await readdir(join(stateRoot, 'counter'))).toEqual(['state.json']) + expect(await get('counter', 'saved')).toBe(1) + await set('counter', 'next', 3) + expect(await get('counter', 'next')).toBe(3) + }) + + it('bounds queued work and accepts new calls after a burst drains', async () => { + const calls = await Promise.allSettled(Array.from({ length: 40 }, (_, i) => set('counter', `key${i}`, i))) + expect(calls.filter(result => result.status === 'fulfilled')).toHaveLength(32) + expect(calls.filter(result => result.status === 'rejected')).toHaveLength(8) + await set('counter', 'after', 99) + expect(await get('counter', 'after')).toBe(99) + }) + + it('bounds cross-namespace admission and releases it after successful and failed reads', async () => { + const calls = await Promise.allSettled(Array.from({ length: 288 }, (_, i) => get(`app-${Math.floor(i / 32)}`, 'value'))) + expect(calls.filter(result => result.status === 'fulfilled')).toHaveLength(256) + expect(calls.filter(result => result.status === 'rejected')).toHaveLength(32) + await writeFile(stateFile(), '{broken') + await expect(get('counter', 'value')).rejects.toThrow(/storage/i) + await rm(stateFile()) + await expect(get('counter', 'toString')).resolves.toBeUndefined() + await set('counter', 'toString', 'own value') + expect(await get('counter', 'toString')).toBe('own value') + }) +}) diff --git a/src/main/extensions/storage.ts b/src/main/extensions/storage.ts new file mode 100644 index 000000000..c3d5633d8 --- /dev/null +++ b/src/main/extensions/storage.ts @@ -0,0 +1,191 @@ +import { mkdir, open, rename, rm, writeFile } from 'fs/promises' +import { join } from 'path' + +import { EXTENSION_STATE_DIR } from '@main/storage/paths.js' + +import { isExtensionJson, type ExtensionJson } from '@shared/types/extensionJson.js' + +// Durable state is independent of disposable bundles. Uninstall deliberately +// retains it: reinstalling an extension must not erase a user's work. All callers +// (views, runtimes and Settings) meet the same validation and admission boundary. +const MAX_STATE_BYTES = 1024 * 1024 +const MAX_STATE_KEYS = 256 +const MAX_PENDING_PER_APP = 32 +const MAX_PENDING_TOTAL = 256 + +// WHY ids are validated and rejected rather than sanitized: an app id becomes a +// directory name, so a permissive id is a path-traversal primitive. Sanitizing — +// stripping or replacing bad characters — silently collapses distinct ids onto the +// same directory: `../timer` and `timer` would share state, and `a/b` and `a-b` +// would too. Rejecting is the only handling where the failure is visible to whoever +// caused it. The pattern is duplicated in the renderer's AppDefinition doc comment; +// if it changes, change both. +const APP_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/ + +export class InvalidAppIdError extends Error { + constructor(appId: unknown) { + super( + `invalid extension app id ${JSON.stringify(appId)} — must match ${String(APP_ID_PATTERN)}`, + ) + this.name = 'InvalidAppIdError' + } +} + +function appDirFor(appId: string): string { + if (typeof appId !== 'string' || !APP_ID_PATTERN.test(appId)) throw new InvalidAppIdError(appId) + return join(EXTENSION_STATE_DIR, appId) +} + +function stateFileFor(appId: string): string { + return join(appDirFor(appId), 'state.json') +} + +function validKey(key: unknown): key is string { + return typeof key === 'string' && key.length > 0 && key.length <= 256 && + !['__proto__', 'constructor', 'prototype'].includes(key) +} + +function assertKey(key: unknown): asserts key is string { + if (!validKey(key)) throw new Error('Invalid extension storage key.') +} + +function stateLimit(): Error { + return new Error('Extension storage limit exceeded (1 MiB, 256 keys). Existing data is unchanged.') +} + +async function readAll(appId: string): Promise> { + let file: Awaited> + try { file = await open(stateFileFor(appId), 'r') } + catch (error) { + // Only absence is an empty namespace. Treating a permission failure or corrupt + // file as {} let the next innocent set() erase every saved key. Surface a + // recoverable error and preserve the bytes for repair, including on delete(). + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw new Error('Extension storage could not be read; saved data was preserved.', { cause: error }) + } + let raw: string + try { + const stat = await file.stat() + if (!stat.isFile()) throw new Error('Extension storage is not a regular file.') + if (stat.size > MAX_STATE_BYTES) throw stateLimit() + // A pre-read stat alone does not bound allocation if a file grows afterward. + // Read bounded chunks through one open handle and inspect one overflow byte; + // never readFile() an untrusted or accidentally enormous saved-state file. + const chunks: Buffer[] = [] + let bytes = 0 + const buffer = Buffer.allocUnsafe(64 * 1024) + for (;;) { + const result = await file.read(buffer, 0, Math.min(buffer.length, MAX_STATE_BYTES + 1 - bytes), null) + if (result.bytesRead === 0) break + bytes += result.bytesRead + if (bytes > MAX_STATE_BYTES) throw stateLimit() + chunks.push(Buffer.from(buffer.subarray(0, result.bytesRead))) + } + try { raw = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, bytes)) } + catch (error) { + // Buffer.toString replaces damaged UTF-8 silently. A later write would + // persist that replacement, destroying the original bytes needed to repair. + throw new Error('Extension storage contains invalid UTF-8; saved data was preserved.', { cause: error }) + } + } finally { await file.close() } + let parsed: unknown + try { parsed = JSON.parse(raw) } + catch (error) { throw new Error('Extension storage contains invalid JSON; saved data was preserved.', { cause: error }) } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Extension storage must contain an object; saved data was preserved.') + } + const entries = Object.entries(parsed) + if (entries.length > MAX_STATE_KEYS) throw stateLimit() + if (entries.some(([key, value]) => !validKey(key) || !isExtensionJson(value))) { + throw new Error('Extension storage contains invalid keys or bounded JSON values; saved data was preserved.') + } + return parsed as Record +} + +async function writeAll(appId: string, data: Record): Promise { + if (Object.keys(data).length > MAX_STATE_KEYS) throw stateLimit() + const encoded = `${JSON.stringify(data, null, 2)}\n` + if (Buffer.byteLength(encoded, 'utf8') > MAX_STATE_BYTES) throw stateLimit() + const dir = appDirFor(appId) + await mkdir(dir, { recursive: true }) + const file = join(dir, 'state.json') + // Same-directory rename publishes either the previous complete snapshot or the + // new one. A failed write/rename must remove only its own staging file; leaving + // one per failed request lets a full disk failure consume still more space. + const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${writeCounter++}` + try { + await writeFile(tmp, encoded, { encoding: 'utf8', mode: 0o600, flag: 'wx' }) + await rename(tmp, file) + } finally { await rm(tmp, { force: true }) } +} + +let writeCounter = 0 +type Queue = { tail: Promise; pending: number } +const queues = new Map() +let pendingTotal = 0 + +function enqueue(appId: string, operation: () => Promise): Promise { + appDirFor(appId) + const queue = queues.get(appId) ?? { tail: Promise.resolve(), pending: 0 } + if (queue.pending >= MAX_PENDING_PER_APP || pendingTotal >= MAX_PENDING_TOTAL) { + return Promise.reject(new Error('Extension storage has too many pending operations.')) + } + queues.set(appId, queue) + queue.pending++ + pendingTotal++ + // Reads share the queue so a get issued after set observes that write even when + // the caller did not await set first. Bounded admission protects legacy views as + // well as managed runtimes, and completed namespaces do not stay in a lifetime + // map merely because that extension wrote a value once before being removed. + const next = queue.tail.then(operation) + const finish = () => { + queue.pending-- + pendingTotal-- + if (queue.pending === 0 && queues.get(appId) === queue) queues.delete(appId) + } + // Both outcomes resolve the private tail; one failed operation must not poison + // later calls. The original promise retains its error for its own caller. + queue.tail = next.then(finish, finish) + return next +} + +export async function extensionStorageGet(appId: string, key: string): Promise { + assertKey(key) + return enqueue(appId, async () => { + const data = await readAll(appId) + return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : undefined + }) +} + +export class NonSerializableValueError extends Error { + constructor(detail: string) { + super(`extension storage value is not bounded JSON: ${detail}`) + this.name = 'NonSerializableValueError' + } +} + +export async function extensionStorageSet(appId: string, key: string, value: unknown): Promise { + assertKey(key) + if (!isExtensionJson(value)) throw new NonSerializableValueError('requires finite JSON within transport size/depth limits') + // Capture before joining the async queue. Keeping a mutable caller reference + // would let a later change bypass validation or silently change the saved value. + const snapshot = JSON.parse(JSON.stringify(value)) as ExtensionJson + return enqueue(appId, async () => { + const data = await readAll(appId) + data[key] = snapshot + await writeAll(appId, data) + }) +} + +export async function extensionStorageDelete(appId: string, key: string): Promise { + assertKey(key) + return enqueue(appId, async () => { + const data = await readAll(appId) + delete data[key] + await writeAll(appId, data) + }) +} + +export async function extensionStorageKeys(appId: string): Promise { + return enqueue(appId, async () => Object.keys(await readAll(appId))) +} diff --git a/src/main/extensions/testing/electronHarness.ts b/src/main/extensions/testing/electronHarness.ts new file mode 100644 index 000000000..52373dc9f --- /dev/null +++ b/src/main/extensions/testing/electronHarness.ts @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { app, BrowserWindow, webContents } from 'electron' +import { registerExtensionScheme, handleExtensionScheme } from '@main/extensions/scheme.js' +import { installExtensionFromPath } from '@main/extensions/install.js' +import { registerExtensionsIpc } from '@main/ipc/extensions.js' +import { extensionBundleDirectory, removeExtension } from '@main/extensions/ledger.js' +import { extensionRevision, type InstalledExtension } from '@shared/types/extensions.js' +import { ExtensionRuntimeService } from '../runtimeService.js' +import { registerExtensionRuntimeIpc } from '../runtimeIpc.js' +import { registerExtensionInputIpc } from '../nativeInput.js' +import { ExtensionCapabilityService } from '../capabilityService.js' + +const root = process.env.AGENT_CODE_EXTENSION_TEST_ROOT +if (!root) throw new Error('An isolated extension test root is required') +// Electron otherwise opens a native exception dialog, which stalls an invisible +// test process and hides the actual stack until the outer watchdog kills it. +process.on('uncaughtException', error => { console.error(error); app.exit(1) }) +process.on('unhandledRejection', error => { console.error(error); app.exit(1) }) +registerExtensionScheme() +app.setPath('userData', join(root, 'electron-data')) +// Keep the fixture alive while the final hidden runtime acknowledges shutdown. +// Otherwise Electron may exit zero between destroy() and our completion record. +app.on('window-all-closed', () => {}) + +type Snapshot = { text: string; src: string | null; failures: Array<{ error: string }>; messages: Array<{ kind: string; theme?: string; preload?: string; parentAccessible?: boolean; instanceId?: string; count?: number; command?: string; selected?: string; fileText?: string }> } + +async function fixture(id: string, activation: string, mount = '', moduleKind: 'named' | 'default' | 'missing' = 'named'): Promise { + const folder = join(root!, 'sources', id) + await mkdir(folder, { recursive: true }) + await writeFile(join(folder, 'agent-code.extension.json'), JSON.stringify({ + id, name: id, description: 'Electron lifecycle fixture', version: '1', apiVersion: 1, + entry: 'index.js', contributes: { views: [{ id: `${id}.main`, title: id, mount: 'panel' }] }, + })) + const exports = moduleKind === 'named' ? 'export { activate };' : moduleKind === 'default' ? 'export default { activate };' : 'export const fixture = true;' + await writeFile(join(folder, 'index.js'), `async function activate(context) { + ${activation} + context.registerView('${id}.main', root => { + ${mount} + root.textContent = 'Fixture view'; + let parentAccessible = false; + try { parentAccessible = !!window.parent.api; } catch {} + window.parent.postMessage({kind:'fixture:mount', theme:getComputedStyle(document.documentElement).getPropertyValue('--theme-canvas'), preload:typeof window.api, parentAccessible}, '*'); + return () => window.parent.postMessage({kind:'fixture:cleanup'}, '*'); + }); + }\n${exports}`) + return installExtensionFromPath(folder) +} + +async function waitFor(win: BrowserWindow, predicate: (state: Snapshot) => boolean, label: string): Promise { + const deadline = performance.now() + 15_000 + let state: Snapshot | undefined + while (performance.now() < deadline) { + state = await win.webContents.executeJavaScript('window.fixtureHarness.snapshot()') as Snapshot + if (predicate(state)) return state + await new Promise(resolve => setTimeout(resolve, 25)) + } + throw new Error(`${label} timed out: ${JSON.stringify(state)}`) +} + +async function waitForDocumentTeardown(win: BrowserWindow): Promise { + // Checking the element's src only proves React requested navigation. Ask + // Chromium which documents still exist so a CSP-blocked about:blank teardown + // cannot pass while failed extension code continues running underneath. + const deadline = performance.now() + 2_000 + while (performance.now() < deadline) { + if (win.webContents.mainFrame.frames.every(frame => !frame.url.startsWith('agent-code-ext:'))) return + await new Promise(resolve => setTimeout(resolve, 25)) + } + throw new Error(`Failed extension document is still alive: ${win.webContents.mainFrame.frames.map(frame => frame.url).join(', ')}`) +} + +void (async () => { + await app.whenReady() + handleExtensionScheme() + registerExtensionsIpc() + const trusted = new Set() + const runtimeStarts: string[] = [] + const projectRoot = join(root!, 'extension-project') + await mkdir(projectRoot, { recursive: true }) + await writeFile(join(projectRoot, 'extension-readable.txt'), 'SDK service boundary\n') + const capabilities = new ExtensionCapabilityService({ + resolveSessionRoot: sessionId => sessionId === 'fixture-session' ? projectRoot : null, + }) + const service = new ExtensionRuntimeService({ preload: join(root!, 'runtime-preload.cjs'), capabilities, onStatus: status => { + if (status.state === 'starting') runtimeStarts.push(status.extensionId) + } }) + const inputBindings = new Map() + const nativeInputs: Array<{ owner: number; key: string; prevented: boolean }> = [] + const observedInputOwners = new Set() + const unregisterInput = registerExtensionInputIpc(contents => trusted.has(contents.id), (owner, bindings) => { + inputBindings.set(owner, bindings) + if (!observedInputOwners.has(owner)) { + observedInputOwners.add(owner) + // Observe after the production listener to distinguish forwarded chords + // from native editing which the host must leave in the child document. + webContents.fromId(owner)!.on('before-input-event', (event, input) => nativeInputs.push({ owner, key: input.key, prevented: event.defaultPrevented })) + } + }) + const unregisterRuntime = registerExtensionRuntimeIpc(service, capabilities, contents => trusted.has(contents.id)) + await fixture('fast', '') + await fixture('slow', 'await new Promise(resolve => setTimeout(resolve, 250));') + await fixture('default-module', '', '', 'default') + await fixture('missing-activate', '', '', 'missing') + await fixture('broken', '', 'throw new Error("fixture mount failed");') + await fixture('late-broken', 'await new Promise(resolve => setTimeout(resolve, 250));', 'throw new Error("late fixture mount failed");') + await fixture('activation-error', 'throw new Error("fixture activation failed");') + await fixture('missing-view', 'return;') + await fixture('undeclared', 'context.registerView("undeclared.other", () => {});') + await fixture('syntax-error', 'if (') + await fixture('denied', 'await context.api.sessions.observe();') + await fixture('storage', `await context.api.storage.set('value', { nested: [1, true, null] }); + const value = await context.api.storage.get('value'); + if (JSON.stringify(value) !== '{"nested":[1,true,null]}') throw new Error('storage round trip failed'); + if (!(await context.api.storage.keys()).includes('value')) throw new Error('storage key missing'); + await context.api.storage.delete('value'); + if (await context.api.storage.get('value') !== undefined) throw new Error('storage delete failed');`) + // The bootstrap is synthesized from the ledger; missing bytes fail its real + // dynamic import. An unknown contributed view below instead gets an error + // document with no bootstrap at all. Keep both failure paths executable. + const missing = await fixture('missing-bundle', '') + await rm(extensionBundleDirectory(missing), { recursive: true }) + await fixture('no-boot', '') + await fixture('retry', `const attempts = (await context.api.storage.get('attempts') || 0) + 1; + await context.api.storage.set('attempts', attempts); + if (attempts === 1) await new Promise(() => {});`) + const win = new BrowserWindow({ + show: false, width: 800, height: 600, + webPreferences: { preload: join(root!, 'preload.cjs'), sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }, + }) + trusted.add(win.webContents.id) + try { + await win.loadFile(join(root!, 'host.html')) + for (const [id, delay] of [['fast', 250], ['slow', 0], ['storage', 0], ['default-module', 0]] as const) { + await win.webContents.executeJavaScript(`window.fixtureHarness.render(${JSON.stringify(id)}, ${delay})`) + const state = await waitFor(win, state => state.messages.some(message => message.kind === 'fixture:mount'), `${id} mount`) + const mounts = state.messages.filter(message => message.kind === 'fixture:mount') + assert.equal(mounts.length, 1, 'mount must happen once') + assert.equal(mounts[0]!.theme, 'rgb(1, 2, 3)', `${id}: theme must exist at first mount`) + assert.equal(mounts[0]!.preload, 'undefined', 'extension must not receive host preload') + assert.equal(mounts[0]!.parentAccessible, false, 'extension must not reach parent API') + console.log(`PASS ${id}: one themed mount, sandbox and host preload isolation`) + } + for (const [id, error, viewId] of [ + ['broken', 'fixture mount failed'], ['late-broken', 'late fixture mount failed'], + ['activation-error', 'fixture activation failed'], ['missing-view', 'was not registered'], + ['missing-activate', 'must export activate(context)'], + ['undeclared', 'not declared in contributes.views'], ['syntax-error', 'Unexpected'], + ['denied', 'is not granted'], ['missing-bundle', 'Failed to fetch dynamically imported module'], + ['no-boot', 'bundle may be missing or blocked', 'no-boot.absent'], + ]) { + await win.webContents.executeJavaScript(`window.fixtureHarness.render(${JSON.stringify(id)}, 0, false, ${JSON.stringify(viewId ?? `${id}.main`)})`) + const state = await waitFor(win, state => state.text.includes(error!) && state.text.includes('Retry'), `${id} actionable failure`) + assert.equal(state.src, 'about:blank', 'failed document must be torn down') + await waitForDocumentTeardown(win) + assert.ok(state.failures.some(failure => failure.error.includes(error!)), 'settings must retain the cause') + assert.equal(state.messages.filter(message => message.kind === 'fixture:mount').length, 0) + console.log(`PASS ${id}: actionable failure, Settings cause and document teardown`) + } + await win.webContents.executeJavaScript('window.fixtureHarness.render("fast", 0, true)') + await waitFor(win, state => state.text.includes('fixture theme unavailable') && state.src === 'about:blank', 'theme failure') + await waitForDocumentTeardown(win) + console.log('PASS theme failure: surfaced before mount') + await win.webContents.executeJavaScript('window.fixtureHarness.render("retry", 0)') + await waitFor(win, state => state.text.includes('failed to start') && state.text.includes('Retry'), 'activation deadline') + await waitForDocumentTeardown(win) + await win.webContents.executeJavaScript('window.fixtureHarness.retry()') + await waitFor(win, state => state.messages.some(message => message.kind === 'fixture:mount'), 'successful retry') + console.log('PASS activation deadline: fresh retry succeeds with durable storage from first attempt') + // Built by the SDK's typed author fixture and real multi-entry Vite preset. + // Handwritten JS here would miss drift in the published helpers and output. + const managed = await installExtensionFromPath(join(root!, 'author-bundle'), async () => true) + const revision = extensionRevision(managed) + await win.webContents.executeJavaScript('window.fixtureHarness.unmount()') + await win.webContents.executeJavaScript('window.fixtureHarness.showThemes()') + await waitFor(win, state => state.text.includes('Counter Night'), 'SDK contribution in the actual appearance picker') + await win.webContents.executeJavaScript('window.fixtureHarness.chooseTheme()') + assert.deepEqual(await win.webContents.executeJavaScript('window.fixtureHarness.themeSnapshot()'), { mode: 'extension-theme:managed.night', canvas: '#102030', appliedMode: 'custom' }) + assert.ok(!runtimeStarts.includes('managed'), 'selecting a declarative theme must not execute extension code') + const command = (name: string) => win.webContents.executeJavaScript(`window.api.extensionsRuntimeCommand('managed', ${JSON.stringify(revision)}, ${JSON.stringify(`managed.${name}`)})`) + await assert.rejects(command('snapshot'), /does not declare activation/) + assert.equal(await command('increment'), 1) + assert.deepEqual(await command('snapshot'), { count: 1, activations: 1 }) + assert.deepEqual({ ...(await command('read')), mtimeMs: 0 }, { + sessionId: 'fixture-session', path: 'extension-readable.txt', text: 'SDK service boundary\n', size: 21, mtimeMs: 0, + }) + assert.equal((await win.webContents.executeJavaScript('window.fixtureHarness.snapshot()')).src, null, 'cold commands must not open a view') + await win.webContents.executeJavaScript('window.fixtureHarness.render("managed", 0, false, "managed.main", false, true)') + const firstView = await waitFor(win, state => state.messages.some(message => message.kind === 'fixture:mount') && state.messages.some(message => message.kind === 'fixture:file'), 'first managed view and scoped file read') + assert.equal(firstView.messages.find(message => message.kind === 'fixture:file')?.fileText, 'SDK service boundary\n') + const other = new BrowserWindow({ show: false, webPreferences: { preload: join(root!, 'preload.cjs'), sandbox: true, contextIsolation: true, nodeIntegration: false } }) + trusted.add(other.webContents.id) + try { + await other.loadFile(join(root!, 'host.html')) + await other.webContents.executeJavaScript('window.fixtureHarness.render("managed", 0)') + const secondView = await waitFor(other, state => state.messages.some(message => message.kind === 'fixture:mount'), 'second managed view') + const firstMount = firstView.messages.find(message => message.kind === 'fixture:mount')! + const secondMount = secondView.messages.find(message => message.kind === 'fixture:mount')! + assert.notEqual(firstMount.instanceId, secondMount.instanceId) + assert.equal(firstMount.theme?.trim(), '#102030', 'the installed theme must reach the real child before mount') + assert.equal(secondMount.preload, 'undefined') + assert.equal(await command('increment'), 2) + for (const owner of [win, other]) await waitFor(owner, state => state.messages.some(message => message.kind === 'fixture:state' && message.count === 2), 'shared managed state') + const child = win.webContents.mainFrame.frames.find(frame => frame.url.startsWith('agent-code-ext://managed/'))! + const waitBindings = async (binding: string) => { + const deadline = performance.now() + 3000 + while (!inputBindings.get(win.webContents.id)?.includes(binding)) { + if (performance.now() > deadline) throw new Error('Application did not publish native binding ' + binding) + await new Promise(resolve => setTimeout(resolve, 20)) + } + } + await waitBindings('Cmd+Shift+P') + await child.executeJavaScript('document.body.tabIndex = 0; document.body.focus()') + await win.webContents.executeJavaScript('window.fixtureHarness.resetInput()') + // Forged DOM keys/postMessage must never become application authority. + await child.executeJavaScript(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'P', code: 'KeyP', metaKey: true, shiftKey: true, bubbles: true })); parent.postMessage({ kind: 'extensions:native-input', input: { key: 'W', metaKey: true } }, '*')`) + assert.equal((await win.webContents.executeJavaScript('window.fixtureHarness.snapshot()')).messages.filter((message: { kind: string }) => message.kind === 'fixture:command').length, 0) + const press = (keyCode: string, modifiers: NonNullable) => { + win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) + win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + } + press('P', ['meta', 'shift']) + await waitFor(win, state => state.messages.some(message => message.command === 'open-command-palette' && message.selected === 'extension'), 'native palette and pane focus') + await win.webContents.executeJavaScript('window.fixtureHarness.resetInput()') + press('W', ['meta']) + await waitFor(win, state => state.messages.some(message => message.command === 'close-pane' && message.selected === 'extension'), 'native close targets its extension pane') + await win.webContents.executeJavaScript('window.fixtureHarness.resetInput(); window.fixtureHarness.rebindPalette()') + await waitBindings('Ctrl+Shift+K') + press('K', ['control', 'shift']) + const customized = await waitFor(win, state => state.messages.some(message => message.command === 'open-command-palette'), 'customized native palette binding') + assert.equal(customized.messages.filter(message => message.kind === 'fixture:command').length, 1) + assert.ok(!inputBindings.get(win.webContents.id)?.includes('Cmd+Shift+P'), 'removed default chord must stop capture') + console.log('PASS native input: forged child keys denied, real palette/close commands select the right pane, customized bindings route once') + const oldDocument = child.url + await win.webContents.executeJavaScript('window.fixtureHarness.render("managed", 0, false, "managed.main", true)') + await waitFor(win, state => state.messages.some(message => message.kind === 'fixture:mount'), 'managed modal mount') + const modal = win.webContents.mainFrame.frames.find(frame => frame.url.startsWith('agent-code-ext://managed/'))! + await modal.executeJavaScript('document.body.tabIndex = 0; document.body.focus()') + await win.webContents.executeJavaScript('window.fixtureHarness.resetInput()') + win.webContents.send('extensions:native-input', { kind: 'key', url: oldDocument, type: 'keydown', input: { key: 'W', code: 'KeyW', metaKey: true } }) + press('D', ['alt']) + await new Promise(resolve => setTimeout(resolve, 40)) + const nativeEditing = nativeInputs.filter(input => input.owner === win.webContents.id && input.key.toLowerCase() === 'd') + assert.ok(nativeEditing.length > 0 && nativeEditing.every(input => !input.prevented), 'a modal must retain native Option editing') + const untouched = await win.webContents.executeJavaScript('window.fixtureHarness.snapshot()') + assert.equal(untouched.messages.filter((message: { kind: string }) => ['fixture:command', 'fixture:close'].includes(message.kind)).length, 0, 'stale input and modal editing cannot affect the hidden workspace') + press('K', ['control', 'shift']) + await waitFor(win, state => state.messages.some(message => message.command === 'open-command-palette'), 'palette access inside a real modal shell') + press('W', ['meta']) + await waitFor(win, state => state.src === null && state.messages.some(message => message.kind === 'fixture:close'), 'native close removes only the modal surface') + await waitForDocumentTeardown(win) + console.log('PASS native modal: stale document rejected, native editing preserved, palette accessible and close removes the owned surface') + const stranger = new BrowserWindow({ show: false, webPreferences: { preload: join(root!, 'preload.cjs'), sandbox: true, contextIsolation: true, nodeIntegration: false } }) + try { + await stranger.loadFile(join(root!, 'host.html')) + await assert.rejects(stranger.webContents.executeJavaScript('window.api.extensionsSetInputBindings({ pane: ["Cmd+W"], modal: [] })'), /application main frame/) + } finally { stranger.destroy() } + + await win.webContents.executeJavaScript('window.fixtureHarness.unmount()') + assert.deepEqual(await command('snapshot'), { count: 2, activations: 1 }) + await other.webContents.executeJavaScript('window.fixtureHarness.unmount()') + await command('arm') + // Coverage and busy runners can delay a renderer timer beyond 100 ms. + // Observe bounded progress rather than asserting one scheduler timeslice; + // only this read is repeated, never the command that starts background work. + const deadline = performance.now() + 3000 + let snapshot = await command('snapshot') + while (snapshot.count <= 2 && performance.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 25)) + snapshot = await command('snapshot') + } + assert.ok(snapshot.count > 2, 'background runtime must progress with every view closed') + assert.equal(snapshot.activations, 1) + console.log('PASS public v2: cold commands, independent view modules in two windows, shared state and zero-view background work') + + // Theme ownership follows the real installer/catalog, independently of + // any runtime. A failed update must leave the selected palette intact; + // uninstall/reinstall must retain the preference without saved-theme copies. + await win.webContents.executeJavaScript('window.fixtureHarness.showThemes()') + const sourceManifestPath = join(root!, 'author-bundle', 'agent-code.extension.json') + const sourceManifest = JSON.parse(await readFile(sourceManifestPath, 'utf8')) + sourceManifest.contributes.themes[0].colors.canvas = '#304050' + await writeFile(sourceManifestPath, JSON.stringify(sourceManifest)) + await installExtensionFromPath(join(root!, 'author-bundle'), async () => true) + await win.webContents.executeJavaScript('window.fixtureHarness.refreshThemes()') + assert.deepEqual(await win.webContents.executeJavaScript('window.fixtureHarness.themeSnapshot()'), { mode: 'extension-theme:managed.night', canvas: '#304050', appliedMode: 'custom' }) + sourceManifest.contributes.themes[0].colors.canvas = 'url(https://example.test/track)' + await writeFile(sourceManifestPath, JSON.stringify(sourceManifest)) + await assert.rejects(installExtensionFromPath(join(root!, 'author-bundle')), /colors.canvas/) + await win.webContents.executeJavaScript('window.fixtureHarness.refreshThemes()') + assert.equal((await win.webContents.executeJavaScript('window.fixtureHarness.themeSnapshot()')).canvas, '#304050') + await removeExtension('managed') + await win.webContents.executeJavaScript('window.fixtureHarness.refreshThemes()') + const absent = await win.webContents.executeJavaScript('window.fixtureHarness.themeSnapshot()') + assert.equal(absent.mode, 'extension-theme:managed.night') + assert.equal(absent.appliedMode, 'dark') + sourceManifest.contributes.themes[0].colors.canvas = '#304050' + await writeFile(sourceManifestPath, JSON.stringify(sourceManifest)) + const startsBeforeReinstall = runtimeStarts.length + await installExtensionFromPath(join(root!, 'author-bundle'), async () => true) + await win.webContents.executeJavaScript('window.fixtureHarness.refreshThemes()') + assert.equal((await win.webContents.executeJavaScript('window.fixtureHarness.themeSnapshot()')).canvas, '#304050') + assert.equal(runtimeStarts.length, startsBeforeReinstall) + await win.webContents.executeJavaScript('window.fixtureHarness.unmount()') + console.log('PASS contributed themes: SDK install/picker/live view, update, invalid update recovery and uninstall/reinstall without activation') + } finally { other.destroy() } + console.log('Extension Electron integration checks passed') + } finally { win.destroy(); unregisterInput(); unregisterRuntime(); await service.dispose(); capabilities.dispose() } + await writeFile(join(root!, 'completed'), 'ok') + app.quit() +})().catch(error => { console.error(error); app.exit(1) }) diff --git a/src/main/extensions/testing/electronPreload.ts b/src/main/extensions/testing/electronPreload.ts new file mode 100644 index 000000000..f71f26819 --- /dev/null +++ b/src/main/extensions/testing/electronPreload.ts @@ -0,0 +1,6 @@ +import { contextBridge } from 'electron' +import { extensionsApi } from '@preload/api/extensions.js' + +// Expose the real host bridge, so the sandbox test proves the extension cannot +// reach a preload that actually exists on its parent (not an empty dummy window). +contextBridge.exposeInMainWorld('api', extensionsApi) diff --git a/src/main/extensions/testing/runtimeHarness.ts b/src/main/extensions/testing/runtimeHarness.ts new file mode 100644 index 000000000..50b2e848d --- /dev/null +++ b/src/main/extensions/testing/runtimeHarness.ts @@ -0,0 +1,329 @@ +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { join } from 'node:path' +import { app, BrowserWindow } from 'electron' +import { installExtensionFromPath } from '../install.js' +import { removeExtension } from '../ledger.js' +import { registerExtensionScheme } from '../scheme.js' +import { extensionStorageGet } from '../storage.js' +import { ExtensionRuntimeService } from '../runtimeService.js' +import { ExtensionRuntimeViews } from '../runtimeViews.js' +import { ExtensionCapabilityService, MAX_EXTENSION_TEXT_FILE_BYTES } from '../capabilityService.js' +import { extensionRevision } from '@shared/types/extensions.js' +import type { ExtensionJson, RuntimeStatus, RuntimeViewEvent } from '@shared/types/extensionRuntime.js' + +const root = process.env.AGENT_CODE_EXTENSION_TEST_ROOT +if (!root) throw new Error('An isolated extension test root is required') +// Electron otherwise opens a native exception dialog, which stalls an invisible +// test process and hides the actual stack until the outer watchdog kills it. +process.on('uncaughtException', error => { console.error(error); app.exit(1) }) +process.on('unhandledRejection', error => { console.error(error); app.exit(1) }) +app.setPath('userData', join(root, 'electron-data')) +registerExtensionScheme() +// This fixture intentionally has zero application windows between generations. +// Keep its main alive so it can assert update/uninstall/restart behavior itself. +app.on('window-all-closed', () => {}) + +function object(value: ExtensionJson | undefined): Record { + assert.ok(value && typeof value === 'object' && !Array.isArray(value)) + return value +} + +async function until(predicate: () => Promise, label: string): Promise { + const deadline = performance.now() + 3000 + while (performance.now() < deadline) { + if (await predicate()) return + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`${label} timed out`) +} + +void (async () => { + await app.whenReady() + const hits: string[] = [] + const server = createServer((request, response) => { hits.push(request.url ?? ''); response.end('unexpected egress') }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + assert.ok(address && typeof address !== 'string') + const endpoint = `http://127.0.0.1:${address.port}/private-state` + const statuses: RuntimeStatus[] = [] + const lifecycle: string[] = [] + const projectRoot = join(root!, 'project') + await mkdir(join(projectRoot, 'nested'), { recursive: true }) + await writeFile(join(projectRoot, 'nested', 'note.txt'), 'permissioned project text\n') + await writeFile(join(projectRoot, 'binary.dat'), Buffer.from([0, 1, 2, 3])) + await writeFile(join(projectRoot, 'large.txt'), 'x'.repeat(MAX_EXTENSION_TEXT_FILE_BYTES + 1)) + app.on('browser-window-created', (_event, window) => { + window.webContents.on('console-message', details => { + if (details.message.startsWith('LIFECYCLE:')) lifecycle.push(details.message) + }) + }) + const capabilities = new ExtensionCapabilityService({ + resolveSessionRoot: sessionId => sessionId === 'fixture-session' ? projectRoot : null, + }) + const service = new ExtensionRuntimeService({ preload: process.env.AGENT_CODE_EXTENSION_RUNTIME_PRELOAD ?? join(root!, 'preload.cjs'), capabilities, startupTimeoutMs: 3000, invocationTimeoutMs: 1500, onStatus: status => statuses.push(status) }) + + const viewEvents: Array<{ owner: number; event: RuntimeViewEvent }> = [] + const views = new ExtensionRuntimeViews(service, (owner, event) => viewEvents.push({ owner, event })) + + async function install(generation: number, id = 'engine', activation?: string, shutdown = '', activationMode?: 'startup' | 'lazy', permissions: string[] = []) { + const source = join(root!, 'runtime-sources', id) + await mkdir(source, { recursive: true }) + // The private host transport is tested before publishing its v2 SDK adapter. + // Use the currently accepted manifest and existing command/storage contract; + // no test pretends legacy registerView closures can cross this boundary. + await writeFile(join(source, 'agent-code.extension.json'), JSON.stringify({ + id, name: id, description: 'Managed runtime integration fixture', version: String(generation), apiVersion: activationMode ? 2 : 1, entry: 'index.js', + ...(activationMode ? { activationEvents: activationMode === 'startup' ? ['onStartupFinished'] : [`onCommand:${id}.increment`, `onCommand:${id}.read`, `onView:${id}.main`] } : {}), + ...(permissions.length ? { permissions } : {}), + contributes: { commands: ['increment', 'snapshot', 'fail', 'hang', 'arm', 'disarm', 'sandbox', 'navigate', 'read'].map(name => ({ id: `${id}.${name}`, title: name })), + views: [{ id: `${id}.main`, title: id, mount: 'panel', ...(activationMode ? { entry: 'view.js' } : {}) }] }, + })) + if (activationMode) await writeFile(join(source, 'view.js'), 'export function mount() {}') + await writeFile(join(source, 'index.js'), `let context; + export async function deactivate() { + console.log('LIFECYCLE:${id}:${generation}:deactivate'); + ${shutdown} + try { await context.api.storage.set('retired-write', true); } + catch { console.log('LIFECYCLE:${id}:${generation}:revoked'); } + await new Promise(resolve => setTimeout(resolve, 30)); + console.log('LIFECYCLE:${id}:${generation}:deactivated'); + } + export async function activate(ctx) { + context = ctx; + ctx.subscriptions.push({ async dispose() { + await new Promise(resolve => setTimeout(resolve, 30)); + console.log('LIFECYCLE:${id}:${generation}:subscription'); + } }); + ${activation ?? ''} + const activations = (await ctx.api.storage.get('activations') || 0) + 1; + await ctx.api.storage.set('activations', activations); + let count = 0, interval; + const publish = () => ctx.views.publish('${id}.main', { count }); + await publish(); + ctx.registerCommand('${id}.increment', () => ++count); + ctx.registerCommand('${id}.snapshot', async () => ({ count, activations, generation: ${generation}, runs: await ctx.api.storage.get('runs') || 0 })); + ctx.registerCommand('${id}.arm', () => { interval = setInterval(() => count++, 20); }); + ctx.registerCommand('${id}.disarm', () => clearInterval(interval)); + ctx.registerCommand('${id}.fail', () => { throw new Error('fixture command failed'); }); + ctx.registerCommand('${id}.read', () => ctx.api.files.readText({ sessionId: 'fixture-session', path: 'nested/note.txt' })); + ctx.registerCommand('${id}.hang', async () => { + await ctx.api.storage.set('runs', (await ctx.api.storage.get('runs') || 0) + 1); + await ctx.api.storage.set('entered', ${generation}); + await new Promise(() => {}); + }); + ctx.registerRequest('identity', (_input, view) => view); + ctx.registerRequest('slowIdentity', async (_input, view) => { await ctx.api.storage.set('viewCalls', (await ctx.api.storage.get('viewCalls') || 0) + 1); await new Promise(resolve => setTimeout(resolve, 80)); return view; }); + ctx.registerRequest('increment', async (amount, view) => { + const next = count += amount; + await publish(); + return { count: next, viewId: view.id, instanceId: view.instanceId }; + }); + ctx.registerCommand('${id}.sandbox', async () => { + let spoofDenied = false; + try { await window.agentCodeRuntimeTransport.request({ method: 'storage.get', key: 'activations', extensionId: 'other' }); } + catch { spoofDenied = true; } + const fetched = await fetch(${JSON.stringify(endpoint)}).then(() => true, () => false); + const popup = window.open(${JSON.stringify(endpoint)}); + return { hostApi: typeof window.api, node: typeof require, process: typeof process, spoofDenied, fetched, popup: popup === null }; + }); + ctx.registerCommand('${id}.navigate', () => { setTimeout(() => { location.href = ${JSON.stringify(endpoint)}; }, 0); }); + }`) + return installExtensionFromPath(source, async () => true) + } + + try { + let installed = await install(1) + let revision = extensionRevision(installed) + const command = (name: string) => service.invokeCommand('engine', revision, `engine.${name}`) + await assert.rejects(service.invokeCommand('engine', revision, 'other.private'), /not declared/) + assert.equal(BrowserWindow.getAllWindows().length, 0, 'invalid commands must not activate code') + const cold = await Promise.allSettled(Array.from({ length: 40 }, () => command('snapshot'))) + assert.equal(cold.filter(result => result.status === 'fulfilled').length, 32) + assert.equal(cold.filter(result => result.status === 'rejected' && /Too many extension calls/.test(String(result.reason))).length, 8) + for (const result of cold) if (result.status === 'fulfilled') assert.equal(object(result.value).activations, 1) + assert.deepEqual((await Promise.all([command('increment'), command('increment'), command('increment')])).sort(), [1, 2, 3]) + assert.equal(object(await command('snapshot')).activations, 1) + assert.equal(BrowserWindow.getAllWindows().length, 1) + console.log('PASS runtime: bounded cold commands share one activation and return individual results') + + const first = await views.attach(11, 'view', 'engine', revision, 'engine.main') + const second = await views.attach(22, 'view', 'engine', revision, 'engine.main') + assert.notEqual(first.instanceId, second.instanceId, 'view instance identity is minted by main across owners') + assert.deepEqual(first.snapshot, { sequence: 1, state: { count: 0 } }) + await assert.rejects(views.request(33, 'view', 'identity', null), /connection is closed/) + const replies = await Promise.all([ + views.request(11, 'view', 'increment', 2), + views.request(22, 'view', 'increment', 3), + ]) + assert.equal(object(replies[0]).count, 5) + assert.equal(object(replies[0]).instanceId, first.instanceId) + assert.equal(object(replies[1]).count, 8) + assert.equal(object(replies[1]).instanceId, second.instanceId) + assert.equal(object(service.viewState('engine', revision, 'engine.main')).count, 8) + for (const owner of [11, 22]) { + assert.deepEqual(viewEvents.filter(message => message.owner === owner).map(message => message.event), [ + { kind: 'state', connectionId: 'view', snapshot: { sequence: 2, state: { count: 5 } } }, + { kind: 'state', connectionId: 'view', snapshot: { sequence: 3, state: { count: 8 } } }, + ]) + } + views.detachOwner(11) + await assert.rejects(views.request(11, 'view', 'identity', null), /connection is closed/) + assert.equal(object(await views.request(22, 'view', 'identity', null)).instanceId, second.instanceId) + const late = views.request(22, 'view', 'slowIdentity', null) + views.detachOwner(22) + await assert.rejects(late, /connection is closed/) + assert.equal(await extensionStorageGet('engine', 'viewCalls'), undefined, 'a closed request must not dispatch after pending ledger I/O') + await views.attach(22, 'view', 'engine', revision, 'engine.main') + const running = views.request(22, 'view', 'slowIdentity', null) + await until(async () => await extensionStorageGet('engine', 'viewCalls') === 1, 'view request entered') + views.detachOwner(22) + await assert.rejects(running, /connection is closed/) + assert.equal(await extensionStorageGet('engine', 'viewCalls'), 1, 'an already dispatched request is never replayed') + const abandoned = views.attach(44, 'race', 'engine', revision, 'engine.main') + views.detach(44, 'race') + const replacement = views.attach(44, 'race', 'engine', revision, 'engine.main') + await assert.rejects(abandoned, /connection is closed/) + const attached = await replacement + assert.equal(object(await views.request(44, 'race', 'identity', null)).instanceId, attached.instanceId) + const crowded = await Promise.allSettled(Array.from({ length: 40 }, (_, index) => views.attach(55, `view-${index}`, 'engine', revision, 'engine.main'))) + assert.equal(crowded.filter(result => result.status === 'fulfilled').length, 32) + assert.equal(crowded.filter(result => result.status === 'rejected' && /Too many extension views/.test(String(result.reason))).length, 8) + views.detachOwner(44) + views.detachOwner(55) + console.log('PASS runtime views: owner binding, sequenced state, close during attach/request and bounded connections') + await command('arm') + await until(async () => Number(object(await command('snapshot')).count) > 8, 'background ticking') + await command('disarm') + assert.equal(object(await command('snapshot')).activations, 1) + console.log('PASS runtime: distinct view requests share state; background work continues with no visible views') + + await assert.rejects(command('fail'), /fixture command failed/) + assert.equal(object(await command('snapshot')).activations, 1, 'a handled command failure must not restart the engine') + assert.deepEqual(await command('sandbox'), { hostApi: 'undefined', node: 'undefined', process: 'undefined', spoofDenied: true, fetched: false, popup: true }) + await command('navigate') + await new Promise(resolve => setTimeout(resolve, 100)) + assert.equal(object(await command('snapshot')).activations, 1, 'renderer navigation must be refused') + assert.deepEqual(hits, []) + console.log('PASS runtime: command errors, host/preload isolation, forged namespaces and direct network/navigation denial') + + await views.attach(66, 'update-view', 'engine', revision, 'engine.main') + const oldRevision = revision + const interrupted = command('hang').then(() => 'unexpected success', error => String(error)) + await until(async () => await extensionStorageGet('engine', 'entered') === 1, 'first command entered') + installed = await install(2) + revision = extensionRevision(installed) + assert.match(await interrupted, /updated or removed/) + await assert.rejects(views.request(66, 'update-view', 'identity', null), /connection is closed/) + assert.ok(viewEvents.some(message => message.owner === 66 && message.event.kind === 'closed')) + await assert.rejects(service.start('engine', oldRevision), /no longer active/) + assert.equal(service.viewState('engine', oldRevision, 'engine.main'), undefined) + assert.deepEqual(await command('snapshot'), { count: 0, activations: 2, generation: 2, runs: 1 }) + assert.deepEqual(lifecycle.filter(message => message.startsWith('LIFECYCLE:engine:1:')), [ + 'LIFECYCLE:engine:1:deactivate', 'LIFECYCLE:engine:1:revoked', 'LIFECYCLE:engine:1:deactivated', 'LIFECYCLE:engine:1:subscription', + ], 'replacement activation must wait for asynchronous author and subscription cleanup') + assert.equal(await extensionStorageGet('engine', 'retired-write'), undefined) + console.log('PASS runtime: update revokes pending work and replaces the engine while preserving storage') + + await assert.rejects(command('hang'), /timed out; its result is unknown/) + assert.deepEqual(await command('snapshot'), { count: 0, activations: 3, generation: 2, runs: 2 }) + console.log('PASS runtime: invocation deadline destroys the stalled engine and never replays the action') + + const removed = command('hang').then(() => 'unexpected success', error => String(error)) + await until(async () => await extensionStorageGet('engine', 'runs') === 3, 'last command entered') + await removeExtension('engine') + assert.match(await removed, /updated or removed/) + await assert.rejects(service.start('engine', revision), /no longer active/) + assert.equal(await extensionStorageGet('engine', 'activations'), 3) + installed = await install(3) + revision = extensionRevision(installed) + assert.deepEqual(await command('snapshot'), { count: 0, activations: 4, generation: 3, runs: 3 }) + console.log('PASS runtime: uninstall stops execution; reinstall retains saved extension state') + + const startup = await install(1, 'startup-engine', undefined, '', 'startup') + const lazy = await install(1, 'lazy-engine', undefined, '', 'lazy') + await service.activateStartupExtensions() + assert.equal(await extensionStorageGet('startup-engine', 'activations'), 1) + assert.equal(await extensionStorageGet('lazy-engine', 'activations'), undefined) + await assert.rejects(service.invokeCommand('lazy-engine', extensionRevision(lazy), 'lazy-engine.snapshot'), /does not declare activation/) + assert.equal(await service.invokeCommand('lazy-engine', extensionRevision(lazy), 'lazy-engine.increment'), 1) + await service.activateStartupExtensions() + assert.equal(await extensionStorageGet('startup-engine', 'activations'), 1, 'startup reconciliation must not activate a live engine twice') + await install(2, 'startup-engine', undefined, '', 'startup') + await until(async () => await extensionStorageGet('startup-engine', 'activations') === 2, 'updated startup runtime') + await assert.rejects(service.start('startup-engine', extensionRevision(startup)), /no longer active/) + await removeExtension('startup-engine') + await removeExtension('lazy-engine') + await until(async () => BrowserWindow.getAllWindows().length === 1, 'startup fixture teardown') + console.log('PASS runtime activation: explicit startup/lazy rules, update activation and no duplicate startup') + + const reader = await install(1, 'file-engine', undefined, '', 'lazy', ['fs.read']) + const readerRevision = extensionRevision(reader) + const fileRead = object(await service.invokeCommand('file-engine', readerRevision, 'file-engine.read')) + assert.equal(typeof fileRead.mtimeMs, 'number') + assert.deepEqual({ ...fileRead, mtimeMs: 0 }, { + sessionId: 'fixture-session', path: 'nested/note.txt', text: 'permissioned project text\n', + size: 26, mtimeMs: 0, + }) + await assert.rejects( + capabilities.invoke('file-engine', readerRevision, { method: 'fs.readText', sessionId: 'fixture-session', path: '../outside.txt' }), + /escapes project root/, + ) + await assert.rejects( + capabilities.invoke('file-engine', readerRevision, { method: 'fs.readText', sessionId: 'missing-session', path: 'nested/note.txt' }), + /target session is not active/, + ) + await assert.rejects( + capabilities.invoke('file-engine', readerRevision, { method: 'fs.readText', sessionId: 'fixture-session', path: 'binary.dat' }), + /binary files are not supported/, + ) + await assert.rejects( + capabilities.invoke('file-engine', readerRevision, { method: 'fs.readText', sessionId: 'fixture-session', path: 'large.txt' }), + /limited to 98304 bytes/, + ) + const denied = await install(1, 'denied-engine', undefined, '', 'lazy') + await assert.rejects(service.invokeCommand('denied-engine', extensionRevision(denied), 'denied-engine.read'), /capability "fs.read" is not granted/) + await removeExtension('file-engine') + await removeExtension('denied-engine') + console.log('PASS runtime files: real preload call, explicit session scope, consent, traversal, binary and size denial') + + const broken = await install(1, 'broken-engine', 'throw new Error("fixture activation failed");') + await assert.rejects(service.start('broken-engine', extensionRevision(broken)), /fixture activation failed/) + const hung = await install(1, 'hung-engine', 'await new Promise(() => {});') + await assert.rejects(service.start('hung-engine', extensionRevision(hung)), /did not finish starting/) + assert.ok(statuses.some(status => status.extensionId === 'engine' && status.state === 'failed' && status.error?.includes('result is unknown'))) + console.log('PASS runtime: activation errors and hangs are bounded and attributed') + const stuck = await install(1, 'stuck-cleanup', undefined, 'await new Promise(() => {});') + await service.start('stuck-cleanup', extensionRevision(stuck)) + await removeExtension('stuck-cleanup') + const recovered = await install(2, 'stuck-cleanup') + await service.start('stuck-cleanup', extensionRevision(recovered)) + assert.ok(lifecycle.includes('LIFECYCLE:stuck-cleanup:1:deactivate')) + assert.ok(!lifecycle.includes('LIFECYCLE:stuck-cleanup:1:deactivated')) + assert.equal(BrowserWindow.getAllWindows().length, 2, 'hung cleanup must not leave the old runtime alive') + await service.pause() + assert.equal(BrowserWindow.getAllWindows().length, 0) + await assert.rejects(service.start('engine', revision), /shutting down/) + await service.resume() + assert.equal(object(await command('snapshot')).activations, 5) + await service.start('stuck-cleanup', extensionRevision(recovered)) + console.log('PASS runtime: a cancelled quit can resume the service and restart saved engines') + const disposal = service.dispose() + assert.equal(service.dispose(), disposal, 'shutdown is idempotent even while cleanup is pending') + await disposal + assert.equal(BrowserWindow.getAllWindows().length, 0) + assert.ok(lifecycle.includes('LIFECYCLE:engine:3:subscription')) + assert.ok(lifecycle.includes('LIFECYCLE:stuck-cleanup:2:subscription')) + await assert.rejects(service.start('engine', revision), /host is stopped/) + console.log('PASS runtime: graceful cleanup has revoked authority; hung cleanup is bounded; disposal drains every window') + console.log('Extension managed runtime integration checks passed') + } finally { + views.dispose() + await service.dispose() + capabilities.dispose() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + await writeFile(join(root!, 'completed'), 'ok') + app.quit() +})().catch(error => { console.error(error); app.exit(1) }) diff --git a/src/main/index.ts b/src/main/index.ts index 9748a31da..0cbb094f4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -46,6 +46,15 @@ import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' import { reconcile } from '@main/tmux/tmuxRecovery.js' import type { PersistedTerminalRef } from '@main/tmux/tmuxRecovery.js' +import { + handleExtensionScheme, + registerExtensionScheme, +} from '@main/extensions/scheme.js' +import { ExtensionRuntimeService } from '@main/extensions/runtimeService.js' +import { ExtensionCapabilityService } from '@main/extensions/capabilityService.js' +import { registerExtensionRuntimeIpc } from '@main/extensions/runtimeIpc.js' +import { registerExtensionInputIpc } from '@main/extensions/nativeInput.js' +import { sweepAbandonedInstallDirectories } from '@main/extensions/install.js' import { STATE_DIR, STATE_FILE, TLDR_HOOK_RUNTIME_DIR } from '@main/storage/paths.js' import { scheduleDebugStoragePrune, @@ -257,6 +266,12 @@ let workflowBridge: WorkflowBridge | null = null let codexCliUpdateReserved = false let workflowShutdownPromise: Promise | null = null let workflowShutdownComplete = false +let extensionRuntime: ExtensionRuntimeService | null = null +let extensionCapabilities: ExtensionCapabilityService | null = null +let unregisterExtensionRuntime: (() => void) | null = null +let unregisterExtensionInput: (() => void) | null = null +let extensionQuitReady = false +let extensionQuitPending: Promise | null = null let sessionForwarder: SessionForwarderControl | null = null // A packaged release needs one executable-level smoke test that stops before @@ -305,6 +320,14 @@ async function runPackagingSmoke(): Promise { // shared. If that lock ever feels too strict, the storage model must be changed // first; deleting the guard alone would make last-writer-wins corruption // possible again. +// MUST run at module scope, before any app.whenReady() handler. Electron silently +// treats a scheme registered after ready as opaque — no origin semantics, no secure +// context, no CORS — and the failure then surfaces in the renderer as a CSP or CORS +// error, which sends you looking at index.html instead of at call ordering. There is +// no runtime warning for getting this wrong. Verified working from a file:// document +// (the production origin) by the B1 spike: dynamic import, relative specifiers, and +// path-traversal rejection all behave. +registerExtensionScheme() // WHY quit has to be distinguishable from an ordinary window close: closing one // window hands its workspace to a survivor, but quitting closes every window // and must NOT collapse them all into whichever one dies last. @@ -602,6 +625,42 @@ async function startApp(): Promise { appRunJournal.recordError('prior_run.classify.error', err) } + // Install the agent-code-ext:// handler before any window exists. The renderer + // imports extension modules over this scheme during startup, so a window that + // opened first could race a request against an unregistered handler and see a + // spurious load failure that never reproduces on a warm run. + handleExtensionScheme() + // The resolver closes over the late-created SessionManager rather than a + // focused renderer. Startup extensions may run with zero views, but every file + // target still has to name a live main-owned session and therefore a real cwd. + extensionCapabilities = new ExtensionCapabilityService({ + resolveSessionRoot: sessionId => manager?.getSpawnCwd(sessionId) ?? null, + }) + extensionRuntime = new ExtensionRuntimeService({ + preload: join(__dirname, '../preload/extensionRuntime.js'), + capabilities: extensionCapabilities, + }) + unregisterExtensionRuntime = registerExtensionRuntimeIpc(extensionRuntime, extensionCapabilities, contents => { + const id = windowIdFor(contents) + return !!id && getBrowserWindow(id)?.webContents === contents + }) + unregisterExtensionInput = registerExtensionInputIpc(contents => { + const id = windowIdFor(contents) + return !!id && getBrowserWindow(id)?.webContents === contents + }) + + // Finish extension housekeeping before a window can install or load code. + // This is serialized with publication too; fire-and-forget previously let a + // slow startup sweep delete staging created by the first install dialog. + // Failures preserve data and are reported; they need not block the whole app. + await sweepAbandonedInstallDirectories().catch(err => { + console.warn('[extensions] staging sweep failed:', err) + }) + + // Performance monitoring and extension startup are independent main-process + // owners. Keep both before window creation: monitoring otherwise misses the + // startup interval, while extension frames can race an unregistered scheme or + // an unfinished install sweep if a window is allowed to open first. powerMonitor.on('suspend', () => mainProbe.noteSuspend()) powerMonitor.on('resume', () => mainProbe.noteResume()) monitorCoordinator.start() @@ -985,7 +1044,11 @@ async function startApp(): Promise { // renderer/workspace/adoptWorkspace.ts for why the SURVIVOR performs the // merge rather than main. // The only party that knows a ⌘Q was cancelled is the unsaved-changes sheet. - setWindowCloseVetoedObserver(() => { quitting = false }) + setWindowCloseVetoedObserver(() => { + quitting = false + extensionQuitReady = false + void extensionRuntime?.resume().catch(error => console.error('[extensions] resume after cancelled quit failed:', error)) + }) setWindowClosedObserver(closedWindowId => { // WHY quitting is excluded: on quit every window closes, and collapsing all // of them into whichever one happens to die last would destroy the @@ -1134,6 +1197,7 @@ async function startApp(): Promise { // items dispatch command ids to THIS window's renderer (issue #148). Menu.setApplicationMenu(buildAppMenu()) performanceService.mark('app.main.window.created') + void extensionRuntime?.activateStartupExtensions().catch(error => console.error('[extensions] startup catalog failed:', error)) app.on('activate', () => { if (sessionShutdownGate.isTerminalShutdownAdmitted()) { @@ -1197,6 +1261,17 @@ app.on('before-quit', (event) => { } return } + if (extensionRuntime && !extensionQuitReady) { + event.preventDefault() + if (!extensionQuitPending) { + extensionQuitPending = extensionRuntime.pause().then(() => { + extensionQuitReady = true + extensionQuitPending = null + app.quit() + }) + } + return + } appRunJournal?.record({ area: 'app.lifecycle', name: 'app.before_quit' }) performanceService.mark('app.main.beforeQuit') // WHY coalescers drain on the initial quit attempt: their buffers are cheap @@ -1239,7 +1314,14 @@ const sessionShutdownGate = installSessionShutdownGate({ if (!current) return null return { killAll: async () => { - await current.killAll() + await Promise.all([current.killAll(), extensionRuntime?.dispose()]) + unregisterExtensionRuntime?.() + unregisterExtensionRuntime = null + unregisterExtensionInput?.() + unregisterExtensionInput = null + extensionRuntime = null + extensionCapabilities?.dispose() + extensionCapabilities = null // WHY a second sweep after killAll: killAll stops the sessions it // knows about, and each ClaudeSession.stop() already terminates its // own mitmdump under a deadline. This catches what that snapshot diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts new file mode 100644 index 000000000..ba4088852 --- /dev/null +++ b/src/main/ipc/extensions.ts @@ -0,0 +1,236 @@ +import { BrowserWindow, dialog, ipcMain } from 'electron' +import type { IpcMainInvokeEvent } from 'electron' + +import { + extensionStorageDelete, + extensionStorageGet, + extensionStorageKeys, + extensionStorageSet, +} from '@main/extensions/storage.js' +import { installExtension, installExtensionFromPath } from '@main/extensions/install.js' +import type { ConsentPrompt } from '@main/extensions/install.js' +import { listInstalledExtensions, onExtensionPublication, removeExtension } from '@main/extensions/ledger.js' +import { installedExtensionCapabilities } from '@main/extensions/grants.js' +import { isValidExtensionId } from '@shared/types/extensionId.js' +import type { + ExtensionCapability, + ExtensionInstallResult, + ExtensionListEntry, +} from '@shared/types/extensions.js' + +// The capability-consent dialog, shared by both install paths (GitHub + local +// folder). A blocking, OS-native dialog on purpose: granting an extension +// filesystem or session access is exactly the moment that must not be a quiet +// in-page toggle. Tier-0-only extensions never reach it (installers only call it +// when permissions is non-empty). +/** + * What each capability actually discloses, in the user's terms. + * + * ── WHY THE RAW ENUM NAME IS NOT ENOUGH ── + * The dialog used to render ` • sessions.observe` and nothing else. That gives a + * user no way to know it discloses the ABSOLUTE WORKING DIRECTORY of every open + * session — which for a working developer enumerates client names, private + * repository names, and employer directory layout. A consent prompt that names a + * permission without describing it is a prompt that can only be answered by + * trusting the author, which is the decision it is supposed to inform. + * + * Keyed by the capability union, so a new capability does not compile until its + * disclosure is written. That is deliberate: the description is part of shipping + * the capability, not a follow-up. + */ +const CAPABILITY_DISCLOSURE: Record = { + 'workspace.observe': 'Which tabs are open, and how many sessions exist.', + 'sessions.observe': + 'Every open session: its title, its provider, and the full folder path it is running in.', + 'panes.observe': 'How your panes are arranged, and which session is in each one.', + 'fs.read': + 'The contents of text files it names inside projects belonging to active sessions.', +} + +function consentPromptFor(evt: IpcMainInvokeEvent, source: string): ConsentPrompt { + return async manifest => { + const win = BrowserWindow.fromWebContents(evt.sender) + const permissions = manifest.permissions ?? [] + const detail = permissions.map(cap => ` • ${CAPABILITY_DISCLOSURE[cap]}`).join('\n') + + const options = { + // 'question', not 'warning'. Every remaining capability is a read-only + // metadata snapshot; the ones that ACTED were removed because nothing + // implemented them. A warning triangle over three read permissions trains + // click-through exactly as reliably as saying too little does, and the next + // capability that genuinely deserves alarm would inherit a numb user. + type: 'question' as const, + buttons: ['Cancel', 'Grant & install'], + // Both point at Cancel: Return, Escape and closing the window all decline. + defaultId: 0, + cancelId: 0, + title: 'Extension permissions', + // `source` is what the USER typed — the repo name or the folder they picked — + // and AppsSettingsRow's own header calls it "the trust decision". It was the + // one thing the dialog did not show. `manifest.name` is attacker-chosen and + // only length-bounded, so it is presented as a claim about an identity + // (`id`), never as the identity itself. + message: `Install ${manifest.id} from ${source}?`, + detail: + `"${manifest.name}" wants to read:\n\n${detail}\n\n` + + `It cannot change anything, and it has no network access. ` + + `Install it only if you trust ${source}.`, + } + const result = win + ? await dialog.showMessageBox(win, options) + : await dialog.showMessageBox(options) + return result.response === 1 + } +} + +// IPC for extension-app state. +// +// WHY appId is a caller-supplied parameter rather than derived from the sender: +// NOT because identity is unavailable, but because the sender is never the +// extension. An extension frame is cross-origin with no preload and therefore no +// `ipcRenderer` at all — it cannot call this or any other handler. Every caller +// here is the trusted host renderer's main frame, brokering on the extension's +// behalf, so `event.senderFrame` would identify the BROKER, not the extension, and +// binding to it would achieve nothing. +// +// (An earlier version of this comment said the opposite: that every app shared one +// WebContents and that a future "Stage 2" giving each extension its own frame would +// make sender binding possible. That stage shipped — the frames exist — and the +// conclusion inverted rather than resolved. A maintainer acting on the old text +// would implement senderFrame binding, find it identifies the host, and either +// break every extension's storage or conclude that Tier 1-3 handlers are now safe +// here. They are not; see below.) +// +// The authority boundary is real, and it lives in two places that actually enforce +// it: `createAppHostApi` closes over one extensionId and is the sole call site of +// `window.api.extensionStorage*`, and `frameHost` refuses any message whose +// `event.source` is not that iframe's contentWindow and whose browser-stamped +// `event.origin` is not `agent-code-ext://`. +// +// The prohibition stands unchanged: do NOT add workspace, session, transcript, +// git, filesystem or network capabilities to this module. Those must route through +// frameHost, where the origin check is — adding one here would be a real privilege +// escalation wearing a namespace's clothes. +export function registerExtensionsIpc(): void { + onExtensionPublication(rows => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + try { window.webContents.send('extensions:changed', rows) } catch (error) { + // Closing one window must not prevent revocation reaching the others. + console.warn('[extensions] window publication failed:', error) + } + } + } + }) + ipcMain.handle('extensions:storage-get', async (_evt, appId: string, key: string) => + extensionStorageGet(appId, key), + ) + + ipcMain.handle( + 'extensions:storage-set', + async (_evt, appId: string, key: string, value: unknown) => + extensionStorageSet(appId, key, value), + ) + + ipcMain.handle('extensions:storage-delete', async (_evt, appId: string, key: string) => + extensionStorageDelete(appId, key), + ) + + ipcMain.handle('extensions:storage-keys', async (_evt, appId: string) => + extensionStorageKeys(appId), + ) + + ipcMain.handle('extensions:list', async (): Promise => + listInstalledExtensions(), + ) + + // WHY install returns a result object instead of rejecting: every failure here is + // something the user can act on — wrong repo name, private repo, missing + // manifest, unsupported API version, archive too large. An IPC rejection reaches + // the renderer as `Error invoking remote method 'extensions:install': …` with the + // real message buried in a prefix, and the error class is lost across the bridge. + // Returning `{ ok: false, error }` keeps the actionable sentence intact and makes + // the Settings UI's job a render, not a parse. + ipcMain.handle( + 'extensions:install', + async (evt, repo: string): Promise => { + try { + const record = await installExtension(repo, consentPromptFor(evt, repo.trim())) + return { ok: true, entry: { ...record, present: true } } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }, + ) + + // "Load unpacked" — install from a local folder chosen in a native picker, so an + // author iterating on an unpublished extension never has to cut a GitHub release. + // The picker runs in main (a directory chooser cannot be a renderer input), and + // the same consent + validation pipeline as GitHub install applies. + ipcMain.handle('extensions:install-path', async (evt): Promise => { + const win = BrowserWindow.fromWebContents(evt.sender) + const options = { + properties: ['openDirectory' as const], + title: 'Load extension from folder', + message: "Choose the extension's built folder (containing agent-code.extension.json)", + } + const picked = win + ? await dialog.showOpenDialog(win, options) + : await dialog.showOpenDialog(options) + const dir = picked.filePaths[0] + if (picked.canceled || !dir) return { ok: false, error: 'No folder selected.' } + try { + const record = await installExtensionFromPath(dir, consentPromptFor(evt, dir)) + return { ok: true, entry: { ...record, present: true } } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + // Reinstall a LOCAL extension from the folder already recorded in its ledger row, + // with no directory picker. + // + // WHY this is not a security regression: the path is not caller-supplied. It is + // read from the ledger, where it was written by a native picker the user drove + // themselves — so this re-runs a choice already made, exactly as the GitHub + // Update button re-runs `owner/repo`. Making the renderer pass a path instead + // WOULD be a regression: it would turn "reinstall what you chose" into "install + // any directory on this machine, on the renderer's say-so". + // + // The full validation pipeline still runs: manifest parse, tree containment, + // entry containment, and — for any manifest requesting permissions — a consent + // prompt. That prompt is UNCONDITIONAL, not "only if the bytes changed": + // finalizeInstall gates on `permissions.length`, never on a hash comparison, so + // an author reloading an unchanged build is asked again. Deliberate, given the + // alternative is comparing against a grant the reload is about to replace. + ipcMain.handle('extensions:update-local', async (evt, id: string): Promise => { + if (!isValidExtensionId(id)) return { ok: false, error: 'Unknown extension.' } + const installed = await listInstalledExtensions() + const entry = installed.find(candidate => candidate.manifest.id === id) + if (!entry) return { ok: false, error: 'Extension is no longer installed.' } + if (entry.origin !== 'local') { + return { ok: false, error: 'This extension was installed from GitHub; use Update.' } + } + try { + const record = await installExtensionFromPath(entry.repo, consentPromptFor(evt, entry.repo)) + return { ok: true, entry: { ...record, present: true } } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + ipcMain.handle('extensions:remove', async (_evt, id: string): Promise => { + await removeExtension(id) + }) + + // Consent belongs to the committed installation, and is checked against a + // fresh full-bundle digest. The helper holds the publication lock across both + // reads, so an update cannot mix one generation's bytes with another's grant. + ipcMain.handle( + 'extensions:granted-capabilities', + async (_evt, id: string, revision: string): Promise => { + if (!isValidExtensionId(id) || typeof revision !== 'string') return [] + return installedExtensionCapabilities(id, revision) + }, + ) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 1604b5614..ca3db4b02 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -55,6 +55,7 @@ import { registerAgentCodeCustomSkillsIpc } from '@main/ipc/agentCodeCustomSkill import { registerAgentCodeInstalledSkillsIpc } from '@main/ipc/agentCodeInstalledSkills.js' import { registerAgentSkillsIpc } from '@main/ipc/agentSkills.js' import type { WorkflowBridge } from '@main/workflows/WorkflowBridge.js' +import { registerExtensionsIpc } from '@main/ipc/extensions.js' // IPC registration aggregator. // @@ -134,6 +135,10 @@ export function registerAllIpc(deps: IpcDeps): void { registerUsageIpc() registerCliUpdatesIpc(deps.cliUpdateOrchestrator) registerWorkflowIpc(deps.workflowBridge) + // Takes no deps on purpose: extension storage is a pure filesystem namespace + // under STATE_DIR with no app service behind it. The moment this needs a dep, + // it has stopped being storage and the Stage-2 sender-identity question applies. + registerExtensionsIpc() registerAgentCodeConventionsIpc(deps.agentCodeConventionsService) registerAgentCodeCustomSkillsIpc(deps.agentCodeConventionsService) registerAgentCodeInstalledSkillsIpc(deps.agentCodeConventionsService) diff --git a/src/main/remote/RemoteServer.ts b/src/main/remote/RemoteServer.ts index 02d3779d3..3dafbcf91 100644 --- a/src/main/remote/RemoteServer.ts +++ b/src/main/remote/RemoteServer.ts @@ -696,7 +696,7 @@ export class RemoteServer extends EventEmitter { return { ok: false, error: 'no transcript on disk yet for this session' } } const kind = this.deps.manager.getSessionKind(msg.sessionId) - if (!kind || kind === 'terminal') { + if (!isAgentProviderKind(kind)) { return { ok: false, error: 'not an agent session' } } // Routing belongs to the registry capability, not a URI prefix. The diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 6a6791412..123a2aee7 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -54,6 +54,7 @@ import { DEFAULT_PROVIDER, isAgentProviderRuntime, isAgentProviderKind, + isAgentSessionKind, isSessionKind, } from '@shared/types/providerKind.js' import type { @@ -835,7 +836,7 @@ export class SessionManager extends EventEmitter { sessionId: string, kind: SessionKind, expectedEntry?: RegistryEntry, - revokeAgentMcp = kind !== 'terminal', + revokeAgentMcp = isAgentSessionKind(kind), expectedGeneration = expectedEntry?.lifecycle.generation, ): boolean { // WHY this helper exists even before the larger SessionState consolidation: @@ -1463,6 +1464,18 @@ export class SessionManager extends EventEmitter { message: 'This Agent Code version does not support the requested provider.', }) } + // Same fence as spawnWithId: an extension-view leaf is process-less and is + // excluded from the rehydrate live-process set (collectLiveProcessIds), so a + // recover for one should never be issued. If a stale renderer does, refuse + // rather than fall through and spawn a terminal shell for it. + if (requestedKind === 'extension-view') { + return Promise.resolve({ + ok: false, + code: 'start-failed', + retryable: false, + message: 'extension-view panes have no process to recover.', + }) + } const kind = options.kind ?? DEFAULT_PROVIDER let providerRuntime: AgentProviderRuntime | undefined try { @@ -2455,6 +2468,14 @@ export class SessionManager extends EventEmitter { if (requestedKind !== undefined && !isSessionKind(requestedKind)) { throw new Error('Unsupported session provider') } + // Extension-view "sessions" are renderer-only tile leaves with NO process; they + // are created directly in the workspace store (openExtensionViewInPane) and must + // never reach main. isSessionKind now accepts 'extension-view', so without this + // it would fall through to the terminal-spawn branch below and start a stray + // shell. A spawn request for one is a stale or hostile caller — refuse it. + if (requestedKind === 'extension-view') { + throw new Error('extension-view panes have no process and cannot be spawned') + } const kind: SessionKind = options.kind ?? DEFAULT_PROVIDER const providerRuntime = resolveProviderRuntime(kind, options.providerRuntime) // Fail here rather than in the forked child. See @@ -4294,7 +4315,7 @@ export class SessionManager extends EventEmitter { sessionId, recovery.kind, undefined, - recovery.kind !== 'terminal', + isAgentSessionKind(recovery.kind), generation, ) } @@ -4352,6 +4373,13 @@ export class SessionManager extends EventEmitter { ): Promise { const requestedKind: unknown = options.kind if (requestedKind !== undefined && !isSessionKind(requestedKind)) return false + // The THIRD renderer→main path that can carry an extension-view kind, after + // spawnWithId and recover. Every close path in the renderer sends the pane's + // kind here, and this returned false for an extension pane already — but by + // accident of the ownership tables being empty for it, not by a decision. + // Stating it makes the boundary three-for-three and stops a future ownership + // change from turning "nothing to kill" into "kill something". + if (requestedKind === 'extension-view') return false const kind = options.kind ?? DEFAULT_PROVIDER let providerRuntime: AgentProviderRuntime | undefined try { @@ -4474,7 +4502,7 @@ export class SessionManager extends EventEmitter { sessionId, claim.kind, undefined, - claim.kind !== 'terminal', + isAgentSessionKind(claim.kind), claim.spawnGeneration, ) } @@ -4681,7 +4709,7 @@ export class SessionManager extends EventEmitter { if (observed) return observed const info = this.spawnInfo.get(sessionId) const kind = this.getSessionKind(sessionId) - if (!info?.resumeSessionId || !kind || kind === 'terminal') return null + if (!info?.resumeSessionId || !isAgentProviderKind(kind)) return null try { // Resume metadata is available before any new committed entry. Providers // with their own history mint the durable locator from that identity; diff --git a/src/main/storage/paths.ts b/src/main/storage/paths.ts index 455fc51e4..413562a93 100644 --- a/src/main/storage/paths.ts +++ b/src/main/storage/paths.ts @@ -21,6 +21,39 @@ export const STATE_DIR = join(homedir(), '.config', APP_SLUG) // The renderer owns the JSON shape; main is a byte mover. export const STATE_FILE = join(STATE_DIR, 'workspace.json') +// Installed extension bundles — immutable generations under .bundles//. +// The ledger selects the published generation. Older installs remain directly +// under until updated; never infer installation from a directory scan. +// +// This is CODE, fetched from a remote repository. It is disposable in the sense +// that reinstalling restores it, and it is the directory a future privileged +// scheme serves from. +export const EXTENSIONS_DIR = join(STATE_DIR, 'extensions') + +// The install ledger: which extensions are installed, from which repo and ref, at +// which content hash. Separate from the bundles so a corrupt or half-extracted +// bundle directory can never make the app forget what is supposed to be installed. +export const EXTENSIONS_LOCKFILE = join(STATE_DIR, 'extensions.json') + +// Per-extension state, one JSON file per extension id. +// +// WHY this is a SIBLING of EXTENSIONS_DIR rather than living inside each bundle: +// the bundle directory is replaced wholesale on install and update — extracting a +// new version over it, or removing it first, would take the user's saved state with +// it. Keeping state outside means an update never touches it and an uninstall can +// choose whether to. It also means the scheme that serves extension code can be +// pointed at EXTENSIONS_DIR without ever exposing state files over that origin. +// +// WHY main-owned rather than the renderer's zustand-persist blob: app-state/store.ts +// records that adding a field without bumping the persist version black-screened +// launch twice (#249). Extension state is authored outside the app's release cycle — +// by definition nobody bumps a version for it — so it must not be able to reach that +// failure mode at all. +// +// WHY deliberately NOT registered with debugRetention, unlike every debug root +// below: those are disposable forensic caches with a disk budget, and this is *user +// data*. A retention sweep would silently delete an extension's saved state. +export const EXTENSION_STATE_DIR = join(STATE_DIR, 'extension-state') // Main-owned desired state and ownership journal for the optional personal // conventions skill. Provider copies are integration surfaces, never the source // of truth; keeping this beside workspace state gives recovery one stable path. diff --git a/src/preload/api/extensions.ts b/src/preload/api/extensions.ts new file mode 100644 index 000000000..0484df03c --- /dev/null +++ b/src/preload/api/extensions.ts @@ -0,0 +1,96 @@ +import { ipcRenderer } from 'electron' +import type { ExtensionJson, RuntimeViewEvent, RuntimeViewSnapshot } from '@shared/types/extensionRuntime.js' +import { subscribe } from '@preload/api/ipc.js' +import type { ExtensionInputBindings, ExtensionNativeInput } from '@shared/types/extensionInput.js' +import type { ExtensionServiceRequest, ExtensionTextFile } from '@shared/types/extensionServices.js' + +import type { + ExtensionCapability, + ExtensionInstallResult, + ExtensionListEntry, + InstalledExtension, +} from '@shared/types/extensions.js' + +// Extension-app storage and management bridge. +// +// ── WHO IS ALLOWED TO CALL WHAT, AND WHY IT IS TWO GROUPS ── +// An earlier version of this header claimed these methods had a single call site, +// `apps/api/useAppHostApi.ts`, and that a second one would mean "the ABI has been +// bypassed". That module does not exist, and five files call these methods +// directly — so the comment asserted a security-shaped invariant that was simply +// false, which is worse than no comment at all. +// +// The real rule has two halves: +// +// The STORAGE methods are extension-facing, and `createAppHostApi` is their only +// legitimate call site. It closes over one extension id, so an extension can +// never name another's namespace. A second call site for THOSE would be a real +// bypass. (ExtensionSettingRow also calls them, deliberately: a contributed +// settings row reads and writes the extension's own storage on its behalf, with +// the id coming from the validated manifest.) +// +// The MANAGEMENT methods — list, install, install-path, update-local, remove, +// granted-capabilities — are host-facing by design. The Settings UI and the frame +// broker call them directly and must. An extension must never reach them, and it +// cannot: its frame is cross-origin with no preload, so it has no `window.api` +// and no ipcRenderer at all. +export const extensionsApi = { + extensionsSetInputBindings: (bindings: ExtensionInputBindings): Promise => ipcRenderer.invoke('extensions:input-bindings', bindings), + onExtensionNativeInput: (handler: (event: ExtensionNativeInput) => void): (() => void) => subscribe('extensions:native-input', handler), + // Host-only view adapter. The child broker fixes the identity fields; main + // authenticates this application frame and mints the runtime view instance. + extensionsRuntimeCommand: (extensionId: string, revision: string, commandId: string): Promise => + ipcRenderer.invoke('extensions:runtime-host', { method: 'command', extensionId, revision, commandId }), + extensionsRuntimeAttach: (extensionId: string, revision: string, viewId: string, connectionId: string): Promise<{ instanceId: string; snapshot: RuntimeViewSnapshot }> => + ipcRenderer.invoke('extensions:runtime-host', { method: 'view.attach', extensionId, revision, viewId, connectionId }), + extensionsRuntimeRequest: (connectionId: string, name: string, input: ExtensionJson): Promise => + ipcRenderer.invoke('extensions:runtime-host', { method: 'view.request', connectionId, name, input }), + extensionsRuntimeDetach: (connectionId: string): Promise => + ipcRenderer.invoke('extensions:runtime-host', { method: 'view.detach', connectionId }), + onExtensionsRuntimeView: (handler: (event: RuntimeViewEvent) => void): (() => void) => + subscribe('extensions:runtime-view', handler), + // Host-only service adapter. frameHost fixes extension identity/revision before + // forwarding a child request; the managed runtime uses its separate preload. + extensionsServiceRequest: (extensionId: string, revision: string, request: ExtensionServiceRequest): Promise => + ipcRenderer.invoke('extensions:runtime-host', { method: 'service', extensionId, revision, request }), + + extensionStorageGet: (appId: string, key: string): Promise => + ipcRenderer.invoke('extensions:storage-get', appId, key), + + extensionStorageSet: (appId: string, key: string, value: unknown): Promise => + ipcRenderer.invoke('extensions:storage-set', appId, key, value), + + extensionStorageDelete: (appId: string, key: string): Promise => + ipcRenderer.invoke('extensions:storage-delete', appId, key), + + extensionStorageKeys: (appId: string): Promise => + ipcRenderer.invoke('extensions:storage-keys', appId), + + // Install management. These are HOST methods, not part of AgentCodeApiV1 — an + // extension must never be able to install or remove another extension. They are + // called only by the Settings UI. + extensionsList: (): Promise => ipcRenderer.invoke('extensions:list'), + onExtensionsChanged: (handler: (rows: InstalledExtension[]) => void): (() => void) => + subscribe('extensions:changed', handler), + + extensionsInstall: (repo: string): Promise => + ipcRenderer.invoke('extensions:install', repo), + + // "Load unpacked" from a local folder (main opens the native directory picker). + extensionsInstallPath: (): Promise => + ipcRenderer.invoke('extensions:install-path'), + + // Reinstall a locally-loaded extension from the folder recorded at install time. + // Takes only the id — main reads the path from its own ledger, so the renderer + // never names a directory. + extensionsUpdateLocal: (id: string): Promise => + ipcRenderer.invoke('extensions:update-local', id), + + extensionsRemove: (id: string): Promise => ipcRenderer.invoke('extensions:remove', id), + + // Reads the set of capabilities a user granted an extension, for the frame broker + // to gate Tier 1-3 calls. A HOST method, not part of AgentCodeApiV1 — an extension + // must never read (or change) its own or another's grants; only the broker calls it. + extensionGrantedCapabilities: (id: string, revision: string): Promise => + ipcRenderer.invoke('extensions:granted-capabilities', id, revision), +} diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index c9683d5a2..6f34ef55f 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -32,6 +32,7 @@ import { remoteApi } from '@preload/api/remote.js' import { usageApi } from '@preload/api/usage.js' import { cliUpdatesApi } from '@preload/api/cliUpdates.js' import { workflowsApi } from '@preload/api/workflows.js' +import { extensionsApi } from '@preload/api/extensions.js' import { agentCodeConventionsApi } from '@preload/api/agentCodeConventions.js' import { agentCodeCustomSkillsApi } from '@preload/api/agentCodeCustomSkills.js' import { agentCodeInstalledSkillsApi } from '@preload/api/agentCodeInstalledSkills.js' @@ -95,6 +96,7 @@ export const api = { ...usageApi, ...cliUpdatesApi, ...workflowsApi, + ...extensionsApi, ...agentCodeConventionsApi, ...agentCodeCustomSkillsApi, ...agentCodeInstalledSkillsApi, diff --git a/src/preload/extensionRuntime.ts b/src/preload/extensionRuntime.ts new file mode 100644 index 000000000..0d355dbab --- /dev/null +++ b/src/preload/extensionRuntime.ts @@ -0,0 +1,31 @@ +import { contextBridge, ipcRenderer } from 'electron' +import type { RuntimeApiRequest, RuntimeEvent, RuntimeInvocation } from '@shared/types/extensionRuntime.js' +import { isExtensionJson } from '@shared/types/extensionJson.js' + +// This closure runs in the isolated preload world, so extension JavaScript +// cannot replace its counter or bypass the check with a direct ipcRenderer call. +// Reject oversized data BEFORE the renderer-to-main IPC clone and limit queued +// messages here; main repeats both checks as its own authority boundary. +let credits = 128 +let creditedAt = performance.now() +function admit(message: unknown): void { + const now = performance.now() + credits = Math.min(128, credits + (now - creditedAt) * 0.128) + creditedAt = now + if (credits < 1) throw new Error('Extension runtime message rate exceeded.') + credits -= 1 + if (!isExtensionJson(message)) throw new Error('Extension runtime message exceeds the JSON limits.') +} + +// This preload is exclusively for a managed extension runtime. Never import the +// application preload here: its filesystem/session/management channels would turn +// a sandboxed extension into an application renderer with unrestricted authority. +contextBridge.exposeInMainWorld('agentCodeRuntimeTransport', { + request: async (request: RuntimeApiRequest) => { admit(request); return ipcRenderer.invoke('extensions:runtime-api', request) }, + send: (event: RuntimeEvent) => { admit(event); ipcRenderer.send('extensions:runtime-event', event) }, + subscribe: (listener: (message: RuntimeInvocation) => void) => { + const handler = (_event: Electron.IpcRendererEvent, message: RuntimeInvocation) => listener(message) + ipcRenderer.on('extensions:runtime-invoke', handler) + return () => ipcRenderer.removeListener('extensions:runtime-invoke', handler) + }, +}) diff --git a/src/renderer/index.html b/src/renderer/index.html index 247923fb1..cdb1f67a4 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -11,10 +11,26 @@ directive, workers fall back to script-src 'self', which blocks blob: — the TS/JSON/CSS/HTML workers die silently and tokenization goes blank intermittently (#513). + ── agent-code-ext: APPEARS IN frame-src AND NOWHERE ELSE ── + That single directive is what contains extension frames, and it is the + one doing the real work here — everything above describes the other + directives, so someone tidying this line has no way to know that. + + It was previously also in script-src, connect-src, img-src, style-src and + font-src, to serve ExtensionHost.activate() — a host-realm + import('agent-code-ext://…') that evaluates third-party extension code in + THIS 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. + + The child loads its own assets under its own far stricter policy (see + childFrameCsp), so the host needs none of these. If background activation + is added later it must be a hidden FRAME, not a host-realm import. --> Agent Code diff --git a/src/renderer/src/app-state/settings/customAppearance.ts b/src/renderer/src/app-state/settings/customAppearance.ts index c6b729b23..3387bd01a 100644 --- a/src/renderer/src/app-state/settings/customAppearance.ts +++ b/src/renderer/src/app-state/settings/customAppearance.ts @@ -1,86 +1,5 @@ -export const CUSTOM_APPEARANCE_COLOR_KEYS = [ - 'canvas', - 'surface', - 'surfaceHi', - 'ink', - 'inkDim', - 'muted', - 'border', - 'borderHi', - 'accent', - 'accentFg', - 'accentSoft', - 'focusRing', - 'panelBg', - 'panelHeaderBg', - 'panelElevatedBg', - 'panelBorder', - 'rowBg', - 'rowHoverBg', - 'rowSelectedBg', - 'rowSelectedFg', - 'rowDangerSelectedBg', - 'controlBg', - 'controlHoverBg', - 'controlActiveBg', - 'controlBorder', - 'controlBorderHover', - 'controlFg', - 'controlActiveFg', - 'inputBg', - 'inputBorder', - 'inputBorderFocus', - 'inputPlaceholder', - 'tabBg', - 'tabActiveBg', - 'tabHoverBg', - 'tabAccent', - 'popoverBg', - 'popoverBorder', - 'overlayScrim', - 'overlayScrimStrong', - 'shadowColor', - 'codeBg', - 'codeBorder', - 'codeCurrentLineBg', - 'codeSelectionBg', - 'codeSelectionInactiveBg', - 'codeScrollbarBg', - 'codeScrollbarHoverBg', - 'codeScrollbarActiveBg', - 'editorBg', - 'editorFg', - 'editorCurrentLineBg', - 'editorSelectionBg', - 'editorSelectionInactiveBg', - 'editorScrollbarBg', - 'editorScrollbarHoverBg', - 'editorScrollbarActiveBg', - 'danger', - 'dangerFg', - 'dangerSoft', - 'dangerBorder', - 'success', - 'successFg', - 'successSoft', - 'successBorder', - 'warning', - 'warningFg', - 'warningSoft', - 'warningBorder', - 'info', - 'infoFg', - 'infoSoft', - 'infoBorder', - 'codeInk', - 'codeInkDim', - 'userBg', - 'toolBg', - 'diffAddBg', - 'diffRemoveBg', - 'diffAddFg', - 'diffRemoveFg', -] as const +import { CUSTOM_APPEARANCE_COLOR_KEYS } from '@shared/appearanceColors' +export { CUSTOM_APPEARANCE_COLOR_KEYS } from '@shared/appearanceColors' export type CustomAppearanceColorKey = typeof CUSTOM_APPEARANCE_COLOR_KEYS[number] export type CustomAppearanceColors = Record diff --git a/src/renderer/src/app-state/settings/extensionThemes.renderer.test.tsx b/src/renderer/src/app-state/settings/extensionThemes.renderer.test.tsx new file mode 100644 index 000000000..d4442b74a --- /dev/null +++ b/src/renderer/src/app-state/settings/extensionThemes.renderer.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, expect, it } from 'vitest' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { useAppStore } from '@renderer/app-state/hooks' +import { createAppHostApi } from '@renderer/apps/api/createAppHostApi' +import { ThemePickerRow } from '@renderer/features/settings/ui/ThemePickerRow' +import { parseExtensionManifest } from '@main/extensions/manifest' +import type { ExtensionListEntry } from '@shared/types/extensions' +import { extensionThemeMode } from '@shared/types/extensionThemes' +import { coerceSettings } from './persistence' +import { applyTheme, resolveThemePayload, themeSettingsForRemote, THEME_CHANGED_EVENT } from './theme' +import { DEFAULT_SETTINGS } from './types' + +const initial = useAppStore.getState() +afterEach(() => { useAppStore.setState(initial); applyTheme(initial.settings, initial.installedExtensions) }) +const mode = extensionThemeMode('palette.night') +function entry(canvas = '#123456'): ExtensionListEntry { + return { manifest: parseExtensionManifest(JSON.stringify({ id: 'palette', name: 'Palette Pack', description: 'A theme', version: '1', apiVersion: 2, entry: 'unused.js', + contributes: { themes: [{ id: 'palette.night', title: 'Night', colors: { canvas, ink: '#abcdef' } }] } })), + origin: 'local', repo: '/fixture', ref: 'local', sha256: 'hash', installedAt: 0, present: true } +} +const canvas = () => document.documentElement.style.getPropertyValue('--theme-canvas') + +it('exposes applied palette colors through the actual frame API before stylesheets define them', async () => { + applyTheme({ ...DEFAULT_SETTINGS, mode }, [entry()]) + const api = createAppHostApi({ extensionId: 'palette', closeSurface: () => {}, showToast: () => {} }) + expect((await api.theme.tokens())['--theme-canvas']).toBe('#123456') +}) + +it('restores selection after hydration and reconciles install/update/remove/reinstall through the real store', () => { + useAppStore.getState().setInstalledExtensions([]) + useAppStore.getState().setSettings(coerceSettings(JSON.parse(JSON.stringify({ ...DEFAULT_SETTINGS, mode })))) + expect(useAppStore.getState().settings.mode).toBe(mode) + expect(document.documentElement.dataset.mode).toBe('dark') + const events: string[] = [] + const changed = () => events.push(canvas()) + window.addEventListener(THEME_CHANGED_EVENT, changed) + try { + useAppStore.getState().setInstalledExtensions([entry()]) + expect(canvas()).toBe('#123456') + useAppStore.getState().setSettings({ contrast: true }) + expect(canvas()).toBe('#123456') + useAppStore.getState().setInstalledExtensions([entry('#654321')]) + expect(canvas()).toBe('#654321') + useAppStore.getState().setInstalledExtensions([{ ...entry(), present: false }]) + expect(canvas()).toBe('') + expect(document.documentElement.dataset.mode).toBe('dark') + expect(document.documentElement.dataset.contrast).toBe('high') + useAppStore.getState().setInstalledExtensions([]) + expect(useAppStore.getState().settings.mode).toBe(mode) + useAppStore.getState().setInstalledExtensions([entry()]) + expect(canvas()).toBe('#123456') + expect(events).toContain('#654321') + expect(events).toContain('') + expect(useAppStore.getState().settings.savedThemes).toEqual(DEFAULT_SETTINGS.savedThemes) + } finally { window.removeEventListener(THEME_CHANGED_EVENT, changed) } +}) + +it('keeps built-in and user-owned palettes independent of installed contributions', () => { + const saved = { id: 'theme:mine', name: 'Night', json: '{"canvas":"#abcdef"}', createdAt: 0, updatedAt: 0 } + const settings = { ...DEFAULT_SETTINGS, savedThemes: [saved], mode: saved.id } + expect(resolveThemePayload(settings, [entry()])?.canvas).toBe('#abcdef') + expect(resolveThemePayload({ ...settings, mode: 'dark' }, [entry()])).toBeNull() + expect(coerceSettings({ ...settings, mode: 'extension-theme:../escape' }).mode).toBe('dark') +}) + +it('sends resolved appearance to the phone without changing the persisted desktop selection', () => { + const settings = { ...DEFAULT_SETTINGS, mode } + const remote = themeSettingsForRemote(settings, [entry()]) + applyTheme(remote) + expect(canvas()).toBe('#123456') + expect(settings.mode).toBe(mode) + expect(remote.savedThemes).toEqual(settings.savedThemes) + applyTheme(themeSettingsForRemote(settings, [])) + expect(document.documentElement.dataset.mode).toBe('dark') + expect(canvas()).toBe('') +}) + +it('offers the extension theme in the real picker and explains a missing selected bundle', async () => { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + function Picker() { + const settings = useAppStore(state => state.settings) + return useAppStore.getState().setSettings({ mode })} onCreate={() => {}} onEdit={() => {}} onDelete={() => {}} /> + } + try { + await act(async () => { useAppStore.getState().setInstalledExtensions([entry()]); root.render() }) + const button = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Palette Pack'))! + expect(button).toBeDefined() + await act(async () => button.click()) + expect(canvas()).toBe('#123456') + expect(button.getAttribute('aria-pressed')).toBe('true') + expect(container.textContent).not.toContain('Delete') + await act(async () => useAppStore.getState().setInstalledExtensions([])) + expect(container.querySelector('[role="status"]')?.textContent).toContain('unavailable') + expect(useAppStore.getState().settings.mode).toBe(mode) + } finally { await act(async () => root.unmount()); container.remove() } +}) diff --git a/src/renderer/src/app-state/settings/extensionThemes.ts b/src/renderer/src/app-state/settings/extensionThemes.ts new file mode 100644 index 000000000..3aace378a --- /dev/null +++ b/src/renderer/src/app-state/settings/extensionThemes.ts @@ -0,0 +1,21 @@ +import type { ExtensionListEntry } from '@shared/types/extensions' +import { extensionThemeColorsSchema, extensionThemeMode } from '@shared/types/extensionThemes' +import { DEFAULT_CUSTOM_APPEARANCE, type CustomAppearanceColors } from './customAppearance' + +export function installedThemeContributions(entries: readonly ExtensionListEntry[]) { + // Keep this derived from the current catalog. Copying extension palettes into + // savedThemes would sever update/uninstall ownership and let the editor mutate + // data which the next install silently replaces. Only New theme makes a copy. + return entries.filter(entry => entry.present).flatMap(entry => + (entry.manifest.contributes?.themes ?? []).flatMap(theme => { + const parsed = extensionThemeColorsSchema.safeParse(theme.colors) + if (!parsed.success || !theme.id.startsWith(`${entry.manifest.id}.`)) return [] + return [{ ...theme, colors: parsed.data, mode: extensionThemeMode(theme.id), extensionName: entry.manifest.name }] + }), + ) +} + +export function resolveExtensionThemeColors(entries: readonly ExtensionListEntry[], mode: string): CustomAppearanceColors | null { + const theme = installedThemeContributions(entries).find(theme => theme.mode === mode) + return theme ? { ...DEFAULT_CUSTOM_APPEARANCE, ...theme.colors } : null +} diff --git a/src/renderer/src/app-state/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts index aa8d9bf0d..5bcde2523 100644 --- a/src/renderer/src/app-state/settings/persistence.ts +++ b/src/renderer/src/app-state/settings/persistence.ts @@ -17,6 +17,7 @@ import { isV4MigratedTheme, } from '@renderer/app-state/settings/savedThemes' import type { SavedTheme } from '@renderer/app-state/settings/savedThemes' +import { isExtensionThemeMode } from '@shared/types/extensionThemes' import { parseCustomAppearanceJson } from '@renderer/app-state/settings/customAppearance' import { isCommandSortMode } from '@renderer/features/command-palette/lib/sortCommands' import type { CommandSortMode } from '@renderer/features/command-palette/lib/sortCommands' @@ -255,6 +256,9 @@ function resolvePersistedMode( return migrated ? migrated.id : DEFAULT_SETTINGS.mode } if (isBuiltInThemeMode(parsed.mode)) return parsed.mode + // Catalog hydration happens after Settings. Erasing a valid extension id here + // would lose the selected theme on every restart and prevent reinstall restore. + if (isExtensionThemeMode(parsed.mode)) return parsed.mode if (isSavedThemeId(parsed.mode) && findSavedTheme(savedThemes, parsed.mode)) { return parsed.mode } diff --git a/src/renderer/src/app-state/settings/slice.ts b/src/renderer/src/app-state/settings/slice.ts index 62b73bd10..14315a6a0 100644 --- a/src/renderer/src/app-state/settings/slice.ts +++ b/src/renderer/src/app-state/settings/slice.ts @@ -26,7 +26,7 @@ export const createSettingsSlice: StateCreator< setSettings: patch => set(state => { const next = { ...state.settings, ...patch } - applyTheme(next) + applyTheme(next, state.installedExtensions) return { settings: next } }, false, 'settings/setSettings'), resetSettings: () => @@ -50,7 +50,7 @@ export const createSettingsSlice: StateCreator< ...state.settings, showStatusMode: !state.settings.showStatusMode, } - applyTheme(next) + applyTheme(next, state.installedExtensions) return { settings: next } }, false, 'settings/toggleStatusMode'), toggleWorktreeBadges: () => @@ -59,7 +59,7 @@ export const createSettingsSlice: StateCreator< ...state.settings, showWorktreeBadges: !state.settings.showWorktreeBadges, } - applyTheme(next) + applyTheme(next, state.installedExtensions) return { settings: next } }, false, 'settings/toggleWorktreeBadges'), toggleUsageHeader: () => @@ -68,7 +68,7 @@ export const createSettingsSlice: StateCreator< ...state.settings, usageHeaderEnabled: !state.settings.usageHeaderEnabled, } - applyTheme(next) + applyTheme(next, state.installedExtensions) return { settings: next } }, false, 'settings/toggleUsageHeader'), cycleUsageHeaderLevel: () => @@ -85,7 +85,7 @@ export const createSettingsSlice: StateCreator< // command does nothing. usageHeaderEnabled: true, } - applyTheme(next) + applyTheme(next, state.installedExtensions) return { settings: next } }, false, 'settings/cycleUsageHeaderLevel'), }) diff --git a/src/renderer/src/app-state/settings/theme.ts b/src/renderer/src/app-state/settings/theme.ts index 899c8577c..62f9a075d 100644 --- a/src/renderer/src/app-state/settings/theme.ts +++ b/src/renderer/src/app-state/settings/theme.ts @@ -19,6 +19,9 @@ import { resolveSavedThemeColors, } from '@renderer/app-state/settings/savedThemes' import { APP_SLUG } from '@shared/appIdentity' +import type { ExtensionListEntry } from '@shared/types/extensions' +import { isExtensionThemeMode } from '@shared/types/extensionThemes' +import { resolveExtensionThemeColors } from './extensionThemes' // Event name fired on `window` after every applyTheme run. Subscribers // (currently: xterm panes that can't see CSS variables) re-read state @@ -68,7 +71,8 @@ const FALLBACK_APP_FONT_FAMILY = // or a Settings blob synced from a machine with different themes). The safe // degradation is a built-in theme that definitely renders, not a // half-remembered palette the user may never have seen. -export function resolveThemePayload(settings: Settings): CustomAppearanceColors | null { +export function resolveThemePayload(settings: Settings, extensions: readonly ExtensionListEntry[] = []): CustomAppearanceColors | null { + if (isExtensionThemeMode(settings.mode)) return resolveExtensionThemeColors(extensions, settings.mode) if (isSavedThemeId(settings.mode)) { const theme = findSavedTheme(settings.savedThemes, settings.mode) if (!theme) return null @@ -89,7 +93,7 @@ export function resolveThemePayload(settings: Settings): CustomAppearanceColors return null } -export function applyTheme(settings: Settings): void { +export function applyTheme(settings: Settings, extensions: readonly ExtensionListEntry[] = []): void { // Renderer capability registries are also consumed by Node-side replay, // policy, and transcript tests. A presentational component may therefore // pull the settings store into a graph that has no DOM. Theme application @@ -98,7 +102,7 @@ export function applyTheme(settings: Settings): void { // forcing each pure consumer to mock `document` merely to inspect policy. if (typeof document === 'undefined' || typeof window === 'undefined') return const root = document.documentElement - const payload = resolveThemePayload(settings) + const payload = resolveThemePayload(settings, extensions) // WHY data-mode is never allowed to hold a raw `theme:`: // // With a payload, 'custom' is written because the 81 inline properties fully @@ -163,6 +167,17 @@ export function applyTheme(settings: Settings): void { window.dispatchEvent(new CustomEvent(THEME_CHANGED_EVENT, { detail: settings })) } +export function themeSettingsForRemote(settings: Settings, extensions: readonly ExtensionListEntry[]): Settings { + if (!isExtensionThemeMode(settings.mode)) return settings + const colors = resolveThemePayload(settings, extensions) + // The phone has no installed extension catalog. Send concrete presentation + // values through its existing custom-theme path without persisting a copy or + // replacing the desktop's stable selection. Missing bundles use the same + // built-in fallback on both clients until their catalog contribution returns. + return colors ? { ...settings, mode: 'custom', customAppearanceJson: JSON.stringify(colors) } + : { ...settings, mode: DEFAULT_SETTINGS.mode } +} + function applyCustomAppearance(root: HTMLElement, colors: CustomAppearanceColors): void { // WHY custom mode writes every color token inline instead of generating a // stylesheet block: the built-in themes are static CSS keyed by diff --git a/src/renderer/src/app-state/settings/types.ts b/src/renderer/src/app-state/settings/types.ts index c944f94be..ccd661337 100644 --- a/src/renderer/src/app-state/settings/types.ts +++ b/src/renderer/src/app-state/settings/types.ts @@ -21,7 +21,7 @@ export type ThemeMode = | 'light-soft' // What `Settings.mode` may actually hold: a built-in id, or a `theme:` -// saved-theme id. Kept as a distinct alias so the many call sites that only +// saved-theme id or `extension-theme:`. Kept as a distinct alias so the many call sites that only // ever deal with built-ins can keep using the narrower ThemeMode. export type ThemeModeValue = ThemeMode | string @@ -155,7 +155,7 @@ export const AGENT_VIEW_MODES: AgentViewModeMeta[] = [ * approximation is not. * * WHY this is a top-level setting and not part of customAppearance: that - * contract is CUSTOM_APPEARANCE_COLOR_KEYS — 80 keys, all colours, validated + * contract is CUSTOM_APPEARANCE_COLOR_KEYS — 81 keys, all colours, validated * as colours and surfaced in the theme editor as colour fields. Threading * three lengths through a colour pipeline means either weakening that * validation or special-casing three keys in every consumer, to buy an axis @@ -327,7 +327,7 @@ export const USAGE_HEADER_LEVELS = ['minimal', 'providers', 'all', 'detailed'] a export type UsageHeaderLevel = (typeof USAGE_HEADER_LEVELS)[number] export type Settings = { - /** Built-in theme id, or the id of an entry in `savedThemes`. One field + /** Built-in theme id, saved theme id, or namespaced extension theme id. One field * answers "what am I looking at" for applyTheme, useThemeSync, and the * paired phone client alike — see savedThemes.ts for why this is not a * separate `activeSavedThemeId`. */ diff --git a/src/renderer/src/app-state/types.ts b/src/renderer/src/app-state/types.ts index 047371932..3d8bcf9b0 100644 --- a/src/renderer/src/app-state/types.ts +++ b/src/renderer/src/app-state/types.ts @@ -8,6 +8,8 @@ import type { import type { SessionId, TabId } from '@renderer/workspace/types' import type { WorkspaceState } from '@renderer/workspace/types' import type { SessionRuntime } from '@renderer/session-runtime/state' +import type { ExtensionListEntry } from '@shared/types/extensions' +import type { ExtensionFailure } from '@renderer/apps/types' import type { ReaderModeState, SpotlightState, @@ -134,6 +136,11 @@ export type UiShellSlice = UiShellState & { closeKeyVault: () => void openRewindPrompt: (sessionId: SessionId) => void closeRewindPrompt: () => void + openApp: (appId: string) => void + closeApp: () => void + setInstalledExtensions: (entries: ExtensionListEntry[]) => void + setInstalledExtensionsError: (error: string | null) => void + setExtensionFailures: (failures: ExtensionFailure[]) => void openAgentViewModePicker: (sessionId: SessionId) => void closeAgentViewModePicker: () => void openColorFlagPicker: (sessionId: SessionId) => void diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index 43712e622..f9962bf2b 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -1,6 +1,7 @@ import { DEFAULT_PALETTE_MODE } from '@renderer/features/command-palette/paletteMode' import type { PaletteMode } from '@renderer/features/command-palette/paletteMode' import type { StateCreator } from 'zustand' +import { applyTheme } from '@renderer/app-state/settings/theme' import type { AppStore, UiShellSlice } from '@renderer/app-state/types' import type { PendingCommandInvocation } from '@renderer/app-state/uiShell/types' @@ -10,7 +11,7 @@ export const createUiShellSlice: StateCreator< [['zustand/devtools', never], ['zustand/subscribeWithSelector', never]], [], UiShellSlice -> = set => ({ +> = (set, get) => ({ commandPaletteOpen: false, paletteMode: DEFAULT_PALETTE_MODE, pathPickerOpen: false, @@ -60,6 +61,14 @@ export const createUiShellSlice: StateCreator< providerSwitchPickerSessionId: null, rewindPromptSessionId: null, agentViewModePickerSessionId: null, + openAppId: null, + installedExtensions: [], + // False until the first SUCCESSFUL extensionsList(). Distinguishes "no extensions" + // from "not asked yet", which the pane leaf needs to avoid claiming an installed + // extension is missing during the async gap on every reload. + installedExtensionsLoaded: false, + installedExtensionsError: null, + extensionFailures: [], colorFlagPickerSessionId: null, // Default keeps the dispatch list at 25% (matching the // previous-hardcoded `basis-1/4`) so the migration is visually a @@ -371,6 +380,24 @@ export const createUiShellSlice: StateCreator< closeRewindPrompt: () => set({ rewindPromptSessionId: null }, false, 'uiShell/closeRewindPrompt'), + openApp: appId => set({ openAppId: appId }, false, 'uiShell/openApp'), + closeApp: () => set({ openAppId: null }, false, 'uiShell/closeApp'), + + setInstalledExtensions: entries => { + set( + { installedExtensions: entries, installedExtensionsLoaded: true, installedExtensionsError: null }, + false, + 'uiShell/setInstalledExtensions', + ) + // Reconcile immediately on install/update/remove. A selected extension + // palette must never linger until the user changes an unrelated setting. + applyTheme(get().settings, entries) + }, + setInstalledExtensionsError: error => + set({ installedExtensionsError: error }, false, 'uiShell/setInstalledExtensionsError'), + setExtensionFailures: failures => + set({ extensionFailures: failures }, false, 'uiShell/setExtensionFailures'), + openAgentViewModePicker: sessionId => set( { agentViewModePickerSessionId: sessionId }, diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts index 832dad867..5b2642123 100644 --- a/src/renderer/src/app-state/uiShell/types.ts +++ b/src/renderer/src/app-state/uiShell/types.ts @@ -1,5 +1,7 @@ import type { PaletteMode } from '@renderer/features/command-palette/paletteMode' import type { TabId, SessionId } from '@renderer/workspace/types' +import type { ExtensionListEntry } from '@shared/types/extensions' +import type { ExtensionFailure } from '@renderer/apps/types' export type DispatchAttachIntent = { sessionId: SessionId @@ -361,6 +363,50 @@ export type UiShellState = { * session. */ agentViewModePickerSessionId: SessionId | null + /** + * Non-null when a built-in app is open; the value is its `AppDefinition` id. + * + * WHY one nullable id rather than one boolean per app: apps are mutually + * exclusive by construction — a single host surface renders one at a time — and + * N booleans would permit two to be true, a state the host physically cannot + * express. Same shape and same reasoning as `rewindPromptSessionId` above. + * + * WHY this is a plain string and not a branded id like SessionId: the value comes + * from a compile-time registry today but from a manifest on disk in Stage 2, and + * a brand would have to be cast away at exactly the boundary where validation + * actually matters. `AppHostSurface` resolves it through `APP_BY_ID` and treats a + * miss as closed, which is the real check. + * + * WHY uiShell (in-memory) and never persisted Settings: an app left open across a + * restart is not desirable, and more importantly extension-adjacent data must not + * enter the zustand-persist blob — app-state/store.ts records that a forgotten + * version bump there black-screened launch twice (#249). + */ + openAppId: string | null + /** + * Installed extensions, as reported by main's ledger. + * + * WHY the store and not a module-scope array: the palette, Settings and the + * view host must all re-render when an extension is installed or removed, and + * a module variable cannot notify them. The previous static `APPS` array was + * exactly that mistake — an adversarial audit found it, along with the stale + * `APP_BY_ID` map built beside it. + * + * Holds the MANIFESTS, not loaded modules. Contributions are declared, so this + * is enough to populate the palette and Settings without importing a single + * extension bundle. + */ + installedExtensions: ExtensionListEntry[] + /** True once extensionsList() has succeeded at least once. */ + installedExtensionsLoaded: boolean + /** A failed load is different from both an empty catalog and a pending load. */ + installedExtensionsError: string | null + /** Extensions whose frame failed to boot, or whose module threw during import + * or activate(). Reported BY THE FRAME over postMessage and collected by + * viewBridge — the extension does not run in this realm, so the host cannot + * observe the throw directly. Surfaced in Settings rather than hidden, because + * a silently-missing extension is undiagnosable for whoever installed it. */ + extensionFailures: ExtensionFailure[] /** Session whose Dispatch color-flag picker modal is open, or null. */ colorFlagPickerSessionId: SessionId | null /** Splitter ratio between the dispatch agent list and the active diff --git a/src/renderer/src/app/main.tsx b/src/renderer/src/app/main.tsx index 4f0b0a13b..621f7076c 100644 --- a/src/renderer/src/app/main.tsx +++ b/src/renderer/src/app/main.tsx @@ -16,6 +16,7 @@ import { AppErrorBoundary } from '@renderer/app/AppErrorBoundary' import { WorkflowClientProvider } from '@renderer/features/workflows/client/WorkflowClientContext' import { ipcWorkflowClient } from '@renderer/features/workflows/client/IpcWorkflowClient' import { startRendererFreezeHeartbeat } from '@renderer/performance/freezeHeartbeat' +import { InstalledExtensionsLoader } from '@renderer/apps/host/InstalledExtensionsLoader' void initializePerformance().then(() => { mark('app.renderer.reactRenderCalled') @@ -86,8 +87,16 @@ createRoot(document.getElementById('root')!).render( + {/* INSIDE AppErrorBoundary, not wrapped around it. The previous + placement put the loader OUTSIDE the boundary while its comment + claimed the opposite, so a throw during its render would have + blanked the whole app instead of being caught. It reads the store + and runs one IPC effect, so there is nothing it needs from being + higher in the tree. */} - + + + diff --git a/src/renderer/src/app/surfaces/GlobalModals.tsx b/src/renderer/src/app/surfaces/GlobalModals.tsx index eb2816bd2..858d09dd0 100644 --- a/src/renderer/src/app/surfaces/GlobalModals.tsx +++ b/src/renderer/src/app/surfaces/GlobalModals.tsx @@ -1,9 +1,14 @@ import { modalSurfaces } from './registry' +import { sortSurfacesByLayer } from './types' export function GlobalModals() { + // Sorted by layer before render. First-party entries omit `layer`, so the stable + // sort leaves their documented order untouched; an extension-contributed surface + // (EXTENSION_SURFACE_LAYER) is lifted into its own band above the first-party + // stack instead of tie-breaking into it. See SurfaceEntry.layer. return ( <> - {modalSurfaces.map(entry => ( + {sortSurfacesByLayer(modalSurfaces).map(entry => ( ))} diff --git a/src/renderer/src/app/surfaces/registry.tsx b/src/renderer/src/app/surfaces/registry.tsx index 8243847cb..8d01af9fe 100644 --- a/src/renderer/src/app/surfaces/registry.tsx +++ b/src/renderer/src/app/surfaces/registry.tsx @@ -30,6 +30,7 @@ import { ColorFlagPickerSurface } from '@renderer/features/workspace/surfaces/Co import { KeyboardShortcutsSurface } from '@renderer/features/settings/surfaces/KeyboardShortcutsSurface' import { RewindToPromptSurface } from '@renderer/features/workspace/surfaces/RewindToPromptSurface' import { AgentTitlePromptSurface } from '@renderer/features/workspace/surfaces/AgentTitlePromptSurface' +import { AppHostSurface } from '@renderer/apps/surfaces/AppHostSurface' import { ProviderSwitchPickerSurface } from '@renderer/features/workspace/surfaces/ProviderSwitchPickerSurface' import { KeyVaultModalSurface } from '@renderer/features/key-vault/surfaces/KeyVaultModalSurface' import { NewAgentInSurface } from '@renderer/features/workspace/surfaces/NewAgentInSurface' @@ -107,6 +108,13 @@ export const modalSurfaces: SurfaceEntry[] = [ // Appended per the contract above. Opened only from a session command that // closes the palette first, so it stacks over established modals by order. { id: 'tldr-history', Component: TldrHistorySurface }, + // Built-in apps host. Last in the array, which per the paint-order contract + // above means it paints above every modal already mounted. That placement is + // reasoned, not defaulted: an app is always user-initiated from the palette and + // is the thing awaiting input for as long as it is open, so nothing already on + // screen has a claim to cover it. No app has a reason to sit *under* another + // modal — if one ever does, that is a signal it should not be an app. + { id: 'app-host', Component: AppHostSurface }, ] /** diff --git a/src/renderer/src/app/surfaces/types.ts b/src/renderer/src/app/surfaces/types.ts index d89b2dcd2..3e21530fa 100644 --- a/src/renderer/src/app/surfaces/types.ts +++ b/src/renderer/src/app/surfaces/types.ts @@ -24,4 +24,30 @@ export type SurfaceEntry = { * props by design — props would put App back in the wiring business. */ Component: ComponentType + /** + * Paint band. Entries are stably sorted by `layer` (default 0) before render, + * so within a band the array order still decides sibling/paint order exactly as + * before — every first-party entry omits `layer` and keeps its documented + * position. The field exists so a NON-first-party surface (an extension one, + * WS7) can be given a distinct band it cannot escape: it can never tie-break + * into the first-party z-50 stack and silently reorder it, which is the exact + * class of bug PR #505 hit. First-party entries should not set it. + */ + layer?: number +} + +/** + * The band all extension-contributed surfaces sit in — above the first-party stack, + * so an extension surface always paints over app chrome (it is user-initiated and + * awaiting input) but cannot reorder first-party surfaces among themselves. + */ +export const EXTENSION_SURFACE_LAYER = 100 + +/** Stable sort by layer. First-party entries (layer undefined → 0) keep their exact + * authored order; only cross-band ordering is imposed. */ +export function sortSurfacesByLayer(entries: readonly SurfaceEntry[]): SurfaceEntry[] { + return entries + .map((entry, index) => ({ entry, index })) + .sort((a, b) => (a.entry.layer ?? 0) - (b.entry.layer ?? 0) || a.index - b.index) + .map(({ entry }) => entry) } diff --git a/src/renderer/src/apps/api/createAppHostApi.ts b/src/renderer/src/apps/api/createAppHostApi.ts new file mode 100644 index 000000000..fcc926227 --- /dev/null +++ b/src/renderer/src/apps/api/createAppHostApi.ts @@ -0,0 +1,161 @@ +import { useAppStore } from '@renderer/app-state/hooks' +// This API needs the tree traversal, not the workspace hook's compatibility +// barrel. That barrel imports the complete provider/editor UI and makes a +// standalone extension host initialize unrelated application modules. +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' + +import type { AgentCodeApiV1, JsonValue } from '@renderer/apps/api/types' +import { CUSTOM_APPEARANCE_CSS_VARS } from '@renderer/app-state/settings/customAppearance' + +// Discovering which `--theme-*` custom properties exist requires walking every +// stylesheet rule, which is O(all CSS in the app). The NAMES are fixed at build +// time — a theme change rewrites values on :root, it never invents a new token — +// so the expensive half runs once per renderer lifetime and only the cheap +// getPropertyValue lookups repeat. +let cachedTokenNames: string[] | null = null + +// Tokens the host assigns at RUNTIME via root.style.setProperty, which therefore +// appear in NO stylesheet — walking document.styleSheets cannot find them however +// thorough the walk. Source of truth: app-state/settings/theme.ts (applyTheme). +// There is no automated link between the two; this comment is the link. +const RUNTIME_ONLY_TOKENS = [ + '--theme-accent', + '--theme-accent-fg', + '--theme-app-font', + '--theme-font-code', +] as const + +function themeTokenNames(): string[] { + if (cachedTokenNames) return cachedTokenNames + + // A selected contributed palette can be applied inline before any stylesheet + // declares its tokens. Seed the canonical appearance vocabulary rather than + // letting first-call stylesheet timing permanently omit those colors from a + // cached API snapshot. The scan still discovers additional non-color tokens. + const names = new Set([...RUNTIME_ONLY_TOKENS, ...Object.values(CUSTOM_APPEARANCE_CSS_VARS)]) + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList + try { + rules = sheet.cssRules + } catch { + // Cross-origin stylesheet — the Google Fonts @import in styles.css is one. + // Reading .cssRules throws SecurityError. Nothing we need is in there. + continue + } + for (const rule of Array.from(rules)) { + if (!(rule instanceof CSSStyleRule)) continue + for (const prop of Array.from(rule.style)) { + if (prop.startsWith('--theme-')) names.add(prop) + } + } + } + + cachedTokenNames = Array.from(names).sort() + return cachedTokenNames +} + +export type AppHostApiDeps = { + extensionId: string + showToast: (message: string) => void + /** Closes whatever surface is currently hosting this extension. */ + closeSurface: () => void +} + +/** + * Builds the `AgentCodeApiV1` instance handed to one extension. + * + * WHY a plain factory rather than the hook it started as: `ExtensionHost` needs + * to construct an API for an extension at ACTIVATION time, which can happen from + * a palette command with no component mounted — and a hook cannot be called + * there. The React-context dependencies (toast, close) are injected instead, so + * the same factory serves both the host and any component that needs one. + * + * WHY extensionId is closed over rather than passed per call: an extension must + * not be able to name a different extension's storage namespace. In this stage + * that is enforced by this closure being the only call site of + * window.api.extensionStorage*. It becomes properly enforceable when each + * extension gets its own frame and main derives the id from the sender. + */ +export function createAppHostApi(deps: AppHostApiDeps): AgentCodeApiV1 { + const { extensionId, showToast, closeSurface } = deps + + return { + extension: { id: extensionId, apiVersion: 1 }, + + storage: { + get: async (key: string): Promise => + (await window.api.extensionStorageGet(extensionId, key)) as T | undefined, + set: (key: string, value: JsonValue) => + window.api.extensionStorageSet(extensionId, key, value), + delete: (key: string) => window.api.extensionStorageDelete(extensionId, key), + keys: () => window.api.extensionStorageKeys(extensionId), + }, + + ui: { + // Async despite being synchronous internally — see the ABI's note on why + // no method here may be declared sync, however obviously sync it is. + close: async () => { + closeSurface() + }, + showToast: async (message: string) => { + showToast(message) + }, + }, + + theme: { + tokens: async () => { + const style = getComputedStyle(document.documentElement) + const out: Record = {} + for (const name of themeTokenNames()) { + const value = style.getPropertyValue(name).trim() + // Skip empties rather than emitting `'--theme-x': ''`. A caller pushing + // these into a frame would otherwise set variables to the empty string, + // which resolves as invalid and defeats the consumer's own fallback. + if (value) out[name] = value + } + return out + }, + }, + + // Tier-1 observe reads. These implementations are UNGATED here on purpose — + // the grant check lives in the frame broker (frameHost.perform), the single + // trusted chokepoint every frame request passes through. Curated, serializable + // snapshots only: never the live store objects, which hold renderer handles. + workspace: { + observe: async () => { + const ws = useAppStore.getState().workspaceState + return { + activeTabId: ws.activeTabId ?? null, + tabIds: ws.tabs.map(tab => tab.id), + sessionCount: Object.keys(ws.sessions).length, + } + }, + // Real subscription for a same-realm caller. In the FRAME model the extension + // calls the child's own subscribe (frameDocument), driven by the host's change + // nudge — this impl is what satisfies the contract and would serve a same-realm + // consumer; it is never reached through the broker (subscribe is not a request). + subscribe: listener => useAppStore.subscribe(s => s.workspaceState, () => listener()), + }, + + sessions: { + observe: async () => + Object.entries(useAppStore.getState().workspaceState.sessions).map(([id, meta]) => ({ + id, + kind: meta.kind ?? null, + cwd: meta.cwd, + title: meta.title ?? null, + })), + subscribe: listener => + useAppStore.subscribe(s => s.workspaceState.sessions, () => listener()), + }, + + panes: { + observe: async () => + useAppStore.getState().workspaceState.tabs.map(tab => ({ + tabId: tab.id, + leafSessionIds: [...collectLeaves(tab.root)], + })), + subscribe: listener => useAppStore.subscribe(s => s.workspaceState.tabs, () => listener()), + }, + } +} diff --git a/src/renderer/src/apps/api/types.ts b/src/renderer/src/apps/api/types.ts new file mode 100644 index 000000000..2b0f1d63e --- /dev/null +++ b/src/renderer/src/apps/api/types.ts @@ -0,0 +1,145 @@ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue } + +// --- Tier-1 observe snapshots ------------------------------------------------ +// Curated, fully-serializable projections of host state — NOT the live store +// objects, which carry renderer-only handles and would not survive the frame +// boundary. These are point-in-time reads; live updates arrive over the push +// channel (Group D). Kept deliberately minimal: an extension gets identity and +// shape, not the host's internal runtime. + +export type ExtensionWorkspaceSnapshot = { + activeTabId: string | null + tabIds: string[] + sessionCount: number +} + +export type ExtensionSessionSnapshot = { + id: string + /** Provider/terminal/extension-view kind, or null if unset. */ + kind: string | null + cwd: string + title: string | null +} + +export type ExtensionPaneSnapshot = { + tabId: string + /** Session ids of the leaves in this tab's tile tree, in tree order. */ + leafSessionIds: string[] +} + +/** + * The Agent Code app host API, version 1. + * + * WHY this exists in a stage where apps are compiled into the renderer and could + * simply `import { useAppStore }`: this object IS the migration boundary. An app + * that talks only through it can be lifted into its own repository and loaded at + * runtime — through a custom scheme or an iframe — with no edits inside it. An app + * that reaches into `@renderer/*` cannot, and the cost of discovering that is a + * simultaneous rewrite of every app that exists. The rule is binary and checkable + * with one grep, which is the only kind of architectural rule that survives contact + * with a codebase this size. + * + * WHY every method returns a Promise, including ones that could be synchronous + * today: under a future postMessage transport nothing can be synchronous, and a + * signature cannot be widened from `void` to `Promise` later without touching + * every call site in every app. It costs nothing now. Of everything in this design + * this is the single highest-value forward-compatibility decision, and it is also + * the easiest one to lose by accident — if you are tempted to make something here + * synchronous "because it obviously is," that is the temptation this comment exists + * to stop. + * + * WHY the surface is this small: it is Tier 0 — everything an app may have without + * asking anyone's permission. Workspace, session, transcript, git, filesystem and + * network access are Tiers 1-3, each gated behind a manifest capability and a + * consent flow that do not exist yet. Adding one before a real app needs it means + * guessing a contract with no consumer to validate it against, and a wrong guess in + * a versioned ABI is far more expensive than a late one. + * + * Versioning: this interface is frozen once an app outside this repo depends on it. + * A v2 is a new `AgentCodeApiV2` built from the same internals with `createHostV1` + * kept alongside it, dispatched on a manifest `apiVersion` — not an edit to this + * file. That is roughly thirty lines and is the whole reason the surface is one + * object rather than 153 flat methods. + */ +export interface AgentCodeApiV1 { + readonly extension: { + /** This app's id. Matches its AppDefinition id and its storage namespace. */ + readonly id: string + readonly apiVersion: 1 + } + + readonly storage: { + get(key: string): Promise + set(key: string, value: JsonValue): Promise + delete(key: string): Promise + keys(): Promise + } + + readonly ui: { + /** Close this app's view. */ + close(): Promise + /** + * Transient app-wide toast. Deliberately not an OS notification — a background + * app that can raise system notifications is a Tier 3 capability, because it + * can interrupt the user while they are working in another application. + */ + showToast(message: string): Promise + } + + readonly theme: { + /** + * Resolved `--theme-*` custom properties, e.g. `{ '--theme-surface': '#111113' }`. + * + * Apps should prefer plain CSS — `background: var(--theme-surface)` — which + * cascades for free today and keeps working unchanged across a frame boundary + * once the host pushes the same variables into the child document. This + * accessor exists only for imperative consumers that cannot use CSS: canvas + * drawing, inline SVG fill computation, chart libraries. + * + * Only the `--theme-*` layer is exposed, never the `--color-*` Tailwind binding + * layer. The former is a stable contract; the latter is an implementation + * detail of how the app's utilities are wired, and depending on it would couple + * an app to the host's build system — exactly what portability forbids. + */ + tokens(): Promise> + } + + // --- Tier 1 — read-only metadata (capability-gated) ------------------------ + // These require a granted manifest permission (workspace.observe / + // sessions.observe / panes.observe). The frame broker denies the call with an + // error if the grant is absent, so an extension that did not request the + // capability never reaches these. Snapshots today; a live subscription is a + // Group-D addition, not a signature change here. + + readonly workspace: { + /** Point-in-time workspace shape. Requires `workspace.observe`. */ + observe(): Promise + /** + * Fire `listener` whenever the workspace changes; returns an unsubscribe. + * The listener receives no argument — call observe() to read fresh state. This + * is the live half of observe: the host pushes a change nudge, the extension + * re-reads. Requires `workspace.observe` (the re-read is what's gated). + */ + subscribe(listener: () => void): () => void + } + + readonly sessions: { + /** All sessions' identity/shape. Requires `sessions.observe`. */ + observe(): Promise + /** Fire on any session change; returns an unsubscribe. Re-read via observe(). */ + subscribe(listener: () => void): () => void + } + + readonly panes: { + /** The tile layout as leaf ids per tab. Requires `panes.observe`. */ + observe(): Promise + /** Fire on any pane-layout change; returns an unsubscribe. Re-read via observe(). */ + subscribe(listener: () => void): () => void + } +} diff --git a/src/renderer/src/apps/host/InstalledExtensionsLoader.tsx b/src/renderer/src/apps/host/InstalledExtensionsLoader.tsx new file mode 100644 index 000000000..f6170c0e7 --- /dev/null +++ b/src/renderer/src/apps/host/InstalledExtensionsLoader.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react' + +import { acceptExtensionPublication, refreshInstalledExtensions } from './installedExtensionsState' + +/** + * Subscribe before loading the first catalog so an install in another window + * cannot fall between the initial read and listener registration. The catalog + * contains declarations only; extension code still executes across its sandbox + * boundary. Keeping one store also makes Settings and live panes agree. + */ +export function InstalledExtensionsLoader({ children }: { children: React.ReactNode }) { + useEffect(() => { + const unsubscribe = window.api.onExtensionsChanged(rows => { + acceptExtensionPublication(rows) + void refreshInstalledExtensions() + }) + void refreshInstalledExtensions() + return unsubscribe + }, []) + return <>{children} +} diff --git a/src/renderer/src/apps/host/derive.ts b/src/renderer/src/apps/host/derive.ts new file mode 100644 index 000000000..2d9821976 --- /dev/null +++ b/src/renderer/src/apps/host/derive.ts @@ -0,0 +1,207 @@ +import type { CommandDef } from '@renderer/features/command-palette/types' +import type { AppDefinition } from '@renderer/apps/types' +import { viewComponentFor } from '@renderer/apps/host/viewBridge' +import { dispatchToFrame, queuePendingCommand } from '@renderer/apps/host/frameRegistry' +import type { CommandBindingDefault } from '@renderer/features/command-keybindings/defaults' +import { tryNormalizeKeybinding } from '@renderer/features/command-keybindings/normalize' +import type { Keybinding } from '@renderer/features/command-keybindings/normalize' +import { extensionRevision, type ExtensionListEntry } from '@shared/types/extensions' + +// Turning DECLARATIONS into app surfaces. +// +// The load-bearing property of everything here is that it reads the MANIFEST, +// never a loaded module. That is what makes lazy activation real: the palette +// lists an extension's commands and the app registry knows about its views while +// not a single extension bundle has been imported. Importing happens on first +// use, inside its own frame. +// +// Cross-extension id collisions are resolved here rather than at install: neither +// author can see the other's manifest, so failing the install would punish +// whoever happened to install second. First wins, and the loser is dropped +// rather than silently shadowing. + +/** `AppDefinition`s for every contributed view. */ +export function deriveAppDefinitions(installed: ExtensionListEntry[]): AppDefinition[] { + const seen = new Set() + const definitions: AppDefinition[] = [] + + for (const entry of installed) { + if (!entry.present) continue + for (const view of entry.manifest.contributes?.views ?? []) { + if (seen.has(view.id)) continue + seen.add(view.id) + definitions.push({ + id: view.id, + title: view.title, + // A view has no description of its own; the extension's is the honest + // fallback and is what the palette shows under the command. + description: entry.manifest.description, + keywords: entry.manifest.keywords, + Component: viewComponentFor(entry, view.id), + }) + } + } + + return definitions +} + +/** + * `CommandDef`s for every contributed command. + * + * A command whose id matches a contributed VIEW id opens that view; anything + * else dispatches to the extension's registered handler. That mapping is why the + * timer can declare `timer.open` with no handler — opening a declared view is + * the host's job, and a handler whose only body is "show my own view" would be a + * worse version of the host's own routing. + */ +export function deriveExtensionCommands( + installed: ExtensionListEntry[], + openApp: (appId: string) => void, + // Opens a contributed view as a PANE (a tile leaf) instead of a modal. A view + // whose manifest `mount` is 'panel' routes here; 'modal' routes to openApp. Made + // optional with a no-op default so the Settings call sites that only LIST commands + // stay a 3-arg call — the routing still resolves there, it just never fires. + openInPane: (viewId: string) => void = () => {}, +): CommandDef[] { + const seen = new Set() + const commands: CommandDef[] = [] + + for (const entry of installed) { + if (!entry.present) continue + const views = entry.manifest.contributes?.views ?? [] + const viewIds = new Set(views.map(view => view.id)) + // Where each view wants to render, so an "open" command targets the right host + // shell — a modal or a pane — from the same declaration. + const viewMountById = new Map(views.map(view => [view.id, view.mount])) + + for (const command of entry.manifest.contributes?.commands ?? []) { + if (seen.has(command.id)) continue + seen.add(command.id) + + // `timer.open` -> view `timer.main`: an extension's "open" command with no + // matching view id still needs a target, so a single-view extension gets + // its only view. More than one view means the author has to be explicit, + // and the command simply dispatches to a handler instead. + const onlyView = viewIds.size === 1 ? [...viewIds][0] : undefined + const targetView = viewIds.has(command.id) + ? command.id + : command.id.endsWith('.open') && onlyView + ? onlyView + : undefined + + commands.push({ + id: command.id, + title: command.title, + // buildCommandRegistry throws on a blank description, so the manifest's + // extension-level description is the fallback rather than an empty + // string — a missing description would be a launch crash. + description: command.description?.trim() || entry.manifest.description.trim() || entry.manifest.name, + // 'app': extensions are mode-independent. They have no relationship to + // the tile tree or to Dispatch, so hiding them in either mode would be + // wrong. + surface: 'app', + // Assigned HERE, not by each consumer. Two Settings surfaces used to + // re-tag these with `.map(c => ({...c, category: 'extensions'}))` after + // calling this function, and the command palette did not — so the same + // command grouped under "Extensions" in the keybinding editor and under + // "Other" in the palette, and sortCommands carried a comment asserting + // extension commands "have no way to declare one at all". The manifest + // still cannot set a category, which is the point: a third party must not + // be able to file itself under a first-party heading. The HOST assigns it, + // and one assignment site is what makes every consumer agree. + category: 'extensions', + keywords: command.keywords ?? [], + run: ({ ui }) => { + // A command that maps to a view is an "open" command — opening a declared + // view is the host's job, not a handler's (an extension declaring a command + // whose only body is "show my own view" would be a worse version of this). + // The view's declared mount decides WHICH host shell opens it. + if (targetView) { + if (viewMountById.get(targetView) === 'panel') openInPane(targetView) + else openApp(targetView) + ui.closePalette() + return + } + if (entry.manifest.apiVersion === 2) { + // Main owns the engine independently of visible panes. Return its + // acknowledgement to the command gateway so errors are visible and + // a cold command neither opens a view nor runs a second activation. + ui.closePalette() + return window.api.extensionsRuntimeCommand(entry.manifest.id, extensionRevision(entry), command.id).then(() => {}) + } + // Legacy action command. It runs inside the extension's live frame — the ONLY + // place the extension executes now that host-realm activation is gone. If a + // frame is open, dispatch straight in. If not, open the extension's single + // view to bring a frame up and QUEUE the command to fire the instant that + // frame signals ready — so a cold "timer.start" opens the timer and starts + // it in one action. With no view to open, the action cannot run yet; the + // background-frame follow-up (Group A, A4) removes that last limitation. + const extensionId = entry.manifest.id + if (!dispatchToFrame(extensionId, command.id)) { + if (onlyView) { + queuePendingCommand(extensionId, command.id) + // Honour the view's DECLARED mount, exactly as the targetView branch + // above does. This called openApp() unconditionally, so a panel-only + // extension's action command opened it as a floating modal — directly + // contradicting its own manifest — because deriveAppDefinitions builds + // an AppDefinition for every view regardless of declared mount. + if (viewMountById.get(onlyView) === 'panel') openInPane(onlyView) + else openApp(onlyView) + } + } + ui.closePalette() + }, + }) + } + } + + return commands +} + +/** + * `CommandBindingDefault`s for every contributed keybinding. + * + * These are DEFAULTS, concatenated onto `buildDefaultKeybindings()` at every + * `resolveEffectiveKeybindings` call site — a user override in + * `commandKeybindingOverrides` still wins, exactly like a first-party default. + * Reads manifests only (no bundle import), the sibling of `deriveExtensionCommands`. + * + * Three deliberate choices: + * - The manifest `key` is freeform text; the resolver needs the canonical + * `Keybinding` form. `tryNormalizeKeybinding` returns null on garbage so one + * malformed manifest key is dropped rather than throwing the whole default table. + * - Several `{command, key}` entries for one command collapse into that command's + * `bindings` array — the multi-chord case the resolver already models. + * - Context is always 'global'. The manifest declares none, and 'global' is the + * strictest for collision-checking (it overlaps every context), so an extension + * binding errs toward being reported as a conflict rather than silently + * shadowing a contextual first-party chord. The reservation check that consumes + * these is what actually lets first-party win; see the WS2 wiring. + */ +export function deriveExtensionKeybindings( + installed: ExtensionListEntry[], +): CommandBindingDefault[] { + const byCommand = new Map() + const order: string[] = [] + + for (const entry of installed) { + if (!entry.present) continue + for (const binding of entry.manifest.contributes?.keybindings ?? []) { + const chord = tryNormalizeKeybinding(binding.key) + if (!chord) continue + let chords = byCommand.get(binding.command) + if (chords === undefined) { + chords = [] + byCommand.set(binding.command, chords) + order.push(binding.command) + } + if (!chords.includes(chord)) chords.push(chord) + } + } + + return order.map(commandId => ({ + commandId, + bindings: byCommand.get(commandId)!, + context: 'global' as const, + })) +} diff --git a/src/renderer/src/apps/host/frameHost.ts b/src/renderer/src/apps/host/frameHost.ts new file mode 100644 index 000000000..626f6a68c --- /dev/null +++ b/src/renderer/src/apps/host/frameHost.ts @@ -0,0 +1,231 @@ +import type { AgentCodeApiV1, JsonValue } from '@renderer/apps/api/types' +import { + extensionIdFromOrigin, + frameRequestEnvelopeSchema, + type FramePush, + type FrameReply, + type FrameRequest, +} from '@renderer/apps/host/frameProtocol' +import type { ExtensionCapability } from '@shared/types/extensions' +import { extensionRevision } from '@shared/types/extensions' +import { useAppStore } from '@renderer/app-state/hooks' + +// The trusted host-side broker for one extension frame (WS4, Decision A). +// +// It is the ONLY thing that both (a) can perform a capability and (b) can hear the +// frame. The extension iframe holds no capability; it posts a request, this broker +// verifies the request came from that exact frame at the expected origin, performs +// it against the real AgentCodeApiV1, and posts back a reply. Every trust decision +// lives here, in the parent realm, never in the child. +// +// The `api` passed in is the same `createAppHostApi(...)` instance the same-realm +// path uses, so storage/ui/theme behave identically across the boundary and there +// is exactly one implementation of each capability to audit. + +export type FrameHostHandle = { + /** Push theme tokens (or a mount command) into the child. */ + push: (message: FramePush) => void + /** + * The capabilities granted to this frame's extension, resolved once. + * + * Exposed so the view bridge can gate its observe subscription on the SAME + * promise the broker gates requests on. It previously made its own + * `extensionGrantedCapabilities` call, which meant every frame open recomputed + * the bundle hash TWICE in the main process — and the IPC handler's comment + * claimed it happened once, citing this cache. + */ + granted: Promise + /** Detach the listener. Idempotent. */ + dispose: () => void +} + +/** Compile-time proof that every FrameRequest method is handled. */ +function assertNeverMethod(request: never): never { + throw new Error(`unhandled frame request method: ${JSON.stringify(request)}`) +} + +export function createFrameHost(options: { + iframe: HTMLIFrameElement + extensionId: string + bundleRevision: string + api: AgentCodeApiV1 + runtime?: { viewId: string; onFailure(message: string): void } +}): FrameHostHandle { + const { iframe, extensionId, bundleRevision, api } = options + let disposed = false + const connectionId = options.runtime ? crypto.randomUUID() : null + let attachment: ReturnType | undefined + // Subscribe before attaching: activate() can publish state before the attach + // reply arrives. The child compares sequences to reject an older snapshot. + const unsubscribeRuntime = connectionId ? window.api.onExtensionsRuntimeView(event => { + if (disposed || event.connectionId !== connectionId) return + if (event.kind === 'state') post({ kind: 'agent-code-ext:runtime-state', snapshot: event.snapshot }) + else options.runtime?.onFailure(event.error) + }) : undefined + + const assertCurrentInstallation = (): void => { + const installed = useAppStore.getState().installedExtensions.find(entry => entry.manifest.id === extensionId) + // Publication updates the store synchronously, while React unmounts frames + // later. Close that gap here: an old frame loses broker authority as soon as + // the update/remove event lands, even while its DOM is still on screen. + if (disposed || !installed || !installed.present || extensionRevision(installed) !== bundleRevision) { + throw new Error('This extension installation is no longer active.') + } + } + // The origin this frame's messages MUST carry. The scheme gives every extension + // its own origin (`agent-code-ext://`), so this is both the target for our + // pushes and the identity we check on every inbound message. + const expectedOrigin = `agent-code-ext://${extensionId}` + + // Hash once when this generation starts, then reuse its exact approved set. + // Every request also checks the current generation above. Publication events + // revoke the old broker before React teardown, so caching does not postpone + // permission downgrades until the user closes the view. + const grantPromise: Promise = window.api + .extensionGrantedCapabilities(extensionId, bundleRevision) + .catch(() => []) + + const requireGrant = async (capability: ExtensionCapability): Promise => { + const granted = await grantPromise + if (!granted.includes(capability)) { + // Becomes an `ok:false` reply automatically (perform's rejection is caught + // below), which the child surfaces as a rejected api call. A denied capability + // must fail loudly at the call, never silently return empty. + throw new Error(`capability "${capability}" is not granted to ${extensionId}`) + } + } + + const post = (message: FrameReply | FramePush): void => { + // Requests can finish after a failed startup or explicit retry. A replacement + // document may share this WindowProxy AND origin, so targetOrigin alone does + // not prevent a late reply from the old broker reaching the new attempt. + if (disposed) return + // targetOrigin is pinned to the extension's origin, never '*': a reply carrying + // storage contents must not be deliverable to a frame that was navigated away + // or replaced between request and reply. + iframe.contentWindow?.postMessage(message, expectedOrigin) + } + + /** + * Which capability each method requires — DATA, not a convention inside a switch. + * + * The tier gate used to exist only as `await requireGrant(...)` lines sprinkled + * through the switch below. Adding a Tier-2/3 member to frameRequestSchema and + * forgetting its requireGrant line was a one-line, review-invisible privilege + * escalation; there was nothing to notice the omission. As a Record keyed by the + * method union, a new schema member does not COMPILE until its tier is declared. + * + * `null` means Tier 0 — no grant needed, available to every extension. + */ + const REQUIRED_CAPABILITY: Record = { + 'runtime.attach': null, + 'runtime.request': null, + 'storage.get': null, + 'storage.set': null, + 'storage.delete': null, + 'storage.keys': null, + 'ui.close': null, + 'ui.showToast': null, + 'theme.tokens': null, + 'workspace.observe': 'workspace.observe', + 'sessions.observe': 'sessions.observe', + 'panes.observe': 'panes.observe', + 'fs.readText': 'fs.read', + } + + const perform = async (request: FrameRequest): Promise => { + assertCurrentInstallation() + // One gate, before the dispatch, so no arm can accidentally skip it. + const needed = REQUIRED_CAPABILITY[request.method] + if (needed) await requireGrant(needed) + assertCurrentInstallation() + + switch (request.method) { + case 'runtime.attach': + if (!connectionId || !options.runtime) throw new Error('This view uses the legacy extension runtime.') + attachment ??= window.api.extensionsRuntimeAttach(extensionId, bundleRevision, options.runtime.viewId, connectionId) + return attachment + case 'runtime.request': + if (!connectionId || !attachment) throw new Error('The extension runtime is not attached.') + await attachment + assertCurrentInstallation() + return window.api.extensionsRuntimeRequest(connectionId, request.name, request.input) + + case 'storage.get': + return api.storage.get(request.key) + case 'storage.set': + await api.storage.set(request.key, request.value as JsonValue) + return undefined + case 'storage.delete': + await api.storage.delete(request.key) + return undefined + case 'storage.keys': + return api.storage.keys() + case 'ui.close': + await api.ui.close() + return undefined + case 'ui.showToast': + await api.ui.showToast(request.message) + return undefined + case 'theme.tokens': + return api.theme.tokens() + // Tier 1 — the grant was already enforced above by REQUIRED_CAPABILITY. + case 'workspace.observe': + return api.workspace.observe() + case 'sessions.observe': + return api.sessions.observe() + case 'panes.observe': + return api.panes.observe() + // Tier 2 is performed in main. The application renderer remains the + // authenticated broker, but never receives an arbitrary root or performs + // filesystem I/O itself. + case 'fs.readText': + return window.api.extensionsServiceRequest(extensionId, bundleRevision, request) + default: + // Exhaustiveness. Without it an unhandled method fell off the end returning + // undefined, which the caller reported as `ok: true, result: undefined` — a + // silent false success for a capability that was never performed. + return assertNeverMethod(request) + } + } + + const onMessage = (event: MessageEvent): void => { + // Three gates, all before the payload is trusted: + // 1. It came from THIS frame's window (not another frame, not the top window). + if (event.source !== iframe.contentWindow) return + // 2. It carries this extension's origin. The browser stamps event.origin; the + // child cannot forge it. A message whose origin resolves to a different + // extension id — or to no valid id — is dropped, never mis-attributed. + if (event.origin !== expectedOrigin) return + if (extensionIdFromOrigin(event.origin) !== extensionId) return + // 3. It matches the request schema. Anything else (an unrelated library's + // postMessage, a malformed frame) fails the parse and is ignored. + const parsed = frameRequestEnvelopeSchema.safeParse(event.data) + if (!parsed.success) return + + const { id, request } = parsed.data + void perform(request).then( + result => post({ kind: 'agent-code-ext:reply', id, ok: true, result }), + error => + post({ + kind: 'agent-code-ext:reply', + id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }), + ) + } + + window.addEventListener('message', onMessage) + return { + push: post, + granted: grantPromise, + dispose: () => { + if (disposed) return + disposed = true + window.removeEventListener('message', onMessage) + unsubscribeRuntime?.() + if (connectionId) void window.api.extensionsRuntimeDetach(connectionId).catch(() => {}) + }, + } +} diff --git a/src/renderer/src/apps/host/frameProtocol.ts b/src/renderer/src/apps/host/frameProtocol.ts new file mode 100644 index 000000000..a3c399bb0 --- /dev/null +++ b/src/renderer/src/apps/host/frameProtocol.ts @@ -0,0 +1,151 @@ +import { z } from 'zod' +import { extensionJsonSchema, type RuntimeViewSnapshot } from '@shared/types/extensionRuntime' +import { extensionFileReadRequestSchema } from '@shared/types/extensionServices' + +// The host <-> extension-frame message contract (WS4, sandbox substrate). +// +// THE INVARIANT, borrowed verbatim from the remote mobile protocol +// (main/remote/protocol/messages.ts): the request union below is the COMPLETE +// set of things a sandboxed extension can ask the host to do. Anything not in it +// is unrepresentable, not "checked and denied". Widening it is a deliberate +// capability decision. +// +// WHY this exists at all — the isolation model (Decision A). An extension view is +// a plain