From a1db787473e25fe1b5a91762e3803efaef1ec43b Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 21:46:20 +0200 Subject: [PATCH 01/22] feat(extensions): per-app main-owned storage Co-Authored-By: Claude Opus 4.8 --- src/main/extensions/storage.ts | 177 +++++++++++++++++++++++++++++++++ src/main/storage/paths.ts | 32 ++++++ 2 files changed, 209 insertions(+) create mode 100644 src/main/extensions/storage.ts 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/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. From ce80b29f041d5574c27f11ff42b35e2d6ef87fcd Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 21:46:20 +0200 Subject: [PATCH 02/22] feat(extensions): AgentCodeApiV1 host ABI, apps host surface & extension registry Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/app-state/types.ts | 6 + src/renderer/src/app-state/uiShell/slice.ts | 11 + src/renderer/src/app-state/uiShell/types.ts | 39 ++++ src/renderer/src/app/main.tsx | 23 +- src/renderer/src/app/surfaces/registry.tsx | 8 + src/renderer/src/apps/api/createAppHostApi.ts | 153 ++++++++++++ src/renderer/src/apps/api/hostGlobal.ts | 65 ++++++ src/renderer/src/apps/api/types.ts | 145 ++++++++++++ src/renderer/src/apps/host/ExtensionHost.ts | 220 ++++++++++++++++++ .../src/apps/host/ExtensionHostProvider.tsx | 100 ++++++++ src/renderer/src/apps/host/derive.ts | 193 +++++++++++++++ src/renderer/src/apps/host/moduleContract.ts | 47 ++++ src/renderer/src/apps/host/registrations.ts | 59 +++++ .../src/apps/surfaces/AppHostSurface.tsx | 90 +++++++ src/renderer/src/apps/types.ts | 55 +++++ src/renderer/src/components/ui/README.md | 24 ++ src/renderer/src/ui/GlobalToast.tsx | 14 +- 17 files changed, 1248 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/apps/api/createAppHostApi.ts create mode 100644 src/renderer/src/apps/api/hostGlobal.ts create mode 100644 src/renderer/src/apps/api/types.ts create mode 100644 src/renderer/src/apps/host/ExtensionHost.ts create mode 100644 src/renderer/src/apps/host/ExtensionHostProvider.tsx create mode 100644 src/renderer/src/apps/host/derive.ts create mode 100644 src/renderer/src/apps/host/moduleContract.ts create mode 100644 src/renderer/src/apps/host/registrations.ts create mode 100644 src/renderer/src/apps/surfaces/AppHostSurface.tsx create mode 100644 src/renderer/src/apps/types.ts 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..cfa0dd06 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -50,6 +50,9 @@ export const createUiShellSlice: StateCreator< usageModalOpen: false, rewindPromptSessionId: null, agentViewModePickerSessionId: null, + openAppId: null, + installedExtensions: [], + 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 +313,14 @@ 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 }, 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..263def39 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,43 @@ 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[] + /** 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/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/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..a4d72262 --- /dev/null +++ b/src/renderer/src/apps/host/derive.ts @@ -0,0 +1,193 @@ +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) + 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/moduleContract.ts b/src/renderer/src/apps/host/moduleContract.ts new file mode 100644 index 00000000..9484029d --- /dev/null +++ b/src/renderer/src/apps/host/moduleContract.ts @@ -0,0 +1,47 @@ +import type { AgentCodeApiV1 } from '@renderer/apps/api/types' + +// WHY this lives under renderer/apps/host/ and NOT in src/shared/types/: +// it references AgentCodeApiV1, which is renderer code, and src/shared is +// compiled by the node tsconfig project which cannot see @renderer/*. That is a +// real boundary, not a build quirk — main validates MANIFESTS (shared types) and +// never touches an extension module, because modules are imported into the +// renderer realm. Putting this in shared compiled fine right up until tsc +// noticed the cross-project import, and moving it is the correct fix rather than +// widening the node project's file list. + +export type Disposable = { dispose(): void } + +/** + * A view's renderer: DOM element in, cleanup function out. + * + * WHY DOM-level rather than a React component: it keeps React optional (plain + * DOM, Preact, Svelte and canvas all work with no framework negotiation), and it + * is the one shape that survives a future move into an iframe. A React component + * reference cannot cross a frame boundary; "call mount with this element" can be + * reimplemented on the far side unchanged. A React author writes + * `createRoot(element).render()` on one line. + */ +export type ViewMount = (element: HTMLElement) => void | (() => void) + +/** + * What `activate()` receives. + * + * WHY registration is separate from declaration: the manifest declares WHAT + * exists so the palette and Settings can list it without loading anything; this + * binds the HANDLERS once the module is finally imported. An id that was never + * declared is rejected here rather than silently accepted, because a handler the + * palette has no entry for can never be invoked and the author would have no + * way to notice. + */ +export type ExtensionContext = { + readonly api: AgentCodeApiV1 + registerCommand(id: string, run: () => void | Promise): Disposable + registerView(id: string, mount: ViewMount): Disposable + /** Disposed in reverse order on deactivate. */ + readonly subscriptions: Disposable[] +} + +export type ExtensionModule = { + activate(context: ExtensionContext): void | Promise + deactivate?(): void | Promise +} diff --git a/src/renderer/src/apps/host/registrations.ts b/src/renderer/src/apps/host/registrations.ts new file mode 100644 index 00000000..2e29cc10 --- /dev/null +++ b/src/renderer/src/apps/host/registrations.ts @@ -0,0 +1,59 @@ +import type { Disposable, ViewMount } from '@renderer/apps/host/moduleContract' + +/** + * Where an activated extension's handlers live. + * + * WHY a host-owned store rather than letting each extension keep its own: the + * palette invokes a command by id long after activation, and the view host + * renders by id on demand. Both need a lookup that outlives the call to + * `activate()` and that the host — not the extension — controls the lifetime of. + * + * Keyed by extension id first so `disposeAll(extensionId)` is a single map + * delete. An extension being removed must not leave handlers behind that a stale + * palette entry could still reach. + */ +export class ExtensionRegistrations { + private commands = new Map void | Promise>>() + private views = new Map>() + + registerCommand( + extensionId: string, + commandId: string, + run: () => void | Promise, + ): Disposable { + const forExtension = this.commands.get(extensionId) ?? new Map() + forExtension.set(commandId, run) + this.commands.set(extensionId, forExtension) + return { + dispose: () => { + // Guarded by identity: a late dispose from a stale closure must not + // remove a handler that a subsequent re-activation registered. + if (forExtension.get(commandId) === run) forExtension.delete(commandId) + }, + } + } + + registerView(extensionId: string, viewId: string, mount: ViewMount): Disposable { + const forExtension = this.views.get(extensionId) ?? new Map() + forExtension.set(viewId, mount) + this.views.set(extensionId, forExtension) + return { + dispose: () => { + if (forExtension.get(viewId) === mount) forExtension.delete(viewId) + }, + } + } + + getCommand(extensionId: string, commandId: string): (() => void | Promise) | undefined { + return this.commands.get(extensionId)?.get(commandId) + } + + getView(extensionId: string, viewId: string): ViewMount | undefined { + return this.views.get(extensionId)?.get(viewId) + } + + disposeAll(extensionId: string): void { + this.commands.delete(extensionId) + this.views.delete(extensionId) + } +} diff --git a/src/renderer/src/apps/surfaces/AppHostSurface.tsx b/src/renderer/src/apps/surfaces/AppHostSurface.tsx new file mode 100644 index 00000000..6ff29c4e --- /dev/null +++ b/src/renderer/src/apps/surfaces/AppHostSurface.tsx @@ -0,0 +1,90 @@ +import { useCallback, useMemo } from 'react' + +import { useAppStore } from '@renderer/app-state/hooks' +import { createAppHostApi } from '@renderer/apps/api/createAppHostApi' +import { deriveAppDefinitions } from '@renderer/apps/host/derive' +import { useExtensionHost } from '@renderer/apps/host/ExtensionHostProvider' +import { Dialog, DialogContent, DialogTitle } from '@renderer/components/ui/dialog' +import { useGlobalToast } from '@renderer/ui/GlobalToast' + +import type { AppDefinition } from '@renderer/apps/types' + +/** + * The single surface entry that hosts every extension view. + * + * WHY DialogContent rather than a hand-rolled shell: it mounts + * `data-agent-code-interaction-owner="app"` for exactly the interval Radix traps + * focus. Seven separate consumers query that marker synchronously — the keybind + * router, the palette's native-menu guard, the global editor's Escape handler, + * the composer Enter registry, type-to-focus, paste-to-focus, and the native + * dictation hotkey. Without it, typing in an extension's input can leak Enter + * and paste into a background agent composer, and a native menu click can mutate + * the workspace underneath. `components/ui/README.md` records that exact failure + * from the old hand-rolled composer guards. Hosting extensions inside the + * primitive means they get all seven protections and cannot forget one. + */ +export function AppHostSurface() { + const openAppId = useAppStore(state => state.openAppId) + const installed = useAppStore(state => state.installedExtensions) + const host = useExtensionHost() + + // Derived per render from the store rather than a module-scope map. The + // previous version built `APP_BY_ID` once at import time, which meant a newly + // installed extension could not open until a reload. + const definitions = useMemo( + () => (host ? deriveAppDefinitions(host, installed) : []), + [host, installed], + ) + + const definition = openAppId + ? definitions.find(candidate => candidate.id === openAppId) + : undefined + + // An unknown id degrades to closed, deliberately unlike + // `providers/registry.renderer.ts`, which throws on an unknown pane kind — that + // throw is correct there, because a pane with no renderer is a broken + // workspace. It is wrong here: `openAppId` routinely holds a stale id after an + // extension is uninstalled, and throwing inside a surface App.tsx mounts + // unconditionally would take down the whole renderer. + if (!definition) return null + + return +} + +function OpenExtensionView({ definition }: { definition: AppDefinition }) { + const closeApp = useAppStore(state => state.closeApp) + const { showToast } = useGlobalToast() + + const api = useMemo( + () => + createAppHostApi({ + // The view id is namespaced `.`, and storage is keyed + // by EXTENSION, not by view — two views of one extension must share + // state, and `timer.main` must not get a different namespace from + // `timer`. + extensionId: definition.id.split('.')[0] ?? definition.id, + showToast, + closeSurface: () => closeApp(), + }), + [definition.id, showToast, closeApp], + ) + + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) closeApp() + }, + [closeApp], + ) + + return ( + + + {/* Radix requires an accessible title on every Dialog; visually hidden + because an extension owns its own header treatment. Omitting it logs + a console error and leaves the dialog unlabelled for screen readers. */} + {definition.title} + + + + ) +} diff --git a/src/renderer/src/apps/types.ts b/src/renderer/src/apps/types.ts new file mode 100644 index 00000000..e223ee52 --- /dev/null +++ b/src/renderer/src/apps/types.ts @@ -0,0 +1,55 @@ +import type { ComponentType } from 'react' + +import type { AgentCodeApiV1 } from '@renderer/apps/api/types' + +/** + * One built-in app. + * + * WHY every field except `Component` is JSON-expressible: this shape is + * deliberately the target that a future out-of-tree manifest resolves INTO. In + * Stage 2 a loader reads `id`/`title`/`description`/`keywords` from + * `agent-code.app.json` on disk and produces `Component` by importing the app's + * bundle — everything else in `apps/` stays exactly as it is. Keeping this type a + * strict superset-by-one of a manifest is what makes that a swap. + * + * The failure to avoid: adding a host-only field here — `getWorkspace: () => + * Workspace`, a store selector, a React context, anything not serializable. That + * silently converts this from a manifest target into a host-only interface, and at + * that moment Stage 1 becomes a substrate Stage 2 has to tear out rather than one + * it keeps. If an app needs a capability, it belongs in `AgentCodeApiV1`, not here. + */ +export type AppDefinition = { + /** + * Stable id. Becomes the palette command id (`app.open.`), the value held in + * `openAppId`, and the on-disk state directory name under + * `~/.config/agent-code/extensions/`. Renaming it orphans saved state and breaks + * muscle memory — treat as permanent. + * + * Must satisfy the same pattern the main process enforces in + * `main/extensions/storage.ts`: /^[a-z][a-z0-9-]{0,63}$/. A violation is not + * caught here — it surfaces as an `InvalidAppIdError` on the first storage call, + * which is deliberate: main owns that rule because main is where the id becomes + * a filesystem path, and duplicating the check here would let the two drift. + */ + id: string + title: string + /** + * REQUIRED and non-empty: this string becomes the palette command's description, + * and `buildCommandRegistry` throws on a blank description. A missing one is a + * launch crash rather than a lint warning, so the type makes it non-optional. + */ + description: string + keywords?: string[] + /** + * The app's UI. + * + * WHY exactly one prop, unlike `SurfaceEntry.Component` which deliberately takes + * none: for a first-party surface propless is right, because the surface can read + * the store directly and props would put the host back in the wiring business. + * For an app the opposite holds — the prop IS the boundary that makes the app + * portable, and `api` is the only thing an app is permitted to depend on. An app + * that imports anything from `@renderer/*` other than these two type modules has + * broken the contract; the check is `grep -rn "@renderer/" src/renderer/src/apps//`. + */ + Component: ComponentType<{ api: AgentCodeApiV1 }> +} diff --git a/src/renderer/src/components/ui/README.md b/src/renderer/src/components/ui/README.md index 74ca4375..f61c8653 100644 --- a/src/renderer/src/components/ui/README.md +++ b/src/renderer/src/components/ui/README.md @@ -207,3 +207,27 @@ The success condition is intentionally boring: contributors and agents reach for familiar names, compose a small local primitive, and keep feature logic in the feature. If this directory starts requiring an architectural diagram to add a button, it has violated its purpose. + +## Why the built-in apps host is not a "surface factory" + +`apps/surfaces/AppHostSurface` renders one of N app components inside a standard +`DialogContent`. It is worth recording why that does not violate the "no generic +modal schema, JSON-driven form renderer, or surface factory" rule above, because +it superficially resembles one. + +It generates nothing. It is a single ordinary entry in the surface registry that +switches on one store field (`openAppId`) and resolves it through a compile-time +array (`apps/registry.ts`). There is no schema, no synthesized shell, and no +second modal mechanism — apps get the same `DialogContent` every other surface +gets, which is the point: that is where the interaction-ownership marker and +Radix's focus trap live, and an app must not be able to opt out of either. + +The guardrail targets first-party component proliferation, where a factory hides +which surfaces exist. Here the surfaces are a single greppable array sitting +beside the registry it mirrors. + +Worth knowing for whoever revisits this: the two alternative hosting designs — +loading app bundles at runtime, or rendering them in an iframe — both *would* +require a genuine factory, since a loader must synthesize mount points from +manifests. This guardrail is therefore an argument in favour of the compiled-in +staging, not against it. diff --git a/src/renderer/src/ui/GlobalToast.tsx b/src/renderer/src/ui/GlobalToast.tsx index bde4a7ee..df4c9dcc 100644 --- a/src/renderer/src/ui/GlobalToast.tsx +++ b/src/renderer/src/ui/GlobalToast.tsx @@ -12,6 +12,18 @@ import { createContext, useCallback, useContext, useRef, useState } from 'react' // dismiss: warning-grade toasts (hotkey failures) use long durations so // the user actually sees them, and a 10-second banner you can't get rid // of reads as broken UI — one click clears it early. +// +// WHY z-[1200] and not z-50: the toast sat below the Radix dialog scrim +// (z-[1100], 88% opaque) so any toast raised while a modal was open was +// washed out to invisibility. That went unnoticed for as long as every caller +// was non-modal chrome — TileLeaf, SafeInlineCode, SafeMarkdownLink. It broke +// the moment extensions started toasting from inside a hosted view, where the +// toast can ONLY fire while a dialog is open, making it hidden 100% of the time. +// +// Raising the toast rather than lowering the dialog is the correct direction: a +// toast is by definition the topmost transient layer, and every existing caller +// is unaffected by it moving up, whereas lowering the dialog would break the +// modal stacking the entire surface registry depends on. type GlobalToastContextValue = { showToast: (message: string, durationMs?: number) => void @@ -54,7 +66,7 @@ export function GlobalToastProvider({ children }: { children: React.ReactNode }) onClick={dismiss} title="Dismiss" className=" - fixed top-3 right-3 z-50 + fixed top-3 right-3 z-[1200] toast-enter cursor-pointer bg-accent/80 border border-accent/40 From 09f890ae164f7579eab0a910d1b7eb6e15d9b23f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 21:46:20 +0200 Subject: [PATCH 03/22] feat(extensions): install extensions from a GitHub repo, with consent Co-Authored-By: Claude Opus 4.8 --- src/main/extensions/install.ts | 279 +++++++++++++++++++ src/main/extensions/ledger.ts | 114 ++++++++ src/main/ipc/extensions.ts | 121 ++++++++ src/main/ipc/index.ts | 5 + src/preload/api/extensions.ts | 45 +++ src/preload/api/index.ts | 2 + src/renderer/src/apps/ui/AppsSettingsRow.tsx | 229 +++++++++++++++ 7 files changed, 795 insertions(+) create mode 100644 src/main/extensions/install.ts create mode 100644 src/main/extensions/ledger.ts create mode 100644 src/main/ipc/extensions.ts create mode 100644 src/preload/api/extensions.ts create mode 100644 src/renderer/src/apps/ui/AppsSettingsRow.tsx diff --git a/src/main/extensions/install.ts b/src/main/extensions/install.ts new file mode 100644 index 00000000..abef99a0 --- /dev/null +++ b/src/main/extensions/install.ts @@ -0,0 +1,279 @@ +import { spawn } from 'child_process' +import { createHash } from 'node:crypto' +import { access, constants as fsConstants, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join, 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)) + } +} + +/** + * 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 mkdtemp(join(tmpdir(), 'agent-code-ext-')) + 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) + + // 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 (the temp dir is cleaned by finally), so nothing is left behind. A + // Tier-0-only extension installs with no prompt, matching the "repo name is the + // trust decision" stance. Inserted here, with the manifest in hand and before + // the ledger write, mirroring WorkflowSourceApprovalStore's gate placement. + 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(staging, finalDir) + + const record: InstalledExtension = { + manifest, + repo, + ref: source.ref, + 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, sha256, permissions) + else await revokeGrant(manifest.id) + + return record + } 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..f3d251ab --- /dev/null +++ b/src/main/extensions/ledger.ts @@ -0,0 +1,114 @@ +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 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 { + 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/ipc/extensions.ts b/src/main/ipc/extensions.ts new file mode 100644 index 00000000..c86d2bb0 --- /dev/null +++ b/src/main/ipc/extensions.ts @@ -0,0 +1,121 @@ +import { BrowserWindow, dialog, ipcMain } from 'electron' + +import { + extensionStorageDelete, + extensionStorageGet, + extensionStorageKeys, + extensionStorageSet, +} from '@main/extensions/storage.js' +import { installExtension } 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' + +// 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 { + // The consent prompt for capability-requesting extensions. 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 the + // user clicks through. Tier-0-only extensions never reach this (installExtension + // only calls it when permissions is non-empty). + const record = await installExtension(repo, 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 + }) + 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 dfdaf127..007e3f29 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -45,6 +45,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. // @@ -110,5 +111,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/preload/api/extensions.ts b/src/preload/api/extensions.ts new file mode 100644 index 00000000..dd9f8d89 --- /dev/null +++ b/src/preload/api/extensions.ts @@ -0,0 +1,45 @@ +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), + + 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 ecf4e568..9f2c63f4 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -26,6 +26,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. @@ -72,6 +73,7 @@ export const api = { ...usageApi, ...cliUpdatesApi, ...workflowsApi, + ...extensionsApi, ...agentCodeConventionsApi, } diff --git a/src/renderer/src/apps/ui/AppsSettingsRow.tsx b/src/renderer/src/apps/ui/AppsSettingsRow.tsx new file mode 100644 index 00000000..db1dcab4 --- /dev/null +++ b/src/renderer/src/apps/ui/AppsSettingsRow.tsx @@ -0,0 +1,229 @@ +import { useCallback, useEffect, useState } from 'react' + +import { useAppStore } from '@renderer/app-state/hooks' +import { useExtensionHost } from '@renderer/apps/host/ExtensionHostProvider' + +import type { ExtensionListEntry } from '@shared/types/extensions' + +/** + * Settings → Extensions. Install from a GitHub repository, list what is installed, + * remove. + * + * WHY this row owns its own state and IPC rather than reading a store: same + * self-subscribing marker pattern as CliUpdateBehaviorRow and DictationApiKeyRow — + * the truth lives in main (`extensions.json` plus the bundle directories), not in + * the renderer's Settings, and mirroring it into zustand-persist would put + * third-party-derived data into the blob whose version bumps have black-screened + * launch twice (#249). + * + * WHY installing is deliberately a paste-a-repo box and not a browsable directory: + * there is no registry to browse, and inventing one would mean hosting an index + * before a single extension exists. The repo name IS the trust decision — the user + * typing `owner/repo` is the consent step, which is why there is no second + * confirmation dialog for a first install. + */ +export function AppsSettingsRow() { + const [entries, setEntries] = useState(null) + const [repo, setRepo] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + + // Push the refreshed list into the store as well as local state: the palette + // and the view host read from the store, so an install that only updated this + // component would add an extension nobody else could see until a reload. + const setInstalledExtensions = useAppStore(state => state.setInstalledExtensions) + const failures = useAppStore(state => state.extensionFailures) + // Needed to tear a live extension down on remove/update. Main has no handle on + // the renderer-side host, so deactivation can only be driven from here — without + // it a removed extension's intervals/listeners/registrations leak for the session. + const extensionHost = useExtensionHost() + + const refresh = useCallback(async () => { + try { + const listed = await window.api.extensionsList() + setEntries(listed) + setInstalledExtensions(listed) + } catch (listError) { + // A failed list is not the same as an empty list, and rendering "no + // extensions" over an IPC failure would be a lie the user acts on. + setEntries([]) + setError(listError instanceof Error ? listError.message : String(listError)) + } + }, [setInstalledExtensions]) + + useEffect(() => { + void refresh() + }, [refresh]) + + // `target` is explicit rather than always read from `repo` so the Update button + // can install a specific entry's repo. The previous version did + // `setRepo(entry.repo); void install()`, but `setRepo` is async and `install` + // closed over the OLD `repo`, so Update installed the empty/last-typed value — + // the no-op bug. Passing the target directly removes the closure dependency. + const install = useCallback( + async (target?: string) => { + const repoTarget = (target ?? repo).trim() + if (!repoTarget || busy) return + + setBusy(true) + setError(null) + setNotice(null) + try { + const result = await window.api.extensionsInstall(repoTarget) + if (result.ok) { + if (target === undefined) setRepo('') + setNotice(`Installed ${result.entry.manifest.name} ${result.entry.manifest.version}`) + } else { + setError(result.error) + } + } catch (installError) { + // installExtension returns {ok:false} for every anticipated failure, so + // reaching here means the IPC itself broke — worth showing distinctly rather + // than swallowing. + setError(installError instanceof Error ? installError.message : String(installError)) + } finally { + setBusy(false) + await refresh() + } + }, + [repo, busy, refresh], + ) + + // Update = reinstall over the existing bundle (install.ts does rm+rename). The + // OLD module is still loaded in the renderer's ESM realm with live subscriptions; + // deactivate it first so its disposers run before the bundle is replaced. The new + // bundle re-activates lazily (or on next startup) under the version+sha cache key. + const update = useCallback( + async (entry: ExtensionListEntry) => { + await extensionHost?.deactivate(entry.manifest.id) + await install(entry.repo) + }, + [extensionHost, install], + ) + + const remove = useCallback( + async (entry: ExtensionListEntry) => { + setError(null) + setNotice(null) + try { + // Tear the live extension down BEFORE deleting its files: deactivate() + // disposes the loaded module's subscriptions and registrations (it works + // off the in-memory module, not the bundle on disk). Skipping this leaked + // intervals/listeners for the rest of the session. + await extensionHost?.deactivate(entry.manifest.id) + await window.api.extensionsRemove(entry.manifest.id) + setNotice(`Removed ${entry.manifest.name}`) + } catch (removeError) { + setError(removeError instanceof Error ? removeError.message : String(removeError)) + } finally { + await refresh() + } + }, + [extensionHost, refresh], + ) + + return ( +
+
+ setRepo(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + // Stop the Enter reaching Settings' own handlers or, worse, a + // background composer. The interaction-ownership marker on the + // Settings takeover root covers the global routers, but the local + // keydown still needs to not bubble into Settings itself. + event.preventDefault() + event.stopPropagation() + void install() + } + }} + placeholder="owner/repo or a github.com URL" + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + disabled={busy} + className="min-w-0 flex-1 border border-input-border bg-input-bg px-2 py-1.5 text-[13px] text-ink outline-none placeholder:text-input-placeholder focus:border-input-border-focus disabled:opacity-50" + /> + +
+ + {error ? ( +
{error}
+ ) : null} + {notice ?
{notice}
: null} + + {entries === null ? ( +
Loading…
+ ) : entries.length === 0 ? ( +
+ No extensions installed. Paste a repository above to install one. +
+ ) : ( +
+ {entries.map(entry => ( +
+
+
+ {entry.manifest.name} + {entry.manifest.version} + {/* A ledger row whose bundle is gone. Shown rather than filtered: + the fix is reinstalling from the recorded repo, and hiding it + would leave the user wondering where the extension went. */} + {!entry.present ? ( + · files missing + ) : null} + {/* An extension whose module threw during import or activate. + Shown here rather than only in the console: a silently + missing extension is undiagnosable for whoever installed + it, and the message names the actual fault. */} + {failures.some(failure => failure.id === entry.manifest.id) ? ( + · failed to start + ) : null} +
+
{entry.manifest.description}
+
+ {entry.repo} @ {entry.ref} +
+ {failures.find(failure => failure.id === entry.manifest.id) ? ( +
+ {failures.find(failure => failure.id === entry.manifest.id)?.error} +
+ ) : null} +
+
+ + +
+
+ ))} +
+ )} +
+ ) +} From e3f22eb716d97f30c3479058d1959e36e18e238a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 21:46:20 +0200 Subject: [PATCH 04/22] feat(extensions): privileged agent-code-ext:// scheme Co-Authored-By: Claude Opus 4.8 --- src/main/extensions/scheme.ts | 232 ++++++++++++++++++++++++++++++++++ src/main/index.ts | 19 +++ 2 files changed, 251 insertions(+) create mode 100644 src/main/extensions/scheme.ts diff --git a/src/main/extensions/scheme.ts b/src/main/extensions/scheme.ts new file mode 100644 index 00000000..4a5c2a8e --- /dev/null +++ b/src/main/extensions/scheme.ts @@ -0,0 +1,232 @@ +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 { 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:///` + */ +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 }) + } + + const extensionId = url.hostname + if (!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 }) + + const nonce = randomBytes(16).toString('base64') + const html = buildFrameDocument({ + 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), + '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. + // + // `*` is safe here because the scheme is only reachable from inside this + // app's renderer, and the handler above has already proven the path is + // contained. There is no ambient authority to leak: this origin serves + // extension bundle files and nothing else. + 'access-control-allow-origin': '*', + // 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/index.ts b/src/main/index.ts index 620c82ad..2682e3db 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, @@ -215,6 +219,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) { @@ -445,6 +458,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) From 03f80f99c82410af2f817f75c75d617bf529c2ce Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 21:46:20 +0200 Subject: [PATCH 05/22] feat(extensions): manifest, contributions & capability declarations Co-Authored-By: Claude Opus 4.8 --- src/main/extensions/manifest.ts | 285 ++++++++++++++++++++++++++++++++ src/shared/types/extensions.ts | 156 +++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 src/main/extensions/manifest.ts create mode 100644 src/shared/types/extensions.ts 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/shared/types/extensions.ts b/src/shared/types/extensions.ts new file mode 100644 index 00000000..05076069 --- /dev/null +++ b/src/shared/types/extensions.ts @@ -0,0 +1,156 @@ +// Extension manifest and install-ledger shapes, shared by main and renderer. +// +// The manifest is authored by a third party and read off disk, so main validates it +// with zod (see main/extensions/manifest.ts) and the renderer only ever sees the +// validated result. These types are the contract between those two halves — the zod +// schema is the source of truth for VALIDATION, and this file is the source of truth +// for the SHAPE. If they drift, the schema wins and this file is the bug. + +/** Where a contributed view is rendered. The host owns the chrome; an extension + * declares the KIND of surface it wants, not the pixels around it. Only 'modal' + * is implemented in v1 — the others are the same registration with a different + * host shell, and are reserved so a manifest written for them stays valid. */ +export type ExtensionViewMount = 'modal' | 'panel' + +export type ExtensionCommandContribution = { + /** Must be namespaced `.` — enforced at install. */ + id: string + title: string + description?: string + keywords?: string[] +} + +export type ExtensionViewContribution = { + id: string + title: string + mount: ExtensionViewMount +} + +export type ExtensionSettingContribution = + | { id: string; title: string; description?: string; type: 'boolean'; default: boolean } + | { id: string; title: string; description?: string; type: 'number'; default: number } + | { id: string; title: string; description?: string; type: 'string'; default: string } + +export type ExtensionKeybindingContribution = { + /** A command id this manifest also contributes. */ + command: string + /** Accelerator, e.g. `cmd+shift+t`. Consulted AFTER every first-party + * binding, so an extension can never shadow ⌘W. */ + key: string +} + +export type ExtensionContributions = { + commands?: ExtensionCommandContribution[] + views?: ExtensionViewContribution[] + settings?: ExtensionSettingContribution[] + keybindings?: ExtensionKeybindingContribution[] +} + +/** + * A power an extension can REQUEST beyond the always-granted Tier-0 API + * (storage/ui/theme, which need no permission). A closed set: an unknown + * capability fails validation at install, exactly as an unknown activation event + * does, rather than being silently ignored. + * + * Tiered by blast radius (see the platform plan): Tier 1 is read-only metadata, + * Tier 2 reads real user content, Tier 3 acts. Enforcement of Tiers 2-3 is only + * meaningful once each extension runs in its own frame — which it now does — so the + * grant a user gives at install (grants.ts) is what a capability check consults. + */ +export type ExtensionCapability = + // Tier 1 — read-only metadata + | 'workspace.observe' + | 'sessions.observe' + | 'panes.observe' + // Tier 2 — reads the user's actual content + | 'fs.read' + | 'transcript.read' + | 'git.read' + // Tier 3 — acts + | 'sessions.prompt' + | 'fs.write' + | 'git.commit' + | 'network.fetch' + +export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [ + 'workspace.observe', + 'sessions.observe', + 'panes.observe', + 'fs.read', + 'transcript.read', + 'git.read', + 'sessions.prompt', + 'fs.write', + 'git.commit', + 'network.fetch', +] + +/** + * When the host imports and activates an extension's module. + * + * The whole reason contributions are DECLARED rather than registered by running + * the extension: the palette and Settings can list everything an extension + * offers while its module has never been loaded, so activation can be deferred + * to first use. Without declarations, populating a command list would require + * importing every installed extension at startup. + */ +export type ExtensionActivationEvent = + | 'onStartupFinished' + | '*' + | `onCommand:${string}` + | `onView:${string}` + +/** Contents of `agent-code.extension.json` at the root of an extension repository. */ +export type ExtensionManifest = { + /** Stable identity. Also the install directory name and the storage namespace, + * so it is constrained to /^[a-z][a-z0-9-]{0,63}$/ — see manifest.ts. */ + id: string + name: string + description: string + /** Author's own version string. Displayed and recorded; not interpreted. */ + version: string + /** Which AgentCodeApi major this extension was written against. The host refuses + * to load a manifest whose apiVersion it does not implement, which is the whole + * point of versioning the ABI rather than silently passing a newer host object + * to an extension written for an older one. */ + apiVersion: number + /** Path, relative to the repository root, of the built ES module to load. + * Must stay inside the bundle — validated, not trusted. */ + entry: string + keywords?: string[] + /** Absent means "never activate" — legal, and how a manifest that only + * contributes settings behaves. */ + activationEvents?: ExtensionActivationEvent[] + contributes?: ExtensionContributions + /** Capabilities beyond Tier 0 this extension requests. The user grants (or + * declines) them at install; absent/empty means Tier 0 only. */ + permissions?: ExtensionCapability[] +} + +/** One row in the install ledger (`extensions.json`). */ +export type InstalledExtension = { + manifest: ExtensionManifest + /** `owner/repo` as the user typed it, normalized. */ + repo: string + /** The git ref actually installed — a release tag when one exists, else the + * default branch name. Recorded so an update can report what it is moving from. */ + ref: string + /** SHA-256 of the downloaded tarball. This is the integrity record: it is what a + * reinstall compares against, and what makes "which bytes am I running" an + * answerable question rather than a guess. */ + sha256: string + /** Epoch millis. */ + installedAt: number +} + +/** What the Settings UI renders for each installed extension. */ +export type ExtensionListEntry = InstalledExtension & { + /** False when the ledger has a row but the bundle directory is missing or its + * entry file is absent — a half-removed or hand-deleted install. Surfaced rather + * than hidden so the user can see why an extension stopped appearing. */ + present: boolean +} + +export type ExtensionInstallResult = + | { ok: true; entry: ExtensionListEntry } + | { ok: false; error: string } From 89dca7e659e74c0bba2a3e03d951f9a5ca6cf67e Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 21:46:20 +0200 Subject: [PATCH 06/22] feat(extensions): wire contributions into command, keybinding & settings systems Co-Authored-By: Claude Opus 4.8 --- .../src/apps/ui/ExtensionSettingRow.tsx | 110 ++++++++++++++++ .../src/features/command-palette/registry.ts | 53 ++++++-- .../src/features/command-palette/types.ts | 11 ++ .../command-palette/ui/CommandPalette.tsx | 39 +++++- .../settings/lib/settingsCategories.ts | 10 ++ .../features/settings/lib/settingsRegistry.ts | 120 +++++++++++++++++- .../settings/ui/CommandKeybindingsRow.tsx | 35 ++++- .../settings/ui/KeyboardShortcutsModal.tsx | 24 +++- .../src/features/settings/ui/SettingsList.tsx | 18 +++ .../src/features/settings/ui/SettingsPage.tsx | 26 +++- .../src/workspace/tile-tree/useKeybinds.ts | 26 +++- 11 files changed, 445 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/apps/ui/ExtensionSettingRow.tsx diff --git a/src/renderer/src/apps/ui/ExtensionSettingRow.tsx b/src/renderer/src/apps/ui/ExtensionSettingRow.tsx new file mode 100644 index 00000000..cd04196e --- /dev/null +++ b/src/renderer/src/apps/ui/ExtensionSettingRow.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * One extension-contributed setting row. + * + * WHY this is a self-subscribing marker row and not a generic `toggle`/`select` + * control: the value must NOT live in the zustand-persist Settings blob. That blob + * needs a persist-version bump on every field change (a forgotten one black-screened + * launch twice, #249), and an extension's fields are authored outside the app's + * release cycle, so nobody can ever bump the version for them. So the value lives in + * the extension's own main-owned storage (`EXTENSION_STATE_DIR//state.json`, via + * `window.api.extensionStorage*`), and this row reads/writes it over IPC — exactly + * the pattern DictationApiKeyRow and CliUpdateBehaviorRow use for their off-blob + * state. + * + * The storage key is the setting's contribution id, and the appId is the extension + * id, so two extensions can never collide and uninstalling one leaves the other's + * values untouched. A missing stored value falls back to the manifest `default`. + */ +type Props = { + extensionId: string + settingId: string + valueType: 'boolean' | 'number' | 'string' + defaultValue: boolean | number | string +} + +export function ExtensionSettingRow({ extensionId, settingId, valueType, defaultValue }: Props) { + const [value, setValue] = useState(defaultValue) + const [error, setError] = useState(null) + // A late IPC read must not clobber an edit the user made while it was in flight. + const editedRef = useRef(false) + + useEffect(() => { + let cancelled = false + void (async () => { + try { + const stored = await window.api.extensionStorageGet(extensionId, settingId) + // Only adopt the stored value if it is still the right shape AND the user + // has not already typed something. A hand-corrupted state.json of the wrong + // type falls back to the default rather than rendering a broken control. + if (cancelled || editedRef.current) return + if (stored !== undefined && typeof stored === valueType) { + setValue(stored as boolean | number | string) + } + } catch (readError) { + if (!cancelled) setError(readError instanceof Error ? readError.message : String(readError)) + } + })() + return () => { + cancelled = true + } + }, [extensionId, settingId, valueType]) + + const persist = (next: boolean | number | string) => { + editedRef.current = true + setValue(next) + setError(null) + // Write-through. The store rejects non-finite numbers, so a NaN can never land. + void window.api + .extensionStorageSet(extensionId, settingId, next) + .catch(writeError => setError(writeError instanceof Error ? writeError.message : String(writeError))) + } + + return ( +
+ {valueType === 'boolean' ? ( + + ) : null} + + {valueType === 'number' ? ( + { + const parsed = Number(event.target.value) + // Ignore an unparseable/blank field rather than writing NaN — the store + // would reject it anyway, and clobbering the value mid-edit is hostile. + if (Number.isFinite(parsed)) persist(parsed) + }} + className="w-full border border-input-border bg-input-bg px-2 py-1.5 text-[13px] text-ink outline-none focus:border-input-border-focus" + /> + ) : null} + + {valueType === 'string' ? ( + persist(event.target.value)} + spellCheck={false} + className="w-full border border-input-border bg-input-bg px-2 py-1.5 text-[13px] text-ink outline-none focus:border-input-border-focus" + /> + ) : null} + + {error ?
{error}
: null} +
+ ) +} diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index bcc5708b..2567e1f2 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -1,6 +1,8 @@ import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' import { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' import { declaredTier, isVisibleInPicker } from '@renderer/features/command-palette/pickerVisibility' +import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import type { CommandBindingDefault } from '@renderer/features/command-keybindings/defaults' import { displayKeybinding } from '@renderer/features/command-keybindings/normalize' import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' import { commandAllowedByRenderedViewPolicy } from '@renderer/workspace/agentDisplayMode' @@ -14,9 +16,16 @@ import type { ResolvedCommand, } from '@renderer/features/command-palette/types' -// The ordered command list now lives in `catalog.ts`, which is context-free by -// contract. This module keeps only the question that NEEDS a context: what -// should the picker show right now. See catalog.ts for why the split exists. +// The ordered first-party command list now lives in `catalog.ts`, which is +// context-free by contract. This module keeps only the question that NEEDS a +// context: what should the picker show right now. See catalog.ts for why the split +// exists. +// +// Extension commands are deliberately NOT in the catalog — they are derived from +// installed manifests and concatenated per call (see allCommandDefs). A frozen +// module-scope array cannot gain a command when an extension is installed without a +// reload, which is exactly the module-scope-snapshot mistake an adversarial audit +// flagged; the per-call concat is the fix. const commandDefs: readonly CommandDef[] = builtInCommandCatalog /** @@ -150,16 +159,38 @@ function renderedViewAvailable(command: CommandDef, ctx: CommandContext): boolea }) } -export function buildCommandRegistry(ctx: CommandContext): ResolvedCommand[] { +/** + * Every command available right now: the first-party catalog plus whatever the + * installed extensions declare. + * + * Extension commands go LAST so they browse after first-party ones in the + * empty-query list — registry order is the palette's browse order, and putting + * third-party entries above the app's own would reshuffle a list people navigate + * by position. + */ +function allCommandDefs(extensionCommands: readonly CommandDef[]): readonly CommandDef[] { + return extensionCommands.length === 0 + ? commandDefs + : [...commandDefs, ...extensionCommands] +} + +export function buildCommandRegistry( + ctx: CommandContext, + extensionCommands: readonly CommandDef[] = [], + // Extension keybinding defaults, so an extension command's row shows its shipped + // chord — the same combined table the router fires from and the editor displays. + extensionKeybindings: readonly CommandBindingDefault[] = [], +): ResolvedCommand[] { // Built once per registry pass rather than per command: resolving the // effective set walks every default, so doing it inside the map would be // O(commands x defaults) on every palette keystroke. const effective = new Map( - resolveEffectiveKeybindings(ctx.flags.commandKeybindingOverrides).map( - entry => [entry.commandId, entry.bindings], - ), + resolveEffectiveKeybindings(ctx.flags.commandKeybindingOverrides, [ + ...buildDefaultKeybindings(), + ...extensionKeybindings, + ]).map(entry => [entry.commandId, entry.bindings]), ) - return commandDefs + return allCommandDefs(extensionCommands) .filter(command => !PALETTE_SELF_EXCLUDED_COMMAND_IDS.has(command.id)) .filter(command => commandApplicable(command, ctx) && commandVisible(command, ctx)) .map(command => { @@ -216,8 +247,10 @@ export type PickerCommandMeta = { * id is a stable, recognizable stand-in for a settings row and avoids * inventing a dummy context purely for a display string. */ -export function listPickerCommandMeta(): PickerCommandMeta[] { - return commandDefs +export function listPickerCommandMeta( + extensionCommands: readonly CommandDef[] = [], +): PickerCommandMeta[] { + return allCommandDefs(extensionCommands) // Commands the palette structurally never renders must not appear here // either. `open-command-palette` was getting a Settings switch that could // never change anything — buildCommandRegistry filters it out BEFORE any diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index 9731b05f..251f04c7 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -65,6 +65,14 @@ export type CommandCategory = | 'preferences' /** Diagnostics, recording, raw inspection, or support artifacts. */ | 'developer' + /** + * Contributed by an installed extension, not a first-party command. Kept as a + * distinct category so the keybind editor and any grouped command UI list + * extension commands under their own heading — and so a third party can never + * masquerade as a first-party group. Extension commands are assigned this + * category at derivation time; the manifest never sets a category itself. + */ + | 'extensions' /** * A closed family of commands controlled as ONE product unit. @@ -201,6 +209,9 @@ export type CommandContext = { openCloseOldAgents: () => void openBulkProviderSwitch: () => void openRewindPrompt: (sessionId: string) => void + /** Open a built-in app by its AppDefinition id. The host surface resolves the + * id and treats a miss as closed, so a stale id here cannot throw. */ + openApp: (appId: string) => void openAgentViewModePicker: (sessionId: string) => void /** Open the Dispatch color-flag swatch picker for a session. */ openColorFlagPicker: (sessionId: string) => void diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index 6225ec57..72bc9a6a 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -65,6 +65,8 @@ import type { PromptTemplateVariableValueMap, } from '@renderer/features/prompt-templates/types' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' +import { deriveExtensionCommands, deriveExtensionKeybindings } from '@renderer/apps/host/derive' +import { useExtensionHost } from '@renderer/apps/host/ExtensionHostProvider' import { resolveAgentPaneLabel } from '@renderer/workspace/tile-tree/paneLabels' import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext' import type { PaletteMode } from '@renderer/features/command-palette/paletteMode' @@ -269,6 +271,7 @@ function OpenCommandPalette({ const closePinAgents = useAppStore(state => state.closePinAgents) const closePathPicker = useAppStore(state => state.closePathPicker) const openUsageModal = useAppStore(state => state.openUsageModal) + const openApp = useAppStore(state => state.openApp) const toggleGitBar = useAppStore(state => state.toggleGitBar) const toggleWorktreesBar = useAppStore(state => state.toggleWorktreesBar) const toggleDebugPanel = useAppStore(state => state.toggleDebugPanel) @@ -634,7 +637,13 @@ function OpenCommandPalette({ enterAiWorkspaceOpenMode, enterAiWorkspaceCreateMode, enterAiWorkspaceClearMode, + openApp, closePalette: onClose, + // NOTE: openApp is in the dep array below alongside every other store + // action. Zustand action identities are stable, so omitting it was + // benign — but it would become a stale closure the instant that + // assumption changed, and there is no lint rule in this repo to catch + // it (no eslint config, no `lint` script). }, flags: { statusModeEnabled, @@ -706,6 +715,7 @@ function OpenCommandPalette({ closePinAgents, closePathPicker, openUsageModal, + openApp, toggleGitBar, toggleWorktreesBar, toggleDebugPanel, @@ -788,7 +798,34 @@ function OpenCommandPalette({ ], ) - const commands = useMemo(() => buildCommandRegistry(commandContext), [commandContext]) + // Extension commands are derived from installed MANIFESTS, not from loaded + // modules — that is what lets the palette list an extension's commands before + // a single byte of it has been imported. `run` activates on demand. + const installedExtensions = useAppStore(state => state.installedExtensions) + const extensionHost = useExtensionHost() + const extensionCommands = useMemo( + () => + extensionHost + ? deriveExtensionCommands( + extensionHost, + installedExtensions, + openApp, + workspace.openExtensionViewInPane, + ) + : [], + [extensionHost, installedExtensions, openApp, workspace.openExtensionViewInPane], + ) + // Extension keybinding defaults, so a palette row for an extension command shows + // its shipped chord. Independent of the host (manifests only), unlike commands. + const extensionKeybindings = useMemo( + () => deriveExtensionKeybindings(installedExtensions), + [installedExtensions], + ) + + const commands = useMemo( + () => buildCommandRegistry(commandContext, extensionCommands, extensionKeybindings), + [commandContext, extensionCommands, extensionKeybindings], + ) const promptTemplates = useMemo( () => allPromptTemplates(customPromptTemplates), diff --git a/src/renderer/src/features/settings/lib/settingsCategories.ts b/src/renderer/src/features/settings/lib/settingsCategories.ts index 33f0457f..b4ffdcd0 100644 --- a/src/renderer/src/features/settings/lib/settingsCategories.ts +++ b/src/renderer/src/features/settings/lib/settingsCategories.ts @@ -6,6 +6,7 @@ export type SettingCategoryId = | 'dictation' | 'experimental' | 'safety' + | 'apps' export type SettingCategory = { id: SettingCategoryId @@ -49,4 +50,13 @@ export const SETTING_CATEGORIES: SettingCategory[] = [ label: 'Safety', description: 'Defaults that change agent risk posture.', }, + // Last on purpose: apps are additive tooling rather than a setting that changes + // how the shell or an agent behaves, so they should not push the behavioural + // categories down the list. In Stage 2 this becomes the install/manage surface + // and the position can be revisited then, with a reason. + { + id: 'apps', + label: 'Extensions', + description: 'Install extensions from a GitHub repository.', + }, ] diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index 15de39ce..cce99b20 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -17,6 +17,8 @@ import type { SettingCategoryId } from '@renderer/features/settings/lib/settings import { listPickerCommandMeta } from '@renderer/features/command-palette/registry' import { isVisibleInPicker } from '@renderer/features/command-palette/pickerVisibility' import type { PickerCommandMeta } from '@renderer/features/command-palette/registry' +import type { CommandDef } from '@renderer/features/command-palette/types' +import type { ExtensionListEntry } from '@shared/types/extensions' import type { ConfigurableBuiltInMcpDomain } from '@mcp/shared/types' import type { MouseButtonBinding } from '@renderer/lib/mouseBinding' import { coerceMouseChordBinding } from '@renderer/lib/mouseBinding' @@ -206,6 +208,24 @@ export type SettingDefinition = type: 'dictation-api-key' } } + | { + id: string + category: SettingCategoryId + title: string + description: string + keywords: string[] + metadata?: SettingMetadata + // Marker for the extension installer. Same escape hatch as + // cli-update-behavior: the truth lives in main (extensions.json plus the + // bundle directories on disk), not in Settings, so the row owns its own + // IPC round-trip. It is also the only row that is a whole interactive + // surface — a text input, an install action, and a list with per-row + // update/remove — which no generic control type can express. + // See apps/ui/AppsSettingsRow.tsx. + control: { + type: 'apps' + } + } | { id: string category: SettingCategoryId @@ -266,6 +286,28 @@ export type SettingDefinition = onResetVisibility: (ctx: SettingActionContext) => void } } + | { + id: string + category: SettingCategoryId + title: string + description: string + keywords: string[] + metadata?: SettingMetadata + // Marker for one extension-contributed setting. Self-subscribing like the + // other markers, because the VALUE lives in the extension's main-owned + // storage (EXTENSION_STATE_DIR), never the zustand-persist blob (#249). The + // payload is everything the row needs to read/write that store; the row owns + // its own IPC round-trip. See apps/ui/ExtensionSettingRow.tsx. + control: { + type: 'extension' + /** Storage namespace (appId) — the owning extension's id. */ + extensionId: string + /** Storage key — the setting's `.` contribution id. */ + settingId: string + valueType: 'boolean' | 'number' | 'string' + default: boolean | number | string + } + } const ACCENT_OPTIONS: ChoiceOption[] = ACCENTS.map(accent => ({ value: accent.id, @@ -362,13 +404,67 @@ function updateDefaultBuiltInMcpDomain( ctx.onChange({ defaultBuiltInMcpDomains: next }) } -export function getSettingsRegistry(): SettingDefinition[] { - // Resolved once per registry build. The command catalog is static for - // the lifetime of the app (it's the flat `commandDefs` array), so - // there's no reason to recompute it per render. - const pickerCommands = listPickerCommandMeta() +/** + * `SettingDefinition`s for every contributed extension setting. Reads manifests + * only (no bundle import), the settings sibling of deriveExtensionCommands. Rows + * are the self-subscribing 'extension' marker; the value lives in per-extension + * storage, never the Settings blob. First-wins on a cross-extension id collision, + * matching the command/keybinding derivations. + */ +function deriveExtensionSettings( + installed: readonly ExtensionListEntry[], +): SettingDefinition[] { + const seen = new Set() + const rows: SettingDefinition[] = [] + + for (const entry of installed) { + if (!entry.present) continue + for (const setting of entry.manifest.contributes?.settings ?? []) { + if (seen.has(setting.id)) continue + seen.add(setting.id) + rows.push({ + id: setting.id, + category: 'apps', + // Prefix with the extension name so a flat Extensions category still reads + // as grouped by owner, and two extensions' identically-titled settings stay + // distinguishable. + title: `${entry.manifest.name}: ${setting.title}`, + description: setting.description ?? '', + keywords: [entry.manifest.name, setting.title, entry.manifest.id], + control: { + type: 'extension', + extensionId: entry.manifest.id, + settingId: setting.id, + valueType: setting.type, + default: setting.default, + }, + }) + } + } + + return rows +} + +export function getSettingsRegistry( + // Extension-contributed commands, derived from installed manifests by the + // caller. Threaded in so the "Commands" category lists them alongside + // first-party commands — which is also what makes them assignable a keybinding + // in the editor, since it iterates this same catalog. Empty default keeps every + // non-extension caller unchanged. The first-party catalog is static, but the + // extension set changes on install/remove, so this is no longer a per-lifetime + // constant and must be recomputed when the caller's extension list changes. + extensionCommands: readonly CommandDef[] = [], + // Installed extensions, so contributed settings render as rows. Same empty + // default + install/remove-recompute reasoning as extensionCommands above. + installedExtensions: readonly ExtensionListEntry[] = [], +): SettingDefinition[] { + const pickerCommands = listPickerCommandMeta(extensionCommands) return [ + // Extension-contributed settings, one row each, under the Extensions category. + // Derived from manifests (no bundle import); an uninstall drops the manifest + // from installedExtensions and the rows vanish with it, leaving a clean block. + ...deriveExtensionSettings(installedExtensions), { id: 'theme-mode', category: 'appearance', @@ -934,6 +1030,20 @@ export function getSettingsRegistry(): SettingDefinition[] { metadata: { scope: 'app', apply: 'immediate', storage: 'setup' }, control: { type: 'cli-update-behavior' }, }, + { + // Built-in apps listing. A marker row with no value — the content is + // apps/registry.ts, which is compile-time data. Deliberately NOT mirrored + // into Settings: extension-adjacent state must stay out of the + // zustand-persist blob (a forgotten version bump there black-screened + // launch twice, #249), and here there is nothing to persist anyway. + // See apps/ui/AppsSettingsRow.tsx. + id: 'apps-installed', + category: 'apps', + title: 'Extensions', + description: 'Install, update, and remove extensions from GitHub repositories.', + keywords: ['apps', 'extensions', 'plugins', 'install', 'github', 'tools'], + control: { type: 'apps' }, + }, { id: 'reset-settings', category: 'workspace', diff --git a/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx index 56506fb3..d357e5a8 100644 --- a/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx +++ b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@renderer/app-state/hooks' import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { deriveExtensionCommands, deriveExtensionKeybindings } from '@renderer/apps/host/derive' +import { useExtensionHost } from '@renderer/apps/host/ExtensionHostProvider' import { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' import type { BindingContext } from '@renderer/features/command-keybindings/defaults' @@ -45,6 +47,7 @@ const CATEGORY_LABELS: Record = { 'workspace-tools': 'Workspace Tools', preferences: 'Preferences', developer: 'Developer', + extensions: 'Extensions', } /** @@ -65,6 +68,10 @@ const CATEGORY_RANK: Record = { 'workspace-tools': 5, preferences: 6, developer: 7, + // Last: extension-contributed commands browse after every first-party group, + // the same "third-party entries never above the app's own" ordering the palette + // uses for extension commands. + extensions: 8, } const CATEGORY_ORDER = (Object.keys(CATEGORY_RANK) as CommandCategory[]) @@ -93,12 +100,34 @@ export function CommandKeybindingsRow() { const settings = useAppStore(state => state.settings) const setSettings = useAppStore(state => state.setSettings) + // Extension commands, derived from installed MANIFESTS exactly as the palette + // (CommandPalette.tsx) and the visibility list (SettingsPage.tsx) do — no bundle + // is imported. Without this the editor iterated only builtInCommandCatalog, so an + // extension command could be shown/hidden in the Commands list but never assigned + // a key here. openApp is a no-op: the editor reads command metadata and never + // invokes `run`. They are stamped with the 'extensions' category so the row + // builder's category filter admits them and they group under their own heading. + const installedExtensions = useAppStore(state => state.installedExtensions) + const extensionHost = useExtensionHost() + const extensionCommands = useMemo( + () => + (extensionHost ? deriveExtensionCommands(extensionHost, installedExtensions, () => {}) : []) + .map(command => ({ ...command, category: 'extensions' as const })), + [extensionHost, installedExtensions], + ) + const [query, setQuery] = useState('') const [capturingFor, setCapturingFor] = useState(null) const [conflict, setConflict] = useState(null) const overrides = settings.commandKeybindingOverrides - const defaults = useMemo(() => buildDefaultKeybindings(), []) + // Shipped defaults + extension-contributed defaults, so the editor shows an + // extension's declared chord as its default and conflict-checks against it — + // the same combined table the router fires from (useKeybinds.ts). + const defaults = useMemo( + () => [...buildDefaultKeybindings(), ...deriveExtensionKeybindings(installedExtensions)], + [installedExtensions], + ) const effective = useMemo(() => { const map = new Map() @@ -118,7 +147,7 @@ export function CommandKeybindingsRow() { const rows = useMemo(() => { const needle = query.trim().toLowerCase() - return builtInCommandCatalog + return [...builtInCommandCatalog, ...extensionCommands] // A command the palette never renders still gets a binding row — it is // reachable by chord, menu and programmatic call, so it is bindable. .filter(command => command.category) @@ -142,7 +171,7 @@ export function CommandKeybindingsRow() { ].join(' ').toLowerCase() return haystack.includes(needle) }) - }, [query, effective, overrides]) + }, [query, effective, overrides, extensionCommands]) const grouped = useMemo(() => { const byCategory = new Map() diff --git a/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx b/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx index a94adc3b..6de9a87a 100644 --- a/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx +++ b/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@renderer/app-state/hooks' import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { deriveExtensionCommands, deriveExtensionKeybindings } from '@renderer/apps/host/derive' +import { useExtensionHost } from '@renderer/apps/host/ExtensionHostProvider' import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' import { displayKeybinding } from '@renderer/features/command-keybindings/normalize' import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' @@ -62,6 +64,7 @@ const CATEGORY_LABELS: Record = { 'workspace-tools': 'Workspace Tools', preferences: 'Preferences', developer: 'Developer', + extensions: 'Extensions', } /** Display order. Exhaustive by type for the same reason the Settings row is: @@ -77,6 +80,7 @@ const CATEGORY_RANK: Record = { 'workspace-tools': 5, preferences: 6, developer: 7, + extensions: 8, } /** @@ -97,6 +101,18 @@ const CONTEXT_LABELS: Record = { export function KeyboardShortcutsModal({ open, onClose }: Props) { const overrides = useAppStore(state => state.settings.commandKeybindingOverrides) + + // Extension commands, so a chord a user bound to one is not a silent hole in a + // reference sheet — the exact failure the CATEGORY_RANK comment above warns + // against. Derived from manifests (no bundle import); openApp unused here. + const installedExtensions = useAppStore(state => state.installedExtensions) + const extensionHost = useExtensionHost() + const extensionCommands = useMemo( + () => + (extensionHost ? deriveExtensionCommands(extensionHost, installedExtensions, () => {}) : []) + .map(command => ({ ...command, category: 'extensions' as const })), + [extensionHost, installedExtensions], + ) const [query, setQuery] = useState('') const inputRef = useRef(null) @@ -111,12 +127,14 @@ export function KeyboardShortcutsModal({ open, onClose }: Props) { }, [open]) const rows = useMemo(() => { - const defaults = buildDefaultKeybindings() + // Include extension-contributed defaults so a chord an extension SHIPS (not + // just one the user rebound) shows in the reference, matching what fires. + const defaults = [...buildDefaultKeybindings(), ...deriveExtensionKeybindings(installedExtensions)] const contextById = new Map(defaults.map(d => [d.commandId, d.context])) const effective = new Map( resolveEffectiveKeybindings(overrides, defaults).map(e => [e.commandId, e.bindings]), ) - const byId = new Map(builtInCommandCatalog.map(c => [c.id, c])) + const byId = new Map([...builtInCommandCatalog, ...extensionCommands].map(c => [c.id, c])) const out: ShortcutRow[] = [] for (const [commandId, bindings] of effective) { @@ -140,7 +158,7 @@ export function KeyboardShortcutsModal({ open, onClose }: Props) { }) } return out - }, [overrides]) + }, [overrides, extensionCommands, installedExtensions]) const filtered = useMemo(() => { const needle = query.trim().toLowerCase() diff --git a/src/renderer/src/features/settings/ui/SettingsList.tsx b/src/renderer/src/features/settings/ui/SettingsList.tsx index aa0e90fb..b6ad593a 100644 --- a/src/renderer/src/features/settings/ui/SettingsList.tsx +++ b/src/renderer/src/features/settings/ui/SettingsList.tsx @@ -11,6 +11,8 @@ import { settingMetadata } from '@renderer/features/settings/lib/settingsRegistr import { CliUpdateBehaviorRow } from '@renderer/features/cli-updates/CliUpdateBehaviorRow' import { DictationApiKeyRow } from '@renderer/features/voice-dictation/DictationApiKeyRow' import { ThemePickerRow } from '@renderer/features/settings/ui/ThemePickerRow' +import { AppsSettingsRow } from '@renderer/apps/ui/AppsSettingsRow' +import { ExtensionSettingRow } from '@renderer/apps/ui/ExtensionSettingRow' import { AgentCodeConventionsRow } from '@renderer/features/settings/ui/AgentCodeConventionsRow' type Props = { @@ -232,6 +234,17 @@ function SettingRow({ features/cli-updates/CliUpdateBehaviorRow.tsx. */} {control.type === 'command-keybindings' ? : null} + {/* One extension-contributed setting. Self-subscribing: the value lives + in the extension's own storage, not the Settings blob (#249). */} + {control.type === 'extension' ? ( + + ) : null} + {control.type === 'cli-update-behavior' ? : null} {/* Voice-dictation API key — same self-subscribing marker-row @@ -240,6 +253,11 @@ function SettingRow({ round-trip. See features/voice-dictation/DictationApiKeyRow.tsx. */} {control.type === 'dictation-api-key' ? : null} + {/* Built-in apps — the purest marker row: there is no value at all, + just a listing of apps/registry.ts, which is compile-time data the + Settings store has no business mirroring. + See apps/ui/AppsSettingsRow.tsx. */} + {control.type === 'apps' ? : null} {control.type === 'agent-code-conventions' ? : null} {/* Theme grid — built-ins and saved themes in one list, with the diff --git a/src/renderer/src/features/settings/ui/SettingsPage.tsx b/src/renderer/src/features/settings/ui/SettingsPage.tsx index 7043bcf3..42fe1f9c 100644 --- a/src/renderer/src/features/settings/ui/SettingsPage.tsx +++ b/src/renderer/src/features/settings/ui/SettingsPage.tsx @@ -31,6 +31,9 @@ import { getSettingsRegistry, matchesSettingQuery } from '@renderer/features/set import { SettingsList } from '@renderer/features/settings/ui/SettingsList' import { SettingsSearch } from '@renderer/features/settings/ui/SettingsSearch' import { SettingsSidebar } from '@renderer/features/settings/ui/SettingsSidebar' +import { deriveExtensionCommands } from '@renderer/apps/host/derive' +import { useExtensionHost } from '@renderer/apps/host/ExtensionHostProvider' +import { useAppStore } from '@renderer/app-state/store' type Props = { onClose: () => void @@ -53,7 +56,28 @@ export function SettingsPage({ onClose, workspace, settings, onChange, onReset } // (null) distinguishable from "creating" (id: null). const [editorTarget, setEditorTarget] = useState<{ id: string | null } | null>(null) - const registry = useMemo(() => getSettingsRegistry(), []) + // Extension commands derived from installed MANIFESTS, exactly as the command + // palette does it (CommandPalette.tsx) — so the Settings "Commands" category + // and the keybind editor list them without importing a single extension bundle. + // + // openApp is a no-op here on purpose: it only ever becomes a command's `run` + // closure, and the Settings command list reads metadata (id, title, visibility) + // and never invokes `run`. Passing the real opener would drag app-open plumbing + // into Settings for a callback that can never fire from this surface. + const installedExtensions = useAppStore(state => state.installedExtensions) + const extensionHost = useExtensionHost() + const extensionCommands = useMemo( + () => + extensionHost + ? deriveExtensionCommands(extensionHost, installedExtensions, () => {}) + : [], + [extensionHost, installedExtensions], + ) + + const registry = useMemo( + () => getSettingsRegistry(extensionCommands, installedExtensions), + [extensionCommands, installedExtensions], + ) const visibleDefinitions = useMemo( () => registry.filter(definition => { diff --git a/src/renderer/src/workspace/tile-tree/useKeybinds.ts b/src/renderer/src/workspace/tile-tree/useKeybinds.ts index fe80ab14..f26e9ce7 100644 --- a/src/renderer/src/workspace/tile-tree/useKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/useKeybinds.ts @@ -1,8 +1,9 @@ import { useEffect, useMemo } from 'react' import { useAppStore } from '@renderer/app-state/hooks' +import { deriveExtensionKeybindings } from '@renderer/apps/host/derive' import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' -import type { BindingContext } from '@renderer/features/command-keybindings/defaults' +import type { BindingContext, CommandBindingDefault } from '@renderer/features/command-keybindings/defaults' import { keybindingFromEvent } from '@renderer/features/command-keybindings/normalize' import { commandOwnsOpenSurface } from '@renderer/features/command-palette/surfaceOwnership' import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' @@ -238,9 +239,17 @@ const GLOBAL_CONTEXT_ONLY: ReadonlySet = new Set /** Chord -> candidate commands, built once per override change. */ function buildBindingIndex( overrides: Record, + // Extension-contributed defaults, concatenated onto the shipped table. A user + // override still wins (resolveEffectiveKeybindings applies overrides on top of + // whatever defaults it is handed), so this is the ONE site that actually makes + // an extension's declared chord fire — the editor/sheet/palette only display it. + extensionDefaults: CommandBindingDefault[], ): Map { const index = new Map() - for (const entry of resolveEffectiveKeybindings(overrides, buildDefaultKeybindings())) { + for (const entry of resolveEffectiveKeybindings(overrides, [ + ...buildDefaultKeybindings(), + ...extensionDefaults, + ])) { for (const binding of entry.bindings) { const list = index.get(binding) ?? [] list.push({ commandId: entry.commandId, context: entry.context }) @@ -258,6 +267,7 @@ export function useKeybinds( const commandKeybindingOverrides = useAppStore( state => state.settings.commandKeybindingOverrides, ) + const installedExtensions = useAppStore(state => state.installedExtensions) const agentViewMode = useAppStore(state => state.settings.agentViewMode) const closeSettingsPage = useAppStore(state => state.closeSettingsPage) const buryPromptSessionId = useAppStore(state => state.buryPromptSessionId) @@ -290,12 +300,20 @@ export function useKeybinds( const pinAgentsOpen = useAppStore(state => state.pinAgentsOpen) const closePinAgents = useAppStore(state => state.closePinAgents) + // Extension keybinding defaults, derived from installed manifests (no bundle + // import). Recomputed only when the installed set changes, so an install/remove + // makes a contributed chord start/stop firing without a reload. + const extensionKeybindings = useMemo( + () => deriveExtensionKeybindings(installedExtensions), + [installedExtensions], + ) + // Built once per override change, not per keystroke. Resolving inside the // handler meant rebuilding the default table and re-normalizing ~30 strings // on every keydown, including ordinary typing. const bindingIndex = useMemo( - () => buildBindingIndex(commandKeybindingOverrides), - [commandKeybindingOverrides], + () => buildBindingIndex(commandKeybindingOverrides, extensionKeybindings), + [commandKeybindingOverrides, extensionKeybindings], ) useEffect(() => { From 5f22fbfd2a0eb5ae5a228f8df7a6045494e91785 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 21:46:20 +0200 Subject: [PATCH 07/22] feat(extensions): sandboxed iframe runtime, in-frame command execution & capability enforcement Co-Authored-By: Claude Opus 4.8 --- src/main/extensions/frameDocument.ts | 253 ++++++++++++++++++++ src/main/extensions/grants.ts | 102 ++++++++ src/renderer/index.html | 13 +- src/renderer/src/apps/host/frameHost.ts | 139 +++++++++++ src/renderer/src/apps/host/frameProtocol.ts | 135 +++++++++++ src/renderer/src/apps/host/frameRegistry.ts | 78 ++++++ src/renderer/src/apps/host/viewBridge.tsx | 212 ++++++++++++++++ 7 files changed, 931 insertions(+), 1 deletion(-) create mode 100644 src/main/extensions/frameDocument.ts create mode 100644 src/main/extensions/grants.ts create mode 100644 src/renderer/src/apps/host/frameHost.ts create mode 100644 src/renderer/src/apps/host/frameProtocol.ts create mode 100644 src/renderer/src/apps/host/frameRegistry.ts create mode 100644 src/renderer/src/apps/host/viewBridge.tsx diff --git a/src/main/extensions/frameDocument.ts b/src/main/extensions/frameDocument.ts new file mode 100644 index 00000000..bf941570 --- /dev/null +++ b/src/main/extensions/frameDocument.ts @@ -0,0 +1,253 @@ +// The HTML document a sandboxed extension frame loads (WS4, Decision A). +// +// The scheme handler serves this for the reserved path `__agent-code-frame__.html`. +// It is a DISTINCT document, at the extension's own origin, framed by the host — so +// it cannot reach `window.api`, the parent DOM, or another extension. Its only +// channel is postMessage to `window.parent`, brokered by frameHost.ts. +// +// WHY generated in main rather than shipped as a static file: the document embeds +// the extension's own `entry` path (from its validated manifest) and a per-load +// nonce, neither of which a static file can carry. `entry` has already passed the +// manifest's path refinements + install-time realpath containment, so it is safe to +// interpolate; it is JSON-encoded here anyway as defence in depth. +// +// RUNTIME NOTE: the bootstrap below is the one part of WS4 that cannot be +// type-checked into correctness — it runs only inside a live frame. It is +// deliberately minimal (Tier-0 API proxy + activate + mount) and converges with the +// SDK's child runtime (WS8); treat a real in-frame load as its acceptance test. + +export type FrameDocumentInput = { + /** The contributed view id to mount once the parent sends the mount message. */ + viewId: string + /** The manifest `entry`, relative to the bundle root (already path-validated). */ + entry: string + /** The parent (host) document origin, so the child posts replies only to it. */ + parentOrigin: string + /** Per-load nonce authorizing exactly the one inline bootstrap script. */ + nonce: string +} + +/** + * The child Content-Security-Policy — far stricter than the host's. + * + * `default-src 'none'` denies everything not re-granted below. Scripts are limited + * to the extension's own origin plus the single nonced inline bootstrap (no + * 'unsafe-inline', so injected script cannot run). There is NO `connect-src` beyond + * the origin and NO `frame-src`: a sandboxed extension cannot open sockets to the + * network or nest further frames. Styles/img/font allow the extension's own assets + * and inline styles (canvas/React need them); that is the widest concession. + */ +export function childFrameCsp(nonce: string): string { + return [ + "default-src 'none'", + `script-src 'self' agent-code-ext: 'nonce-${nonce}'`, + "style-src 'self' 'unsafe-inline' agent-code-ext:", + 'img-src agent-code-ext: data: blob:', + 'font-src agent-code-ext: data:', + "connect-src 'self' agent-code-ext:", + ].join('; ') +} + +export function buildFrameDocument(input: FrameDocumentInput): string { + const { viewId, entry, parentOrigin, nonce } = input + // The bootstrap. Everything the child needs to (a) expose a Tier-0 API that + // proxies to the parent over postMessage, (b) import and activate the extension, + // (c) mount its view on the parent's signal. Kept in one nonced module. + const bootstrap = ` +const PARENT_ORIGIN = ${JSON.stringify(parentOrigin)}; +const VIEW_ID = ${JSON.stringify(viewId)}; +const ENTRY = ${JSON.stringify(entry)}; + +// Correlate replies to requests over the single channel to the parent. +let seq = 0; +const pending = new Map(); +window.addEventListener('message', (event) => { + // Authenticate the SENDER by window reference, not by origin string. When the + // host renderer is loaded from file:// (the packaged/preview build), its origin + // is opaque: a message it posts arrives here with event.origin === "null" (or a + // non-matching serialization), so an === PARENT_ORIGIN check silently drops + // every reply and mount signal — the view then never mounts and there is no + // error anywhere. event.source is a live WindowProxy the child cannot forge and + // the browser never rewrites, so it holds across every origin quirk. This is the + // exact mirror of frameHost.ts's inbound gate (event.source === iframe.contentWindow). + if (event.source !== window.parent) return; + const msg = event.data; + if (!msg || typeof msg !== 'object') return; + if (msg.kind === 'agent-code-ext:reply') { + const p = pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + if (msg.ok) p.resolve(msg.result); else p.reject(new Error(msg.error)); + } else if (msg.kind === 'agent-code-ext:mount') { + mountView(msg.viewId); + } else if (msg.kind === 'agent-code-ext:theme') { + for (const [name, value] of Object.entries(msg.tokens || {})) { + document.documentElement.style.setProperty(name, value); + } + } else if (msg.kind === 'agent-code-ext:command') { + // Invoke a contributed command's handler, registered by the extension in + // activate() via context.registerCommand. This is the whole point of unifying + // activation onto the frame: the handler runs HERE, against the same engine the + // view shows, not a second host-realm instance. A missing handler is silent — + // an "open my view" command is routed by the host and never dispatched here. + const handler = commands.get(msg.commandId); + if (handler) { + try { + Promise.resolve(handler()).catch((e) => console.error('[extension] command failed:', msg.commandId, e)); + } catch (e) { + console.error('[extension] command failed:', msg.commandId, e); + } + } + } else if (msg.kind === 'agent-code-ext:event') { + // A change nudge for a Tier-1 observe topic. Fan out to registered listeners; + // each typically re-reads via observe(). Snapshot the set before iterating so a + // listener that unsubscribes mid-notify does not skip a sibling. + const set = eventListeners.get(msg.topic); + if (set) { + for (const cb of Array.from(set)) { + try { cb(); } catch (e) { console.error('[extension] observe listener failed:', msg.topic, e); } + } + } + } +}); + +function request(method, extra) { + const id = 'q' + (++seq); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + // targetOrigin '*' rather than PARENT_ORIGIN: a file:// parent's origin does + // not reliably match a specific-string targetOrigin, so pinning it to + // "file://" makes the browser refuse to deliver the request and activate() + // hangs on its first storage read. This is safe because the message is sent to + // window.parent EXPLICITLY (only the host receives it — the child CSP forbids + // nested frames, so there is no other embedder) and the requests carry no host + // secrets; the host's frameHost re-authenticates every request by the child's + // own unforgeable agent-code-ext:// origin before performing it. + window.parent.postMessage( + { kind: 'agent-code-ext:request', id, request: Object.assign({ method }, extra) }, + '*', + ); + }); +} + +// The Tier-0 AgentCodeApiV1, proxied. Same method names as the same-realm object. +const api = { + extension: { apiVersion: 1 }, + storage: { + get: (key) => request('storage.get', { key }), + set: (key, value) => request('storage.set', { key, value }), + delete: (key) => request('storage.delete', { key }), + keys: () => request('storage.keys', {}), + }, + ui: { + close: () => request('ui.close', {}), + showToast: (message) => request('ui.showToast', { message }), + }, + theme: { tokens: () => request('theme.tokens', {}) }, + // Tier-1 observe. The host broker gates each on the extension's grant, so a call + // here rejects if the capability was not consented to at install. subscribe() is + // local: it registers a listener the host wakes with a change nudge (the extension + // then re-reads via observe). Returns an unsubscribe, matching AgentCodeApiV1. + workspace: { observe: () => request('workspace.observe', {}), subscribe: (cb) => subscribeTopic('workspace', cb) }, + sessions: { observe: () => request('sessions.observe', {}), subscribe: (cb) => subscribeTopic('sessions', cb) }, + panes: { observe: () => request('panes.observe', {}), subscribe: (cb) => subscribeTopic('panes', cb) }, +}; + +// Listeners for host-pushed change nudges (Tier-1 observe live updates), keyed by +// topic. A function declaration so the api object above can reference it regardless +// of source order; it is only ever CALLED at runtime, after the whole bootstrap ran. +const eventListeners = new Map(); +function subscribeTopic(topic, cb) { + let set = eventListeners.get(topic); + if (!set) { set = new Set(); eventListeners.set(topic, set); } + set.add(cb); + return function () { set.delete(cb); }; +} + +const views = new Map(); +const commands = new Map(); +const subscriptions = []; +const context = { + api, + // Real now (was a no-op). Handlers are invoked by the host's 'command' push + // above — the frame is the ONE place an extension's command runs, so its + // handlers must actually be kept. + registerCommand: (id, run) => { commands.set(id, run); return { dispose() { commands.delete(id); } }; }, + registerView: (id, mount) => { views.set(id, mount); return { dispose() { views.delete(id); } }; }, + subscriptions, +}; + +// Report the mounted view's natural content height to the parent, which sizes +// the iframe (and therefore the host modal) to it. Without this the iframe has no +// definite height to give an 'height:100%' child, so it collapses to the host's +// minimum and a taller extension is clipped. #root is height:auto (see the child +// CSS), so scrollHeight is the CONTENT height, and growing the iframe never +// changes it — so the ResizeObserver below cannot enter a resize feedback loop. +function reportSize() { + const root = document.getElementById('root'); + if (!root) return; + const height = root.scrollHeight; + if (height > 0) window.parent.postMessage({ kind: 'agent-code-ext:resize', height: height }, '*'); +} + +let mounted = false; +function mountView(viewId) { + if (mounted) return; + const mount = views.get(viewId); + if (!mount) return; + mounted = true; + const root = document.getElementById('root'); + if (!root) return; + mount(root); + // Report once now, then on every content change (a picker expands, digits + // reflow), so the modal tracks the view instead of freezing at first paint. + reportSize(); + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(function () { reportSize(); }); + observer.observe(root); + } +} + +// Import, activate, mount, then announce readiness. +import('./' + ENTRY) + .then((mod) => mod.activate(context)) + .then(() => { + mountView(VIEW_ID); + // Signal the host that activate() has resolved — meaning registerCommand() has + // run and command handlers exist. The host registers this frame's command + // dispatcher on this signal and flushes any command queued while the view was + // closed, so a flushed command can never arrive before its handler is registered. + window.parent.postMessage({ kind: 'agent-code-ext:ready' }, '*'); + }) + .catch((error) => { + const root = document.getElementById('root'); + if (root) root.textContent = 'Extension failed to load: ' + (error && error.message || error); + }); +`.trim() + + return [ + '', + '', + '', + '', + ``, + // #root is width:100% but height:AUTO on purpose: it sizes to the extension's + // content, which reportSize() measures and the host uses to size the iframe. + // A height:100% here would make #root track the (initially collapsed) iframe + // instead, and the content-height signal would always read the clamp. + // + // overflow:hidden kills the frame's OWN scrollbars. The host already sizes the + // iframe to the reported content height, so there is nothing legitimate to + // scroll — but a view whose background bleeds a pixel past the edge (the timer + // uses margin:-1px to reach the modal corners) would otherwise raise a stray + // horizontal bar, which then steals height and raises a vertical one too. The + // measurement reads #root.scrollHeight, which is unaffected by this. + '', + '', + '', + '
', + ``, + '', + '', + ].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/renderer/index.html b/src/renderer/index.html index 247923fb..335480b2 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,6 +4,17 @@ Agent Code diff --git a/src/renderer/src/apps/host/frameHost.ts b/src/renderer/src/apps/host/frameHost.ts new file mode 100644 index 00000000..ae5e05ae --- /dev/null +++ b/src/renderer/src/apps/host/frameHost.ts @@ -0,0 +1,139 @@ +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 +} + +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) + } + + const perform = async (request: FrameRequest): Promise => { + 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 — gated on the grant. requireGrant throws (→ ok:false reply) when the + // extension did not request/receive the capability at install. + case 'workspace.observe': + await requireGrant('workspace.observe') + return api.workspace.observe() + case 'sessions.observe': + await requireGrant('sessions.observe') + return api.sessions.observe() + case 'panes.observe': + await requireGrant('panes.observe') + return api.panes.observe() + } + } + + 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..20cded89 --- /dev/null +++ b/src/renderer/src/apps/host/frameProtocol.ts @@ -0,0 +1,135 @@ +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