diff --git a/.gitmodules b/.gitmodules index fdb440b4..48767819 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,6 @@ [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 diff --git a/docs/extensions/authoring.md b/docs/extensions/authoring.md new file mode 100644 index 00000000..060f11ff --- /dev/null +++ b/docs/extensions/authoring.md @@ -0,0 +1,328 @@ +# Writing an Agent Code extension + +> **Status:** live. API version **1**. +> +> Reference implementation: [`Juliusolsson05/agent-code-timer`](https://github.com/Juliusolsson05/agent-code-timer). +> It exercises every part of this document — declarations, lazy activation, a +> view, persisted state, theming, and background work. + +An extension is a **GitHub repository** containing a manifest and a built ES +module. Agent Code downloads it, validates it, and loads it at runtime. Nothing +is compiled into the app. + +``` +Settings → Extensions → owner/repo → Install +``` + +--- + +## 1. The manifest + +`agent-code.extension.json`, at the repository root. + +```jsonc +{ + "id": "timer", + "name": "Timer", + "description": "Focus timer with interval reminders.", + "version": "0.1.0", + "apiVersion": 1, + "entry": "dist/index.js", + + "activationEvents": ["onStartupFinished"], + + "contributes": { + "commands": [ + { "id": "timer.open", "title": "Open Timer", "keywords": ["pomodoro"] }, + { "id": "timer.start", "title": "Start Timer" } + ], + "views": [ + { "id": "timer.main", "title": "Timer", "mount": "modal" } + ], + "settings": [ + { "id": "timer.defaultMinutes", "title": "Default duration", + "type": "number", "default": 30 } + ], + "keybindings": [ + { "command": "timer.start", "key": "cmd+shift+t" } + ] + } +} +``` + +| Field | Rules | +|---|---| +| `id` | `/^[a-z][a-z0-9-]{0,63}$/`. Becomes a directory name and your storage namespace. **Permanent** — renaming orphans user data. | +| `apiVersion` | Must match the host. v1 today; a host that implements only v1 refuses a v2 manifest with a message saying so. | +| `entry` | Relative path to a built ES module. No absolute paths, no `..`, no backslashes, must end `.js`/`.mjs`. | +| `contributes.*.id` | Must start with `.`. Enforced at install. | + +### Why contributions are declared + +The palette and Settings need to know what your extension offers **before it has +been loaded**. That is what makes lazy activation possible — a command can be +listed and invoked while your module has never been imported. Without +declarations, every installed extension would have to be imported at startup just +to populate a command list. + +It also means a *broken* extension still shows what it contributes, with the +failure reason next to it, instead of silently vanishing. + +### Activation events + +| Event | Fires | +|---|---| +| `onCommand:` | when one of your declared commands is invoked | +| `onView:` | when one of your declared views is opened | +| `onStartupFinished` | once, after the window is interactive | +| `*` | immediately at startup — **avoid**; it makes every launch pay for you | + +Use `onStartupFinished` only if you genuinely need to run without a window — a +timer that keeps counting, a watcher reacting to events. Otherwise prefer +`onCommand:`/`onView:` so you cost nothing until used. + +--- + +## 2. The module + +Your `entry` must export `activate`, and may export `deactivate`. + +```ts +export function activate(context: ExtensionContext): void | Promise { + context.subscriptions.push( + context.registerCommand('timer.start', () => { /* … */ }), + context.registerView('timer.main', element => { + element.textContent = 'hello' + return () => { /* cleanup on close */ } + }), + ) +} + +export function deactivate(): void { /* optional */ } +``` + +- **`registerCommand`/`registerView` reject an id you did not declare.** A handler + the palette has no entry for could never be invoked, so this fails loudly rather + than leaving you with a dead command and no clue why. +- **Everything in `subscriptions` is disposed on deactivate**, in reverse order. +- **A view mount returns its own cleanup.** It runs when the view closes. + +### Views are DOM-level + +```ts +type ViewMount = (element: HTMLElement) => void | (() => void) +``` + +You get an empty element. Do whatever you like inside it. React is optional: + +```tsx +import { createRoot } from 'react-dom/client' + +context.registerView('timer.main', element => { + const root = createRoot(element) + root.render() + return () => queueMicrotask(() => root.unmount()) +}) +``` + +This is DOM-level rather than "export a React component" for two reasons: it keeps +React optional (plain DOM, Preact, Svelte, canvas all work), and it is the one +shape that survives extensions later moving into an iframe — a component +reference cannot cross a frame boundary, but "call mount with this element" can. + +--- + +## 3. React must come from the host + +**You cannot bundle your own React.** Two React instances in one document means +two reconcilers, and every hook throws *invalid hook call*. + +Agent Code publishes its runtime on `globalThis.__agentCodeHost`. Alias the +specifiers to shims that read it: + +```ts +// vite.config.ts +resolve: { + alias: [ + { find: /^react$/, replacement: resolve(__dirname, 'src/host/react.ts') }, + { find: /^react\/jsx-runtime$/, replacement: resolve(__dirname, 'src/host/jsx-runtime.ts') }, + { find: /^react-dom\/client$/, replacement: resolve(__dirname, 'src/host/react-dom-client.ts') }, + ], +} +``` + +```ts +// src/host/react.ts +const react = globalThis.__agentCodeHost.react +export default react +export const { useState, useEffect, useRef, useMemo, useCallback /* … */ } = react +``` + +Copy these four files from +[agent-code-timer/src/host/](https://github.com/Juliusolsson05/agent-code-timer/tree/main/src/host). + +**Why aliasing and not `external: ['react']`:** marking it external leaves a bare +`react` specifier in the output, and a browser cannot resolve a bare specifier +without an import map — which would have to be declared by the host before its own +first module loads. Aliasing resolves it at *your* build time and needs nothing +from the host beyond the global. Any dependency that imports react +(framer-motion does) gets the alias for free. + +--- + +## 4. Build output + +Two hard requirements: + +1. **A single ES module** at the manifest's `entry` path. +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 a file that does not exist"*. + +```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 renderer with +`nodeIntegration: false`, 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. + +The reference timer hit this on its first real run. framer-motion ships eight of +those guards. + +**CSS must be inlined by your bundle**, not emitted as a sibling file. Vite's +library mode emits `style.css` regardless of `cssCodeSplit`, and the host fetches +exactly one file — a stylesheet nothing requests silently never applies. Import +it `?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.ts b/src/main/extensions/grants.ts new file mode 100644 index 00000000..15617d6b --- /dev/null +++ b/src/main/extensions/grants.ts @@ -0,0 +1,102 @@ +import { mkdir, readFile, rename, writeFile } from 'fs/promises' +import { join } from 'path' + +import { z } from 'zod' + +import { STATE_DIR } from '@main/storage/paths.js' +import type { ExtensionCapability } from '@shared/types/extensions.js' + +// The capability grant store (WS5). +// +// A grant records that the user approved a specific set of capabilities for a +// specific extension AT a specific content hash. Keying on the sha256 — not just +// the id — is the load-bearing choice, borrowed from WorkflowSourceApprovalStore: +// an extension can be updated in place (install doubles as update), so a grant that +// keyed on id alone would let an update silently inherit permissions the user +// approved for different code. When the bytes change, the grant no longer matches +// and the capabilities must be re-approved. +// +// Tier-0 capabilities (storage/ui/theme) are NOT recorded here — they are granted +// to every extension without asking, so they never appear in a manifest's +// `permissions` and never reach this store. + +const GRANTS_FILE = join(STATE_DIR, 'extension-grants.json') + +const grantSchema = z.object({ + extensionId: z.string().min(1), + /** The exact bytes the grant was given for. A different sha means re-consent. */ + sha256: z.string().regex(/^[a-f0-9]{64}$/), + capabilities: z.array(z.string()), + grantedAt: z.number().finite(), +}) + +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-${process.pid}-${Date.now()}` + await writeFile(tmp, `${JSON.stringify(rows, null, 2)}\n`, 'utf8') + await rename(tmp, GRANTS_FILE) +} + +/** + * 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, + sha256: string, + capabilities: readonly ExtensionCapability[], +): Promise { + const rows = await readGrants() + await writeGrants([ + ...rows.filter(row => row.extensionId !== extensionId), + { extensionId, sha256, 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 (`sha256`). 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. + */ +export async function grantedCapabilities( + extensionId: string, + sha256: string, +): Promise> { + const rows = await readGrants() + const row = rows.find(candidate => candidate.extensionId === extensionId) + if (!row || row.sha256 !== sha256) return new Set() + return new Set(row.capabilities as ExtensionCapability[]) +} + +/** Drop an extension's grant. Called on uninstall so a reinstall must re-consent. */ +export async function revokeGrant(extensionId: string): Promise { + const rows = await readGrants() + const next = rows.filter(row => row.extensionId !== extensionId) + if (next.length !== rows.length) await writeGrants(next) +} diff --git a/src/main/extensions/install.ts b/src/main/extensions/install.ts new file mode 100644 index 00000000..bdd1d413 --- /dev/null +++ b/src/main/extensions/install.ts @@ -0,0 +1,371 @@ +import { spawn } from 'child_process' +import { createHash } from 'node:crypto' +import { access, constants as fsConstants, cp, mkdir, mkdtemp, 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 { ManifestError, parseExtensionManifest } from '@main/extensions/manifest.js' +import { readLedger, writeLedger } from '@main/extensions/ledger.js' +import { recordGrant, revokeGrant } from '@main/extensions/grants.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 + +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.`, + ) + } + return `${match[1]}/${match[2]}` +} + +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 fetch(`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 fetch(`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}`, + } +} + +async function downloadTarball(url: string): Promise<{ bytes: Buffer; sha256: string }> { + const res = await fetch(url, { headers: { 'user-agent': 'agent-code' } }) + if (!res.ok) throw new InstallError(`Download failed with HTTP ${res.status}.`) + + // Buffered rather than streamed to disk because we need the hash of the exact + // bytes we are about to extract, and because MAX_TARBALL_BYTES keeps the ceiling + // small. A streaming hash would avoid the buffer but complicates the cap: a + // stream that exceeds the limit has already written part of a file we then have + // to clean up. If extensions ever get large enough for this to matter, switch to + // streaming with an abort-on-cap, not to a bigger buffer. + const declared = Number(res.headers.get('content-length') ?? '0') + if (declared > MAX_TARBALL_BYTES) { + throw new InstallError(`Archive is ${Math.round(declared / 1e6)} MB; the limit is 32 MB.`) + } + + const bytes = Buffer.from(await res.arrayBuffer()) + if (bytes.byteLength > MAX_TARBALL_BYTES) { + throw new InstallError(`Archive is ${Math.round(bytes.byteLength / 1e6)} MB; the limit is 32 MB.`) + } + + 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. + const child = spawn(tar, ['-xzf', archivePath, '-C', destDir, '--strip-components=1'], { + stdio: ['ignore', 'ignore', 'pipe'], + }) + let stderr = '' + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) + child.once('error', reject) + child.once('exit', code => { + if (code === 0) resolveExtract() + else reject(new InstallError(`Could not unpack the archive (tar exit ${code}): ${stderr.trim()}`)) + }) + }) +} + +/** + * 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)) + } +} + +/** + * The shared tail of every install path: consent, move the validated bundle into + * place, write the ledger row, bind (or drop) the grant. Both the GitHub and the + * local-folder installers converge here once they have a validated manifest in a + * staging directory — the ONLY differences between them are how the bundle got + * staged and what `repo`/`ref`/`sha256` describe its provenance. + * + * `bundleDir` is renamed into place (not copied), so it must already be a temp + * directory the caller owns; on any failure here the caller's `finally` removes it. + */ +/** + * A staging directory INSIDE the extensions root, not in the OS temp dir. + * + * finalizeInstall commits by `rename(staging, finalDir)`. rename cannot cross + * filesystems: where /tmp is its own mount — Linux tmpfs, the common case — it fails + * with EXDEV. And because the commit deletes the live bundle BEFORE the rename, that + * failure mode is not "install didn't work", it is "install destroyed the version you + * had". Staging as a sibling of the destination makes the rename same-filesystem by + * construction, which is the only way to get atomicity out of it. + * + * The dot prefix keeps it out of the ledger's view of installed extension directories. + */ +async function makeStagingDir(): Promise { + await mkdir(EXTENSIONS_DIR, { recursive: true }) + return await mkdtemp(join(EXTENSIONS_DIR, '.staging-')) +} + +async function finalizeInstall( + manifest: ExtensionManifest, + bundleDir: string, + provenance: { 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.`, + ) + } + } + + await mkdir(EXTENSIONS_DIR, { recursive: true }) + const finalDir = join(EXTENSIONS_DIR, manifest.id) + + // Remove any previous install of this id before renaming the new one in. This + // makes install idempotent and doubles as the update path. Extension STATE is + // untouched — it lives under EXTENSION_STATE_DIR precisely so an update cannot + // take a user's saved data with it. + await rm(finalDir, { recursive: true, force: true }) + await rename(bundleDir, finalDir) + + const record: InstalledExtension = { + manifest, + repo: provenance.repo, + ref: provenance.ref, + sha256: provenance.sha256, + installedAt: Date.now(), + } + + const ledger = await readLedger() + await writeLedger([...ledger.filter(row => row.manifest.id !== manifest.id), record]) + + // Bind the grant to exactly these bytes. A downgrade to Tier-0-only drops any + // prior grant, so revoking capabilities is as simple as shipping a manifest that + // no longer asks for them. + if (permissions.length > 0) await recordGrant(manifest.id, provenance.sha256, permissions) + else await revokeGrant(manifest.id) + + return record +} + +/** + * 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) + + const manifest = await readManifestFrom(staging) + await verifyEntryInsideBundle(staging, manifest.entry) + + return await finalizeInstall(manifest, staging, { repo, ref: source.ref, sha256 }, promptConsent) + } finally { + await rm(work, { recursive: true, force: true }) + } +} + +/** + * 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. + await cp(sourceReal, staging, { + recursive: true, + filter: src => { + const rel = relative(sourceReal, src) + return rel === '' || !rel.split(sep).some(part => part === 'node_modules' || part === '.git') + }, + }) + + const manifest = await readManifestFrom(staging) + await verifyEntryInsideBundle(staging, manifest.entry) + + // No tarball to hash, so bind the grant to the built ENTRY bytes: they change + // exactly when the code the user is consenting to changes, which is the grant's + // whole invariant. A dev rebuild therefore correctly forces re-consent. + const entryBytes = await readFile(join(staging, manifest.entry)) + const sha256 = createHash('sha256').update(entryBytes).digest('hex') + + return await finalizeInstall( + manifest, + staging, + { repo: sourceReal, ref: 'local', sha256 }, + promptConsent, + ) + } finally { + await rm(work, { recursive: true, force: true }) + } +} diff --git a/src/main/extensions/ledger.ts b/src/main/extensions/ledger.ts new file mode 100644 index 00000000..a7cdd44e --- /dev/null +++ b/src/main/extensions/ledger.ts @@ -0,0 +1,121 @@ +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 { 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". +// Rows are dropped INDIVIDUALLY, not the whole ledger — one bad hand-edit must +// not orphan every other installed extension. +const installedExtensionSchema = z.object({ + manifest: extensionManifestSchema, + 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 { + return [] + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + const rows: InstalledExtension[] = [] + for (const candidate of parsed) { + const result = installedExtensionSchema.safeParse(candidate) + if (result.success) { + rows.push(result.data) + } else { + // Surfaced, not silent: a dropped row means a corrupt/hand-edited ledger, + // and the message names the field so it is diagnosable rather than an + // extension mysteriously vanishing from the list. + console.warn( + `[extensions] dropping invalid ledger row: ${result.error.issues[0]?.message ?? 'unknown'}`, + ) + } + } + return rows +} + +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-${process.pid}-${Date.now()}` + await writeFile(tmp, `${JSON.stringify(rows, null, 2)}\n`, 'utf8') + await rename(tmp, EXTENSIONS_LOCKFILE) +} + +/** + * 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 { + const rows = await readLedger() + return Promise.all( + rows.map(async row => { + let present = false + try { + await access(join(EXTENSIONS_DIR, row.manifest.id, 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}`) + await rm(join(EXTENSIONS_DIR, id), { recursive: true, force: true }) + const ledger = await readLedger() + await writeLedger(ledger.filter(row => row.manifest.id !== id)) +} diff --git a/src/main/extensions/manifest.ts b/src/main/extensions/manifest.ts new file mode 100644 index 00000000..e0dd12c4 --- /dev/null +++ b/src/main/extensions/manifest.ts @@ -0,0 +1,285 @@ +import { z } from 'zod' + +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. + +/** The one AgentCodeApi major this host implements. */ +export const SUPPORTED_API_VERSION = 1 + +// 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']), +}) + +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). +const capabilityName = z.enum([ + 'workspace.observe', + 'sessions.observe', + 'panes.observe', + 'fs.read', + 'transcript.read', + 'git.read', + 'sessions.prompt', + 'fs.write', + 'git.commit', + 'network.fetch', +]) + +// `.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(), + }) + .optional(), + permissions: z.array(capabilityName).max(16).optional(), +}) + +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 v2, this build has v1" 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. + if (result.data.apiVersion !== SUPPORTED_API_VERSION) { + throw new ManifestError( + `extension targets Agent Code API v${result.data.apiVersion}, this build implements v${SUPPORTED_API_VERSION}`, + ) + } + + assertContributionsAreCoherent(result.data) + return result.data +} + +/** + * 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 ?? [] + + // 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] + 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') + + 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/scheme.ts b/src/main/extensions/scheme.ts new file mode 100644 index 00000000..d8a5cd5a --- /dev/null +++ b/src/main/extensions/scheme.ts @@ -0,0 +1,275 @@ +import { randomBytes } from 'crypto' +import { net, protocol } from 'electron' +import { realpath } from 'fs/promises' +import { extname, join, resolve as resolvePath, sep } from 'path' +import { pathToFileURL } from 'url' + +import { isValidExtensionId } from '@shared/types/extensionId.js' +import { EXTENSIONS_DIR } from '@main/storage/paths.js' +import { buildFrameDocument, childFrameCsp } from '@main/extensions/frameDocument.js' +import { readLedger } from '@main/extensions/ledger.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(): void { + protocol.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 }) + } + + // 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') + const parentOrigin = url.searchParams.get('parentOrigin') + if (!viewId || !parentOrigin) return new Response('bad request', { status: 400 }) + + const record = (await readLedger()).find(row => row.manifest.id === extensionId) + // A frame for an extension not in the ledger is a stale/removed reference; + // 404 like a missing bundle rather than serving an empty shell. + if (!record) return new Response('not found', { status: 404 }) + + // 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 ?? []).some(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.entry, + parentOrigin, + 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. + const root = join(EXTENSIONS_DIR, extensionId) + + 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.ts b/src/main/extensions/storage.ts new file mode 100644 index 00000000..e210f427 --- /dev/null +++ b/src/main/extensions/storage.ts @@ -0,0 +1,177 @@ +import { mkdir, readFile, rename, writeFile } from 'fs/promises' +import { join } from 'path' + +import { EXTENSION_STATE_DIR } from '@main/storage/paths.js' + +// Per-app JSON state, main-owned. +// +// One file per app (`extensions//state.json`) rather than one shared file +// keyed by app id: uninstalling an app becomes `rm -rf` of one directory, a +// corrupt write can only lose one app's data, and two apps writing concurrently +// never contend for the same file. + +// 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') +} + +async function readAll(appId: string): Promise> { + const file = stateFileFor(appId) + let raw: string + try { + raw = await readFile(file, 'utf8') + } catch { + // Missing file is the normal first-run case, not an error. + return {} + } + try { + const parsed: unknown = JSON.parse(raw) + // A corrupt or non-object file degrades to empty rather than throwing. An app + // losing saved state is recoverable and visible; an app that throws on every + // read can never start again, and the user has no way to clear it from the UI. + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + return parsed as Record + } catch { + return {} + } +} + +async function writeAll(appId: string, data: Record): Promise { + const dir = appDirFor(appId) + await mkdir(dir, { recursive: true }) + const file = join(dir, 'state.json') + // temp+rename, matching workspace.json's discipline: rename is atomic within a + // filesystem, so a crash mid-write leaves either the old file or the new one — + // never a half-written one that then fails to parse forever. The temp file lives + // in the same directory precisely so the rename cannot cross a device boundary. + // + // The temp name is UNIQUE per write. An earlier version used a fixed + // `${file}.tmp`, which review reproduced as a hard failure 3/3: two concurrent + // writers both create the same temp path, the first rename consumes it, and the + // second rename fails ENOENT — losing one write AND rejecting. Uniqueness alone + // still permits a lost update (both read the old state), which is why every write + // for an app also goes through the serialization queue below. + const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${writeCounter++}` + await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8') + await rename(tmp, file) +} + +let writeCounter = 0 + +// One promise chain per app id, so read-modify-write cycles for the same app never +// interleave. +// +// WHY this is necessary and not paranoia: the ABI makes every storage method +// async, which invites exactly the fire-and-forget shape that breaks a naive +// implementation — `void api.storage.set('a', 1); void api.storage.set('b', 2)` from +// one click handler. Without serialization both calls read the same original state +// and the second write erases the first key. Chaining makes that sequence correct +// without the extension author having to know it needed awaiting. +// +// WHY a Map keyed by app rather than one global chain: two different extensions +// write to different files and have no reason to block each other. The map grows by +// one small entry per app that ever writes, bounded by the number of installed +// extensions. +const writeQueues = new Map>() + +function enqueueWrite(appId: string, mutate: () => Promise): Promise { + // Validate BEFORE joining the queue so a bad id rejects immediately instead of + // waiting behind unrelated work — and so an invalid id never creates a queue entry. + appDirFor(appId) + + const previous = writeQueues.get(appId) ?? Promise.resolve() + // `.catch(() => {})` on the tail: one failed write must not poison every + // subsequent write for that app. The failure still propagates to ITS caller + // through the returned promise; this only stops it propagating to the next one. + const next = previous.catch(() => {}).then(mutate) + writeQueues.set( + appId, + next.catch(() => {}), + ) + return next +} + +export async function extensionStorageGet(appId: string, key: string): Promise { + return (await readAll(appId))[key] +} + +export class NonSerializableValueError extends Error { + constructor(detail: string) { + super(`extension storage value is not JSON-serializable: ${detail}`) + this.name = 'NonSerializableValueError' + } +} + +// WHY non-finite numbers are rejected rather than stored: the ABI types values +// as JsonValue, whose `number` member is unrestricted, so TypeScript happily +// accepts NaN and ±Infinity — but JSON.stringify turns all three into `null`. +// An extension writing NaN and reading back null is a silent data corruption +// with no error anywhere, and the author has no way to discover it except by +// noticing wrong behaviour much later. Rejecting at the boundary converts a +// silent corruption into an immediate, attributable failure. +// +// Checked here rather than in the renderer because this is the last point where +// the value is still structured; after JSON.stringify the information is gone. +function assertSerializable(value: unknown, path = 'value'): void { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new NonSerializableValueError(`${path} is ${String(value)}`) + } + return + } + if (value === null || typeof value !== 'object') return + if (Array.isArray(value)) { + value.forEach((entry, index) => assertSerializable(entry, `${path}[${index}]`)) + return + } + for (const [key, entry] of Object.entries(value)) { + assertSerializable(entry, `${path}.${key}`) + } +} + +export async function extensionStorageSet( + appId: string, + key: string, + value: unknown, +): Promise { + assertSerializable(value) + return enqueueWrite(appId, async () => { + const data = await readAll(appId) + data[key] = value + await writeAll(appId, data) + }) +} + +export async function extensionStorageDelete(appId: string, key: string): Promise { + return enqueueWrite(appId, async () => { + const data = await readAll(appId) + delete data[key] + await writeAll(appId, data) + }) +} + +export async function extensionStorageKeys(appId: string): Promise { + return Object.keys(await readAll(appId)) +} diff --git a/src/main/index.ts b/src/main/index.ts index a901116e..fe9a3686 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -25,6 +25,10 @@ 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 { STATE_DIR, STATE_FILE } from '@main/storage/paths.js' import { scheduleDebugStoragePrune, @@ -216,6 +220,15 @@ 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() + const hasSingleInstanceLock = packagingSmoke || app.requestSingleInstanceLock() if (packagingSmoke) { @@ -446,6 +459,12 @@ 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() + void performanceService.start().catch(err => { console.warn('[performance] failed to start:', err) appRunJournal?.recordError('performance.start.error', err) diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts new file mode 100644 index 00000000..37d9037d --- /dev/null +++ b/src/main/ipc/extensions.ts @@ -0,0 +1,151 @@ +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, removeExtension } from '@main/extensions/ledger.js' +import { grantedCapabilities, revokeGrant } from '@main/extensions/grants.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). +function consentPromptFor(evt: IpcMainInvokeEvent): ConsentPrompt { + return async manifest => { + const win = BrowserWindow.fromWebContents(evt.sender) + const detail = (manifest.permissions ?? []).map(cap => ` • ${cap}`).join('\n') + const options = { + type: 'warning' as const, + buttons: ['Cancel', 'Grant & install'], + defaultId: 0, + cancelId: 0, + title: 'Extension permissions', + message: `${manifest.name} requests capabilities beyond the default:`, + detail: `${detail}\n\nThese let the extension act outside its own sandbox. Only grant them if you trust ${manifest.id}.`, + } + 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: +// in Stage 1 every app is compiled into the one renderer and shares a single +// WebContents, so `event.sender` cannot distinguish the timer from any other app. +// That makes this a NAMESPACE, not an authority boundary — any renderer code can +// name any app's namespace today, exactly as it can already call the other ~130 +// unvalidated handlers in this directory. +// +// The invariant that matters is therefore about what may be added here, not about +// who is calling: storage is the only capability whose worst case (an app reading +// another app's saved preferences, in a single-user desktop app where all app code +// is compiled from this repo) is acceptable without sender binding. Do NOT add +// workspace, session, transcript, git, filesystem, or network capabilities to this +// module. Those are Tier 1-3 in the API design and they need the sender-derived +// identity that only Stage 2 — where each app gets its own frame and preload — can +// provide. Adding one here would be a real privilege escalation wearing a +// namespace's clothes. +export function registerExtensionsIpc(): void { + 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)) + 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)) + 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) + // A reinstall must re-consent; a lingering grant would silently re-arm. + await revokeGrant(id) + }) + + // Reads WHICH capabilities the user consented to for an extension. This is NOT + // the Tier 1-3 escalation the module header forbids: it does not PERFORM any + // capability (each capability still executes through its own per-feature IPC) — + // it reports the grant so the frame broker (frameHost.perform) can GATE a + // capability call before allowing it. Keyed on the installed sha256 so a grant + // recorded for old bytes never authorizes new ones; grantedCapabilities enforces + // that match and returns empty on mismatch or unknown id. + ipcMain.handle( + 'extensions:granted-capabilities', + async (_evt, id: string): Promise => { + const installed = await listInstalledExtensions() + const entry = installed.find(candidate => candidate.manifest.id === id) + if (!entry) return [] + return [...(await grantedCapabilities(id, entry.sha256))] + }, + ) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 53ee0a0e..62246568 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -46,6 +46,7 @@ import type { CliUpdateOrchestrator } from '@main/setup/cliUpdateOrchestrator.js import { registerWorkflowIpc } from '@main/ipc/workflows.js' import { registerAgentCodeConventionsIpc } from '@main/ipc/agentCodeConventions.js' import type { WorkflowBridge } from '@main/workflows/WorkflowBridge.js' +import { registerExtensionsIpc } from '@main/ipc/extensions.js' // IPC registration aggregator. // @@ -112,5 +113,9 @@ 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) } diff --git a/src/main/remote/RemoteServer.ts b/src/main/remote/RemoteServer.ts index 5a2f9c6f..5f8873b9 100644 --- a/src/main/remote/RemoteServer.ts +++ b/src/main/remote/RemoteServer.ts @@ -12,6 +12,7 @@ import type { AppRunJournal } from '@main/incident/AppRunJournal.js' import type { ResolveConditionResult } from '@shared/sessionFeed/types.js' import type { ConditionCustomAction } from '@shared/conditions-core/contract.js' import type { SessionKind } from '@shared/types/providerKind.js' +import { isAgentProviderKind } from '@shared/types/providerKind.js' import type { PromptDeliveryResult } from '@shared/types/providerConfig.js' import type { SessionBackendSnapshot } from '@shared/types/session.js' @@ -662,7 +663,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' } } const chunk = msg.beforeMarker diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index e5aa560b..f59f7e45 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -746,6 +746,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 const cwd = path.resolve(options.cwd) const existingClaim = this.recoveriesInFlight.get(options.sessionId) @@ -1003,6 +1015,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 if ( this.sessions.has(sessionId) || @@ -2259,7 +2279,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 { return await resolveProviderTranscriptPath({ kind, diff --git a/src/main/storage/paths.ts b/src/main/storage/paths.ts index ac64ecb1..654abc20 100644 --- a/src/main/storage/paths.ts +++ b/src/main/storage/paths.ts @@ -21,6 +21,38 @@ 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 — one directory per extension id, each holding the +// unpacked repository contents (manifest + built entry + assets). +// +// 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 00000000..6721d6d9 --- /dev/null +++ b/src/preload/api/extensions.ts @@ -0,0 +1,49 @@ +import { ipcRenderer } from 'electron' + +import type { + ExtensionCapability, + ExtensionInstallResult, + ExtensionListEntry, +} from '@shared/types/extensions.js' + +// Extension-app storage bridge. +// +// These are the only `extension*`-prefixed methods on the flat api object, and the +// prefix is doing real work: it is what lets `apps/api/useAppHostApi.ts` be the sole +// call site. App code never touches `window.api` — it receives AgentCodeApiV1, which +// closes over its own app id. If a second call site for these methods ever appears +// outside useAppHostApi, the ABI has been bypassed and an app has become +// non-portable, which is the one failure Stage 1 exists to prevent. +export const extensionsApi = { + 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'), + + 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'), + + 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): Promise => + ipcRenderer.invoke('extensions:granted-capabilities', id), +} diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index 45c54649..120a5943 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -27,6 +27,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' // Composed preload API surface. @@ -74,6 +75,7 @@ export const api = { ...usageApi, ...cliUpdatesApi, ...workflowsApi, + ...extensionsApi, ...agentCodeConventionsApi, } diff --git a/src/renderer/index.html b/src/renderer/index.html index 247923fb..cdb1f67a 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/types.ts b/src/renderer/src/app-state/types.ts index 0c231087..4222c6d8 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/host/ExtensionHost' import type { ReaderModeState, SpotlightState, @@ -116,6 +118,10 @@ export type UiShellSlice = UiShellState & { closeUsageModal: () => void openRewindPrompt: (sessionId: SessionId) => void closeRewindPrompt: () => void + openApp: (appId: string) => void + closeApp: () => void + setInstalledExtensions: (entries: ExtensionListEntry[]) => 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 8c50c788..6f4eb92e 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -50,6 +50,13 @@ export const createUiShellSlice: StateCreator< usageModalOpen: false, 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, + extensionFailures: [], colorFlagPickerSessionId: null, // Default keeps the dispatch list at 25% (matching the // previous-hardcoded `basis-1/4`) so the migration is visually a @@ -310,6 +317,18 @@ 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 }, + false, + 'uiShell/setInstalledExtensions', + ), + 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 00fb2c3a..2ebdd530 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/host/ExtensionHost' export type DispatchAttachIntent = { sessionId: SessionId @@ -303,6 +305,45 @@ 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 + /** Extensions whose import or activate() threw. Surfaced in Settings rather + * than hidden, because a silently-missing extension is undiagnosable. */ + 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 b14b17f9..a138c300 100644 --- a/src/renderer/src/app/main.tsx +++ b/src/renderer/src/app/main.tsx @@ -16,6 +16,15 @@ 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 { installHostGlobal } from '@renderer/apps/api/hostGlobal' +import { ExtensionHostProvider } from '@renderer/apps/host/ExtensionHostProvider' + +// Publish globalThis.__agentCodeHost before ANY extension module can be +// imported. Extension bundles alias `react` to a shim that reads this object at +// module-evaluation time, so an extension imported before this ran would throw +// on its very first import rather than on first render — a failure that would +// look like a broken extension rather than a host ordering bug. +installHostGlobal() void initializePerformance().then(() => { mark('app.renderer.reactRenderCalled') @@ -85,9 +94,17 @@ createRoot(document.getElementById('root')!).render( - - - + {/* INSIDE GlobalToastProvider because the extension API's showToast + comes from it, and INSIDE AppErrorBoundary's parent so a throw + while loading extensions is caught rather than blanking the app. + Extensions activate from an effect here, not from bootstrap: + blocking the first paint on third-party module evaluation would let + one slow extension delay startup for everything. */} + + + + + diff --git a/src/renderer/src/app/surfaces/GlobalModals.tsx b/src/renderer/src/app/surfaces/GlobalModals.tsx index eb2816bd..858d09dd 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 1945190e..a3a87992 100644 --- a/src/renderer/src/app/surfaces/registry.tsx +++ b/src/renderer/src/app/surfaces/registry.tsx @@ -26,6 +26,7 @@ import { AgentViewModePickerSurface } from '@renderer/features/workspace/surface import { ColorFlagPickerSurface } from '@renderer/features/workspace/surfaces/ColorFlagPickerSurface' import { KeyboardShortcutsSurface } from '@renderer/features/settings/surfaces/KeyboardShortcutsSurface' import { RewindToPromptSurface } from '@renderer/features/workspace/surfaces/RewindToPromptSurface' +import { AppHostSurface } from '@renderer/apps/surfaces/AppHostSurface' // The surface registry (issue #494). Adding a surface = write a wrapper // in the owning feature's surfaces/ folder + add ONE import + ONE array @@ -79,6 +80,13 @@ export const modalSurfaces: SurfaceEntry[] = [ { id: 'color-flag-picker', Component: ColorFlagPickerSurface }, { id: 'rewind-to-prompt', Component: RewindToPromptSurface }, { id: 'usage', Component: UsageModalSurface }, + // 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 d89b2dcd..3e21530f 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 00000000..94def366 --- /dev/null +++ b/src/renderer/src/apps/api/createAppHostApi.ts @@ -0,0 +1,153 @@ +import { useAppStore } from '@renderer/app-state/hooks' +import { collectLeaves } from '@renderer/workspace/workspaceStore' + +import type { AgentCodeApiV1, JsonValue } from '@renderer/apps/api/types' + +// 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 + + const names = new Set(RUNTIME_ONLY_TOKENS) + 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/hostGlobal.ts b/src/renderer/src/apps/api/hostGlobal.ts new file mode 100644 index 00000000..38a472f5 --- /dev/null +++ b/src/renderer/src/apps/api/hostGlobal.ts @@ -0,0 +1,65 @@ +import * as React from 'react' +import * as ReactDOM from 'react-dom/client' +import * as ReactJsxRuntime from 'react/jsx-runtime' + +/** The shape an extension bundle's shims read. Changing it is an ABI break. */ +export type AgentCodeHostGlobal = { + react: typeof React + reactDom: typeof ReactDOM + jsxRuntime: typeof ReactJsxRuntime + apiVersion: 1 +} + +const GLOBAL_KEY = '__agentCodeHost' + +/** + * Publish the host runtime for extension bundles to bind against. + * + * WHY this exists at all: an extension that renders React cannot bundle its own + * copy — two React instances in one document means two reconcilers, and every + * hook throws "invalid hook call". It also cannot import the host's copy by + * specifier, because the app's chunks are content-hashed (`index-BV5jdlWA.js`) + * and rotate on every build, so there is no stable URL to import from. + * + * WHY a global rather than a field on AgentCodeApiV1: putting React on the + * extension API would version the host's React major INTO the extension ABI — a + * React 19 upgrade would become a breaking change for every extension, including + * the ones that never used React. The API stays framework-free; the runtime is a + * separate, lower-level handshake. + * + * WHY a global rather than an import map: an import map must be declared before + * the document's first module loads and must resolve `react` to a URL — which + * would have to be a shim that reads a global anyway. Same mechanism, extra + * indirection, plus a load-order constraint. The extension's own build aliases + * `react` to a shim that reads this object, so no bare specifier ever reaches the + * browser. + * + * WHY frozen and non-configurable: this object is reachable from all renderer + * code, so locking it removes the trivial footgun of one extension swapping the + * host's React out from under every other consumer. It is NOT a security + * boundary — a same-realm extension can reach `window.api` directly, and only + * moving extensions into a frame changes that. The freeze is about accidents, + * not attacks, and the distinction is worth keeping honest. + */ +export function installHostGlobal(): void { + const globals = globalThis as Record + // Idempotent: React StrictMode and HMR can both re-run module init, and + // defineProperty on an existing non-configurable key throws. + if (Object.prototype.hasOwnProperty.call(globals, GLOBAL_KEY)) return + + const host: AgentCodeHostGlobal = Object.freeze({ + react: React, + reactDom: ReactDOM, + jsxRuntime: ReactJsxRuntime, + apiVersion: 1, + }) + + Object.defineProperty(globals, GLOBAL_KEY, { + value: host, + writable: false, + configurable: false, + // Non-enumerable so it does not show up in devtools' global listing or in + // anything that walks globalThis. Extensions know the key by name. + enumerable: false, + }) +} diff --git a/src/renderer/src/apps/api/types.ts b/src/renderer/src/apps/api/types.ts new file mode 100644 index 00000000..2b0f1d63 --- /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/ExtensionHost.ts b/src/renderer/src/apps/host/ExtensionHost.ts new file mode 100644 index 00000000..145a86ec --- /dev/null +++ b/src/renderer/src/apps/host/ExtensionHost.ts @@ -0,0 +1,220 @@ +import type { AgentCodeApiV1 } from '@renderer/apps/api/types' +import type { + Disposable, + ExtensionContext, + ExtensionModule, + ViewMount, +} from '@renderer/apps/host/moduleContract' +import type { ExtensionListEntry } from '@shared/types/extensions' + +import { ExtensionRegistrations } from '@renderer/apps/host/registrations' + +export type ExtensionFailure = { id: string; name: string; error: string } + +type Loaded = { + entry: ExtensionListEntry + module: ExtensionModule + context: ExtensionContext +} + +/** + * Imports, activates and disposes installed extensions. + * + * WHY every failure is a VALUE and never a throw: an extension is third-party + * code loaded at runtime. A module that fails to import, exports no `activate`, + * or throws inside it must leave every other extension running and must not be + * able to blank the renderer. Failures accumulate in `failures` and are surfaced + * in Settings, where the user can act on them. + * + * WHY activation is memoized on the in-flight promise rather than a boolean: + * two triggers can race — a palette command and a view open in the same frame — + * and a boolean set after the await would let both run `activate()`. Storing the + * promise means the second caller joins the first. + */ +export class ExtensionHost { + private loaded = new Map() + private activating = new Map>() + private registrations = new ExtensionRegistrations() + private failures: ExtensionFailure[] = [] + + constructor( + /** Builds the per-extension API instance. Injected so the host does not + * reach into React context, and so tests can supply a fake. */ + private makeApi: (extensionId: string) => AgentCodeApiV1, + private onFailuresChanged: (failures: ExtensionFailure[]) => void, + ) {} + + getFailures(): ExtensionFailure[] { + return this.failures + } + + isActivated(extensionId: string): boolean { + return this.loaded.has(extensionId) + } + + /** + * Import and activate, once. + * + * The single line of actual loading is the dynamic import below. `@vite-ignore` + * stops Vite trying to analyze a runtime specifier at build time; Rollup passes + * a variable import() through untouched either way — Monaco's own foreign-module + * loader does exactly this in the shipped bundle, which is what retired the risk + * that it would not survive the build. The B1 spike then proved it works over + * agent-code-ext:// from a file:// document. + */ + async activate(entry: ExtensionListEntry): Promise { + const id = entry.manifest.id + if (this.loaded.has(id)) return + const inFlight = this.activating.get(id) + if (inFlight) return inFlight + + const run = (async () => { + try { + // The version+sha query is a CACHE KEY, not decoration. + // + // The ES module registry caches by URL, and it caches FAILURES too: once + // `import(url)` rejects, every later import of that exact URL returns the + // same rejection for the life of the realm — even after the file on disk + // has been replaced. Without this, an extension that fails once can never + // be fixed by reinstalling; the user updates it, sees the identical error, + // and has no way to tell that their fix did land. Reloading the window is + // the only escape, and nothing tells them that. + // + // The sha is already the installer's integrity record, so it changes + // exactly when the bytes change — which is precisely the condition under + // which the module must be re-evaluated. + const cacheKey = `${entry.manifest.version}-${entry.sha256.slice(0, 12)}` + const url = `agent-code-ext://${id}/${entry.manifest.entry}?v=${cacheKey}` + const module = (await import(/* @vite-ignore */ url)) as ExtensionModule + + if (typeof module.activate !== 'function') { + // The most likely authoring mistake by a wide margin, so it gets its + // own message rather than surfacing as a TypeError from a call site. + throw new Error('entry module does not export an activate(context) function') + } + + const context = this.makeContext(entry) + await module.activate(context) + this.loaded.set(id, { entry, module, context }) + this.clearFailure(id) + } catch (error) { + this.recordFailure(id, entry.manifest.name, error) + } finally { + this.activating.delete(id) + } + })() + + this.activating.set(id, run) + return run + } + + async deactivate(extensionId: string): Promise { + const loaded = this.loaded.get(extensionId) + if (!loaded) return + this.loaded.delete(extensionId) + + try { + await loaded.module.deactivate?.() + } catch (error) { + // A throwing deactivate must not prevent disposal — the subscriptions are + // the host's cleanup contract, and skipping them would leak intervals and + // listeners for the rest of the session. + this.recordFailure(extensionId, loaded.entry.manifest.name, error) + } + + // Reverse order: later subscriptions were created against earlier ones, so + // disposing forwards can tear down a dependency while a dependant still + // holds it. This is the same ordering every disposable stack uses, for the + // same reason. + for (const subscription of [...loaded.context.subscriptions].reverse()) { + try { + subscription.dispose() + } catch { + // One bad disposer must not strand the rest. + } + } + + this.registrations.disposeAll(extensionId) + } + + async deactivateAll(): Promise { + await Promise.all([...this.loaded.keys()].map(id => this.deactivate(id))) + } + + /** + * Run a contributed command, activating the extension first if needed. + * + * This is what makes `onCommand:` activation real: the palette lists the + * command from the manifest, and the module is imported only when it is + * actually invoked. + */ + async executeCommand(entry: ExtensionListEntry, commandId: string): Promise { + await this.activate(entry) + const handler = this.registrations.getCommand(entry.manifest.id, commandId) + if (!handler) { + // Declared but never registered. Not a crash: an extension may reasonably + // declare a command whose only meaning is "open my view", which the host + // resolves without a handler. + return + } + try { + await handler() + } catch (error) { + this.recordFailure(entry.manifest.id, entry.manifest.name, error) + } + } + + /** The mount registered for a view, if its extension has activated. */ + getView(extensionId: string, viewId: string): ViewMount | undefined { + return this.registrations.getView(extensionId, viewId) + } + + private makeContext(entry: ExtensionListEntry): ExtensionContext { + const id = entry.manifest.id + const declaredCommands = new Set( + (entry.manifest.contributes?.commands ?? []).map(command => command.id), + ) + const declaredViews = new Set((entry.manifest.contributes?.views ?? []).map(view => view.id)) + const subscriptions: Disposable[] = [] + + return { + api: this.makeApi(id), + subscriptions, + + registerCommand: (commandId, run) => { + // Rejecting an undeclared id is deliberate. A handler the palette has no + // entry for can never be invoked, so silently accepting it would leave + // the author with a command that does nothing and no clue why. The throw + // is caught by activate() and shown as an extension failure. + if (!declaredCommands.has(commandId)) { + throw new Error( + `registerCommand("${commandId}") — not declared in contributes.commands`, + ) + } + return this.registrations.registerCommand(id, commandId, run) + }, + + registerView: (viewId, mount) => { + if (!declaredViews.has(viewId)) { + throw new Error(`registerView("${viewId}") — not declared in contributes.views`) + } + return this.registrations.registerView(id, viewId, mount) + }, + } + } + + private recordFailure(id: string, name: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + this.failures = [...this.failures.filter(failure => failure.id !== id), { id, name, error: message }] + this.onFailuresChanged(this.failures) + // Also to the console: Settings shows the message, but a stack is what + // actually locates a bug inside third-party code. + console.warn(`[extensions] ${id} failed:`, error) + } + + private clearFailure(id: string): void { + if (!this.failures.some(failure => failure.id === id)) return + this.failures = this.failures.filter(failure => failure.id !== id) + this.onFailuresChanged(this.failures) + } +} diff --git a/src/renderer/src/apps/host/ExtensionHostProvider.tsx b/src/renderer/src/apps/host/ExtensionHostProvider.tsx new file mode 100644 index 00000000..41c740f1 --- /dev/null +++ b/src/renderer/src/apps/host/ExtensionHostProvider.tsx @@ -0,0 +1,100 @@ +import { createContext, useContext, useEffect, useMemo, useRef } from 'react' + +import { useAppStore } from '@renderer/app-state/hooks' +import { createAppHostApi } from '@renderer/apps/api/createAppHostApi' +import { ExtensionHost } from '@renderer/apps/host/ExtensionHost' +import { useGlobalToast } from '@renderer/ui/GlobalToast' + +const ExtensionHostContext = createContext(null) + +export function useExtensionHost(): ExtensionHost | null { + return useContext(ExtensionHostContext) +} + +/** + * Owns the single ExtensionHost and drives the install → load → activate cycle. + * + * WHY a provider rather than a module singleton: the host needs `showToast`, + * which is React context, and it needs to push failures into the store. Both are + * app-lifetime concerns, and a module singleton would have to reach for them + * through globals. + */ +export function ExtensionHostProvider({ children }: { children: React.ReactNode }) { + const { showToast } = useGlobalToast() + const setInstalledExtensions = useAppStore(state => state.setInstalledExtensions) + const setExtensionFailures = useAppStore(state => state.setExtensionFailures) + const closeApp = useAppStore(state => state.closeApp) + + // Refs so the host is constructed exactly once and never re-created by a + // re-render. Re-creating it would orphan every activated extension: their + // modules stay in the module cache but their registrations would be gone, and + // a second activate() would run activate() twice on the same module instance. + const closeAppRef = useRef(closeApp) + closeAppRef.current = closeApp + const showToastRef = useRef(showToast) + showToastRef.current = showToast + + const host = useMemo( + () => + new ExtensionHost( + extensionId => + createAppHostApi({ + extensionId, + // Through refs so the API object handed to a long-lived extension + // never captures a stale callback from the render that created it. + showToast: message => showToastRef.current(message), + closeSurface: () => closeAppRef.current(), + }), + failures => setExtensionFailures(failures), + ), + [setExtensionFailures], + ) + + useEffect(() => { + let cancelled = false + + const load = async () => { + let installed: Awaited> + try { + installed = await window.api.extensionsList() + } catch { + // A failed list is not an empty list. Leaving the store untouched keeps + // whatever was previously loaded rather than making every extension + // vanish because one IPC call failed. + return + } + if (cancelled) return + setInstalledExtensions(installed) + + // NOTE (Group A): host-realm onStartupFinished/'*' eager activation was + // REMOVED here on purpose. It imported and ran the extension in the renderer's + // OWN realm — unsandboxed, with access to window.api the frame model exists to + // deny — and, worse, produced a SECOND instance separate from the one the view + // frame runs, so a background engine (the timer) and its visible view drove + // different state. Activation now happens only inside the extension's frame. + // + // Consequence until the background-frame follow-up (A4): a startup/'*' + // extension activates lazily on its first view/command open rather than at + // launch. For the timer this means its wall-clock deadline is still restored + // from storage on open (so the TIME is never lost), but reminders do not fire + // while every view is closed. That is correct-but-later, never split-brain — + // and A4 restores true background activation via a persistent headless frame. + } + + void load() + return () => { + cancelled = true + } + }, [setInstalledExtensions]) + + useEffect(() => { + return () => { + // Best-effort on teardown. In practice the renderer is going away anyway, + // but an extension holding an interval or an AudioContext deserves its + // deactivate() called rather than being killed mid-flight. + void host.deactivateAll() + } + }, [host]) + + 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 00000000..69a1073b --- /dev/null +++ b/src/renderer/src/apps/host/derive.ts @@ -0,0 +1,199 @@ +import type { CommandDef } from '@renderer/features/command-palette/types' +import type { AppDefinition } from '@renderer/apps/types' +import type { ExtensionHost } from '@renderer/apps/host/ExtensionHost' +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 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 ExtensionHost. +// +// 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( + host: ExtensionHost, + 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(host, 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( + // Retained for signature stability with deriveAppDefinitions and every caller, + // but no longer used: command execution was moved off the host-realm ExtensionHost + // and into the extension's frame (Group A), which ended the split-brain where a + // palette command drove a different instance than the visible view. + _host: ExtensionHost, + 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 ?? entry.manifest.description, + // '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', + 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 + } + // 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 00000000..6a55194d --- /dev/null +++ b/src/renderer/src/apps/host/frameHost.ts @@ -0,0 +1,173 @@ +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' + +// 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 + /** 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 + api: AgentCodeApiV1 +}): FrameHostHandle { + const { iframe, extensionId, api } = options + // 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}` + + // The capability grant, fetched ONCE and cached as a promise. This is the "teeth" + // of the tiered permission model: every Tier 1-3 request is gated on it below. The + // grant is keyed on the installed sha256 in main (grantedCapabilities), so bytes + // that were never consented to authorize nothing. Read here rather than in main's + // per-feature IPC because that surface is deliberately NOT sender-bound — the frame + // broker is the one renderer chokepoint every capability call already passes through. + const grantPromise: Promise = window.api + .extensionGrantedCapabilities(extensionId) + .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 => { + // 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 = { + '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', + } + + const perform = async (request: FrameRequest): Promise => { + // One gate, before the dispatch, so no arm can accidentally skip it. + const needed = REQUIRED_CAPABILITY[request.method] + if (needed) await requireGrant(needed) + + switch (request.method) { + 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() + 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) + let disposed = false + return { + push: post, + dispose: () => { + if (disposed) return + disposed = true + window.removeEventListener('message', onMessage) + }, + } +} diff --git a/src/renderer/src/apps/host/frameProtocol.ts b/src/renderer/src/apps/host/frameProtocol.ts new file mode 100644 index 00000000..b69211b6 --- /dev/null +++ b/src/renderer/src/apps/host/frameProtocol.ts @@ -0,0 +1,153 @@ +import { z } from 'zod' + +// 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