diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index f70346934..f39c8dd95 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -31,6 +31,11 @@ description: | ## Package Map +External right-panel UI addons use `packages/server/src/panel-extensions/` and +`packages/ui/src/components/panel-extensions/`; see `dev-docs/PANEL_EXTENSIONS.md`. +They are sandboxed UI packages, not native OpenCode/backend plugins. Never import +author code into the primary renderer, expose generic RPC or grant native commands. + - `packages/server/`: Fastify control API, shared OpenCode service, locations, auth, filesystem, Git, Yolo, speech. - `packages/ui/`: SolidJS application, generated client adapters, stores, components, i18n. - `packages/electron-app/`: Electron host. diff --git a/AGENTS.md b/AGENTS.md index 24545d716..17b51e184 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # AGENT NOTES ## Styling Guidelines +- External panel extensions use `components/panel-extensions/` and `styles/panels/panel-extensions.css`. Never import author JavaScript into the main renderer or expose native/OpenCode bridges. ZIPs contain only a manifest and a self-contained HTML panel; install/replacement revokes activation. API versions belong to the public extension contract, not internal Solid modules. See `dev-docs/PANEL_EXTENSIONS.md` for distribution, consent, scope and sandbox limits. - Provider accounts use a native dropdown like Web search defaults, without an Accounts disclosure or selectable account rows. Provider headers keep the name and text Manage models button on one row; a faint separator distinguishes the account controls below. Account actions are always visible, not hover-revealed. The `provider-account` container stacks the label above the dropdown/actions at narrow card widths. Native account ordering supplies the current account; rename/remove drafts stay keyed by credential. Environment connections are read-only. The browser never reads credential secrets. Native labels win; only `default` may display a sanitized Codex login from the owned server adapter. Opt-in Codex rotation runs before prompt/command admission, using fresh bounded quotas, local manual-change fences and no mutation/prompt replay; other providers remain manual. Account styling lives in `styles/components/provider-accounts.css`, backend policy in `provider-accounts/`, with limitations and isolated validation in `dev-docs/PROVIDER_ACCOUNTS_UX_PLAN.md`. Browser preview percentages remain simulated. - Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed. - Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`. diff --git a/dev-docs/PANEL_EXTENSIONS.md b/dev-docs/PANEL_EXTENSIONS.md new file mode 100644 index 000000000..96409c476 --- /dev/null +++ b/dev-docs/PANEL_EXTENSIONS.md @@ -0,0 +1,166 @@ +# External right-panel extensions — API 1 + +CodeNomad panel extensions are independently distributed **UI addons**, not +OpenCode plugins. Authors publish their own GitHub repositories and release ZIPs. +Users install the downloaded ZIP from **Customize right panel → Panel extensions**; +no CodeNomad rebuild or shared OpenCode service restart is involved. + +This first distribution contract intentionally exposes only session identity and +appearance. It does **not** implement #801's gallery or grant transcript/image/file +reads. The later assets example must add a narrowly authorized, bounded image-read +capability; importing application stores or opening the generic RPC proxy is not +an extension API. + +## Package layout + +The ZIP contains exactly two UTF-8 files at its root: + +```text +manifest.json +panel.html +``` + +`panel.html` is self-contained: inline JavaScript/CSS and optional data-URI assets. +Build with any framework outside CodeNomad, then bundle into that one HTML file. +There are no install scripts, npm installation, dependency fetching, Node modules, +server entrypoints or native binaries. Symlinks, extra files, nested paths, +duplicate ZIP entries, encrypted entries and invalid UTF-8 are rejected. Limits: +2 MiB ZIP, 4 KiB manifest, 2 MiB uncompressed HTML, 32 installed extensions, +16 MiB persisted catalogue, 128 explicit folder grants per extension. + +```json +{ + "id": "example.session", + "name": "Session example", + "version": "1.0.0", + "apiVersion": 1, + "author": "Example", + "license": "MIT", + "repository": "https://github.com/example/session", + "permissions": ["session.context"] +} +``` + +- ID: lowercase `namespace.name`, each segment 2–40 characters, letters/digits/ + hyphens, starting with a letter. Use your GitHub account/organization namespace; + maintain the same ID across releases. The namespace is **not** verified ownership. +- Version: `major.minor.patch`, optionally `-prerelease`. Publish new code under a + new version; never replace the bytes of an already published release asset. +- API version: compatibility with the extension host, **not** the exact application + version. API 1 extensions work on hosts supporting API 1. An unsupported major + API or unknown permission is rejected, without executing author code. +- Author, name, license and repository are mandatory. Repository must be an HTTPS + GitHub repository URL, without credentials/query/fragment. Metadata is a claim, + not a signature. Use a real repository, SPDX license where possible, and include + that license's notice in the source and in the self-contained panel. +- Unknown manifest fields are rejected. Internal Solid interfaces/import paths are + not public compatibility promises. Additive API-1 evolution must preserve old + packages; breaking changes require a new API major and an explicit migration. + +## Installation, updates and scope + +Inspection shows the manifest and SHA-256 of the exact ZIP bytes before install. +The user acknowledges trust; installation starts **disabled**. Enable with either: + +- **All projects:** every opened project on this CodeNomad backend/profile. +- **This folder:** the exact server-owned physical folder opened as the project. + It persists across closing/reopening, but does not infer sibling worktrees, + ancestor folders, another WSL distribution or a native project-ID family. + +Both scopes live under the selected CodeNomad profile's `panel-extensions/`, not +inside a repository or the OpenCode discovery/database directories. In remote +access, installation affects that **server profile**, not the viewer's device. +Profiles/channels remain separate; a ZIP can be installed in each desired profile. +Global grants take precedence; turn off All projects before limiting to folders. + +To update, download and inspect a new release ZIP and install it over the same ID. +Compare the displayed digest with the publisher's checksum through a trusted +channel. Replacing code revokes **all** activation grants, even when permissions +are unchanged. The old package remains intact on validation, conflict or storage +failure. Concurrent mutations compare exact digests. Removal is explicit and +removes the installed code plus its grants, not sessions or project data. + +There is no marketplace, URL installer, silent update, automatic project discovery, +signature authority or remote-code startup hook. Keep trusted source/releases +available for audit; never regard a SHA-256 or a GitHub URL alone as proof of trust. + +## Public browser API + +The host supplies `window.codenomad` before author scripts run: + +```js +const unsubscribe = codenomad.onContext(context => { + // { apiVersion: 1, sessionId: string | null, locale, appearance: "light" | "dark" } + document.querySelector("#session").textContent = context.sessionId ?? "—" +}) +// codenomad.getContext() returns the latest context, or null before initialization. +// unsubscribe() removes this listener. +``` + +There is no instance URL, auth token, directory, prompt, transcript, credential, +filesystem handle, eval callback or native bridge in this API. Translate your panel +using `context.locale`; CodeNomad does not accept injected translation keys. +Honor appearance, keyboard navigation, accessible labels and square host chrome. +The panel can be unmounted whenever hidden, disconnected, disabled, replaced, +removed, or when the project/session changes. Treat DOM state as disposable. + +Each mounting has a new one-use MessageChannel handshake bound to the injected +document. Parent window messages are not an RPC dispatcher. Late HTTP results and +old ports cannot initialize a different session or a reloaded/navigated document. + +## Isolation and limits + +Author code is never imported into the main renderer. It runs in an iframe with +`sandbox="allow-scripts"`: no same-origin, forms, popup, download or top-navigation +grant. A host-inserted CSP precedes author bytes and disallows network APIs, +external scripts/styles/images, nested frames, base URLs, objects and form targets. +Only inline scripts/styles and data/blob image assets are allowed. Windows Tauri +may inject native bridge objects into subframes; their presence is not permission. +The frame's opaque origin, CSP and native transport restrictions must prevent +their use. No app-native capabilities are passed through the extension API. + +This is **not** a process/CPU isolation guarantee or an offline/safe-code guarantee. +A hostile approved author can hang its renderer and can try self-navigation; +browser navigation is not comprehensively blocked by CSP. A navigated document +cannot acquire the context handshake; it is detached on the next load. Install +only trusted authors and do not pass secrets to extensions. Desktop-native command +denial needs its own native qualification, not merely Chromium frame tests. + +## Independent GitHub repository rules + +Copy `examples/panel-extension/` into a separate repository, replace its example +identity/repository, and commit source, license, README, manifest and build recipe. +Publish a GitHub Release with: + +1. A tag matching the manifest version, e.g. `v1.0.0`. +2. An immutable `namespace.name-1.0.0.zip` release asset and SHA-256 file. +3. Supported API major, requested permissions, changelog, maintainer and issue URL. +4. Reproducible build/test instructions and dependency licenses if bundling a UI. +5. Security reports handled by the extension author; never request credentials, + weaken the sandbox, depend on application internals or auto-run commands. + +From the package folder, Python's standard library is enough: + +```sh +python -m zipfile -c example.session-1.0.0.zip manifest.json panel.html +python -m zipfile -l example.session-1.0.0.zip +``` + +Publish this built asset, **not** GitHub's repository source ZIP (which has a parent +directory and other files). No central repository is required: author-owned repos +are supported. A curated index can later link to those immutable releases without +becoming an execution/install authority. + +## Checks + +```sh +node --import tsx --test packages/server/src/panel-extensions/extension.test.ts +node scripts/test-panel-extension-native.mjs # Windows, isolated Tauri/WebView2 +# from packages/ui: +node --import tsx --test tests/browser/panel-extensions.test.ts +``` + +Server checks cover format/permission/API validation, ZIP bounds/path attacks, +durable scopes, replacement revocation, concurrent changes and corrupt-state +preservation. Rendered tests use the real right panel, installer, route handlers +and event dispatcher to exercise consent, lifecycle, stale reads and frame policy. diff --git a/examples/panel-extension/LICENSE b/examples/panel-extension/LICENSE new file mode 100644 index 000000000..9cecf2b3b --- /dev/null +++ b/examples/panel-extension/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) CodeNomad contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/panel-extension/README.md b/examples/panel-extension/README.md new file mode 100644 index 000000000..28aaee652 --- /dev/null +++ b/examples/panel-extension/README.md @@ -0,0 +1,26 @@ +# Independent CodeNomad panel extension starter + +Copy this folder to **your own GitHub repository**. Replace the `example.session` +identity, author and repository in `manifest.json`, and give your repository a +license. The sample is MIT-licensed. No CodeNomad source imports or build tool are +needed; it only displays the current session identifier, with English/French text +and appearance changes. + +From this directory: + +```sh +python -m zipfile -c example.session-1.0.0.zip manifest.json panel.html +python -c "import hashlib,pathlib; p=pathlib.Path('example.session-1.0.0.zip'); pathlib.Path(str(p)+'.sha256').write_text(hashlib.sha256(p.read_bytes()).hexdigest()+' '+p.name+'\n')" +``` + +Publish both files as assets of release `v1.0.0` on your repository. Users download +the ZIP, inspect/install it through the right-panel customization surface, then +enable it for All projects or This folder. GitHub's source-code ZIP is not the +installable package. New code requires a new release/version and explicit trust; +activation is revoked on replacement. + +API compatibility is `apiVersion: 1`, not the exact CodeNomad version. This sample +cannot read messages/images or access files, OpenCode, native commands or network +APIs. It is a distribution smoke example, **not** the planned assets gallery. +See `dev-docs/PANEL_EXTENSIONS.md` in the CodeNomad repository for the public +contract, packaging rules, lifecycle and security limits. diff --git a/examples/panel-extension/manifest.json b/examples/panel-extension/manifest.json new file mode 100644 index 000000000..7a37fd05c --- /dev/null +++ b/examples/panel-extension/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "example.session", + "name": "Session example", + "version": "1.0.0", + "apiVersion": 1, + "author": "Example", + "license": "MIT", + "repository": "https://github.com/example/session", + "permissions": ["session.context"] +} diff --git a/examples/panel-extension/panel.html b/examples/panel-extension/panel.html new file mode 100644 index 000000000..0ec8d1408 --- /dev/null +++ b/examples/panel-extension/panel.html @@ -0,0 +1,18 @@ + + +

+

+ diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 0a30ecb38..5182b89e4 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -7,6 +7,7 @@ import type { RecentFolder, } from "./config/schema" import type { FormInfo, OpenCodeEvent, PermissionRequest } from "@opencode/client" +export type { PanelExtensionManifest, PanelExtensionSummary, PanelExtensionContext } from "./panel-extensions/contract" export type { GitHistoryCommit, GitHistoryPage, GitCommitFile, GitCommitDetails, GitCommitDiff } from "./git-history-types" /** diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 22970aa4e..921e4598d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -7,6 +7,7 @@ import path from "path" import { fileURLToPath } from "url" import { createRequire } from "module" import { createHttpServer } from "./server/http-server" +import { PanelExtensionStore } from "./panel-extensions/store" import { WorkspaceManager } from "./workspaces/manager" import { resolveConfigLocation } from "./config/location" import { SettingsService } from "./settings/service" @@ -502,6 +503,9 @@ async function main() { const httpBindHost = nativeParent.available ? "127.0.0.1" : options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1" const servers: Array> = [] + const panelExtensions = new PanelExtensionStore(path.join(configDir, "panel-extensions"), () => { + eventBus.publish({ type: "storage.stateChanged", owner: "panelExtensions", value: {} }) + }) const httpServer = options.http || nativeParent.available ? createHttpServer({ @@ -523,6 +527,7 @@ async function main() { remoteProxySessionManager, yoloManager, permissionReceipts, + panelExtensions, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, @@ -552,6 +557,7 @@ async function main() { remoteProxySessionManager, yoloManager, permissionReceipts, + panelExtensions, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, diff --git a/packages/server/src/panel-extensions/archive-fixture.ts b/packages/server/src/panel-extensions/archive-fixture.ts new file mode 100644 index 000000000..bd4704752 --- /dev/null +++ b/packages/server/src/panel-extensions/archive-fixture.ts @@ -0,0 +1,27 @@ +// Deterministic, memory-only stored ZIPs for server and rendered browser regressions. +import { crc32 } from "node:zlib" +export function fixtureZip(files: { name: string; data: string | Buffer; mode?: number; size?: number }[]): Buffer { + const local: Buffer[] = [], central: Buffer[] = [] + let offset = 0 + for (const file of files) { + const name = Buffer.from(file.name), bytes = Buffer.from(file.data), size = file.size ?? bytes.length + const header = Buffer.alloc(30) + header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4) + header.writeUInt32LE(crc32(bytes), 14); header.writeUInt32LE(bytes.length, 18); header.writeUInt32LE(size, 22); header.writeUInt16LE(name.length, 26) + local.push(header, name, bytes) + const entry = Buffer.alloc(46) + entry.writeUInt32LE(0x02014b50); entry.writeUInt16LE(0x314, 4); entry.writeUInt16LE(20, 6) + entry.writeUInt32LE(crc32(bytes), 16); entry.writeUInt32LE(bytes.length, 20); entry.writeUInt32LE(size, 24); entry.writeUInt16LE(name.length, 28) + entry.writeUInt32LE(((file.mode ?? 0o100644) << 16) >>> 0, 38); entry.writeUInt32LE(offset, 42) + central.push(entry, name); offset += header.length + name.length + bytes.length + } + const directory = Buffer.concat(central), end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10) + end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16) + return Buffer.concat([...local, directory, end]) +} +export const fixtureManifest = { id: "example.session", name: "Session example", version: "1.0.0", apiVersion: 1, + author: "Example", license: "MIT", repository: "https://github.com/example/session", permissions: ["session.context"] } +export const fixtureArchive = (html = "

Example

", changes = {}) => fixtureZip([ + { name: "manifest.json", data: JSON.stringify({ ...fixtureManifest, ...changes }) }, { name: "panel.html", data: html }, +]) diff --git a/packages/server/src/panel-extensions/archive.ts b/packages/server/src/panel-extensions/archive.ts new file mode 100644 index 000000000..b290cecc3 --- /dev/null +++ b/packages/server/src/panel-extensions/archive.ts @@ -0,0 +1,68 @@ +import { createHash } from "node:crypto" +import yauzl from "yauzl" +import { z } from "zod" +import { PANEL_EXTENSION_LIMITS, type PanelExtensionManifest } from "./contract" + +export const ManifestSchema = z.object({ + id: z.string().regex(/^[a-z][a-z0-9-]{1,39}\.[a-z][a-z0-9-]{1,39}$/), + name: z.string().trim().min(1).max(80), + version: z.string().regex(/^\d{1,5}\.\d{1,5}\.\d{1,5}(?:-[a-zA-Z0-9.-]{1,40})?$/), + apiVersion: z.literal(1), + author: z.string().trim().min(1).max(120), + license: z.string().trim().min(1).max(80), + repository: z.string().max(512).url().refine(value => { + const url = new URL(value) + return url.protocol === "https:" && url.hostname === "github.com" && !url.username && !url.password + && !url.search && !url.hash && /^\/[\w.-]+\/[\w.-]+\/?$/.test(url.pathname) + }), + permissions: z.tuple([z.literal("session.context")]), +}).strict() + +export interface PanelExtensionPackage { manifest: PanelExtensionManifest; html: string; digest: string } +export class PanelExtensionError extends Error { + constructor(readonly code: "invalid" | "conflict" | "limit" | "unavailable" | "missing" | "disabled") { super(code) } +} + +export async function readPanelExtensionArchive(bytes: Buffer): Promise { + if (!bytes.length || bytes.length > PANEL_EXTENSION_LIMITS.archiveBytes) throw new PanelExtensionError("limit") + try { + const files = await new Promise>((resolve, reject) => { + yauzl.fromBuffer(bytes, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => { + if (error || !zip) return reject(new PanelExtensionError("invalid")) + const files = new Map() + let ended = false + const fail = () => { if (!ended) { ended = true; zip.close(); reject(new PanelExtensionError("invalid")) } } + zip.on("error", fail) + zip.on("end", () => { if (!ended) { ended = true; zip.close(); resolve(files) } }) + zip.on("entry", (entry: yauzl.Entry) => { + const mode = entry.externalFileAttributes >>> 16 + const limit = entry.fileName === "manifest.json" ? 4096 : PANEL_EXTENSION_LIMITS.htmlBytes + // No extraction: only these two root entries, no links, directories or paths. + if (!["manifest.json", "panel.html"].includes(entry.fileName) || files.has(entry.fileName) + || (mode & 0xf000) === 0xa000 || (entry.generalPurposeBitFlag & 1) !== 0 || entry.uncompressedSize > limit) return fail() + zip.openReadStream(entry, (error, stream) => { + if (error || !stream) return fail() + const chunks: Buffer[] = []; let size = 0 + stream.on("error", fail) + stream.on("data", chunk => { + size += chunk.length + if (size > limit) { stream.destroy(); fail() } else chunks.push(chunk) + }) + stream.on("end", () => { + if (ended) return + files.set(entry.fileName, Buffer.concat(chunks)) + zip.readEntry() + }) + }) + }) + zip.readEntry() + }) + }) + if (files.size !== 2) throw new PanelExtensionError("invalid") + const decoder = new TextDecoder("utf-8", { fatal: true }) + const manifest = ManifestSchema.parse(JSON.parse(decoder.decode(files.get("manifest.json")))) + const html = decoder.decode(files.get("panel.html")) + if (!html.trim()) throw new PanelExtensionError("invalid") + return { manifest, html, digest: createHash("sha256").update(bytes).digest("hex") } + } catch (error) { throw error instanceof PanelExtensionError ? error : new PanelExtensionError("invalid") } +} diff --git a/packages/server/src/panel-extensions/contract.ts b/packages/server/src/panel-extensions/contract.ts new file mode 100644 index 000000000..915cfb392 --- /dev/null +++ b/packages/server/src/panel-extensions/contract.ts @@ -0,0 +1,29 @@ +/** Public extension contract, independent of CodeNomad's internal Solid modules. */ +export const PANEL_EXTENSION_API_VERSION = 1 +export const PANEL_EXTENSION_LIMITS = { archiveBytes: 2 * 1024 * 1024, htmlBytes: 2 * 1024 * 1024, installed: 32, storageBytes: 16 * 1024 * 1024 } as const + +export interface PanelExtensionManifest { + id: string + name: string + version: string + apiVersion: 1 + author: string + license: string + repository: string + permissions: ["session.context"] +} + +export interface PanelExtensionSummary { + manifest: PanelExtensionManifest + digest: string + global: boolean + project: boolean + enabled: boolean +} + +export interface PanelExtensionContext { + apiVersion: 1 + sessionId: string | null + locale: string + appearance: "light" | "dark" +} diff --git a/packages/server/src/panel-extensions/extension.test.ts b/packages/server/src/panel-extensions/extension.test.ts new file mode 100644 index 000000000..f00316ea1 --- /dev/null +++ b/packages/server/src/panel-extensions/extension.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import Fastify from "fastify" +import { PANEL_EXTENSION_LIMITS } from "./contract" +import { fixtureArchive, fixtureManifest, fixtureZip } from "./archive-fixture" +import { readPanelExtensionArchive } from "./archive" +import { PanelExtensionStore } from "./store" +import { registerPanelExtensionRoutes } from "../server/routes/panel-extensions" + +test("ZIP contract is bounded and rejects unsupported APIs, paths, links, duplicate entries and extra code", async () => { + const pkg = await readPanelExtensionArchive(fixtureArchive()) + assert.equal(pkg.manifest.id, fixtureManifest.id) + assert.match(pkg.digest, /^[a-f0-9]{64}$/) + for (const changes of [{ apiVersion: 2 }, { permissions: ["filesystem"] }, { repository: "https://github.com.evil.test/a/b" }, + { id: "../outside" }, { server: "index.js" }, { repository: "https://github.com/a/b?token=secret" }]) { + await assert.rejects(readPanelExtensionArchive(fixtureArchive("

x

", changes)), { code: "invalid" }) + } + const manifest = { name: "manifest.json", data: JSON.stringify(fixtureManifest) } + for (const files of [ + [manifest, { name: "../panel.html", data: "x" }], + [manifest, { name: "panel.html", data: "x", mode: 0o120777 }], + [manifest, { name: "panel.html", data: "x" }, { name: "panel.html", data: "y" }], + [manifest, { name: "panel.html", data: "x" }, { name: "index.js", data: "x" }], + [manifest, { name: "panel.html", data: "x", size: PANEL_EXTENSION_LIMITS.htmlBytes + 1 }], + [manifest, { name: "panel.html", data: Buffer.from([0xff]) }], + ]) await assert.rejects(readPanelExtensionArchive(fixtureZip(files)), { code: "invalid" }) + await assert.rejects(readPanelExtensionArchive(Buffer.alloc(PANEL_EXTENSION_LIMITS.archiveBytes + 1)), { code: "limit" }) +}) + +test("install, scoped consent, restart, replacement revocation and concurrent edits preserve authoritative state", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-panel-extension-")) + try { + let changes = 0 + const store = new PanelExtensionStore(directory, () => changes++) + const first = await readPanelExtensionArchive(fixtureArchive()) + await store.install(first) + assert.equal((await store.list("/repo"))[0].enabled, false) + await assert.rejects(store.panel(first.manifest.id, first.digest, "/repo"), { code: "disabled" }) + await Promise.all([ + store.activate(first.manifest.id, first.digest, "/repo", "project", true), + store.activate(first.manifest.id, first.digest, "/second", "project", true), + ]) + assert.equal((await new PanelExtensionStore(directory).list("/repo"))[0].enabled, true) + assert.equal((await store.list("/repo/sibling"))[0].enabled, false) + assert.equal(await store.panel(first.manifest.id, first.digest, "/second"), first.html) + await store.activate(first.manifest.id, first.digest, "/repo", "global", true) + assert.equal((await store.list("/unrelated"))[0].enabled, true) + const second = await readPanelExtensionArchive(fixtureArchive("

Updated

", { version: "1.1.0" })) + await assert.rejects(store.install(second), { code: "conflict" }) + await store.install(second, first.digest) + assert.equal((await store.list("/repo"))[0].enabled, false) + await assert.rejects(store.activate(first.manifest.id, first.digest, "/repo", "global", true), { code: "conflict" }) + await store.remove(second.manifest.id, second.digest) + assert.deepEqual(await store.list("/repo"), []) + assert.ok(changes >= 6) + await writeFile(path.join(directory, "installed.json"), "corrupt") + await assert.rejects(store.install(first), { code: "unavailable" }) + assert.equal(await readFile(path.join(directory, "installed.json"), "utf8"), "corrupt") + } finally { await rm(directory, { recursive: true, force: true }) } +}) + +test("typed routes demand consent, digest and owned folder; exports contain no executable HTML", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-panel-route-")) + const app = Fastify() + const workspace = { id: "owned", path: "/owned" } as any + registerPanelExtensionRoutes(app, { store: new PanelExtensionStore(directory), workspaceManager: { get: id => id === "owned" ? workspace : undefined } }) + try { + const archiveBase64 = fixtureArchive().toString("base64") + const inspection = await app.inject({ method: "POST", url: "/api/panel-extensions/inspect", payload: { archiveBase64 } }) + assert.equal(inspection.statusCode, 200) + const { digest } = inspection.json() + assert.equal((await app.inject({ method: "POST", url: "/api/panel-extensions", payload: { archiveBase64, digest } })).statusCode, 400) + assert.equal((await app.inject({ method: "POST", url: "/api/panel-extensions", payload: { archiveBase64, digest, acknowledged: true } })).statusCode, 200) + const panel = `/api/panel-extensions/${fixtureManifest.id}/panel?instanceId=owned&digest=${digest}` + assert.equal((await app.inject(panel)).statusCode, 403) + assert.equal((await app.inject({ method: "PATCH", url: `/api/panel-extensions/${fixtureManifest.id}?instanceId=foreign`, + payload: { digest, scope: "project", enabled: true } })).statusCode, 404) + assert.equal((await app.inject({ method: "PATCH", url: `/api/panel-extensions/${fixtureManifest.id}?instanceId=owned`, + payload: { digest, scope: "project", enabled: true, directory: "/foreign" } })).statusCode, 400) + assert.equal((await app.inject({ method: "PATCH", url: `/api/panel-extensions/${fixtureManifest.id}?instanceId=owned`, + payload: { digest, scope: "project", enabled: true } })).statusCode, 200) + assert.equal((await app.inject(panel)).json().html, "

Example

") + const catalogue = await app.inject("/api/panel-extensions?instanceId=owned") + assert.equal(catalogue.headers["cache-control"], "no-store") + assert.equal(catalogue.body.includes("

Example"), false) + assert.equal((await app.inject("/api/panel-extensions?instanceId=owned&directory=/foreign")).statusCode, 400) + const invalid = await app.inject({ method: "POST", url: "/api/panel-extensions/inspect", payload: { archiveBase64: "secret-not-a-zip" } }) + assert.equal(invalid.statusCode, 400) + assert.equal(invalid.body.includes("secret-not"), false) + } finally { await app.close(); await rm(directory, { recursive: true, force: true }) } +}) diff --git a/packages/server/src/panel-extensions/store.ts b/packages/server/src/panel-extensions/store.ts new file mode 100644 index 000000000..36478e95e --- /dev/null +++ b/packages/server/src/panel-extensions/store.ts @@ -0,0 +1,98 @@ +import { randomUUID } from "node:crypto" +import { promises as fs } from "node:fs" +import path from "node:path" +import { z } from "zod" +import { ManifestSchema, PanelExtensionError, type PanelExtensionPackage } from "./archive" +import { PANEL_EXTENSION_LIMITS, type PanelExtensionSummary } from "./contract" + +const RecordSchema = z.object({ manifest: ManifestSchema, html: z.string().max(PANEL_EXTENSION_LIMITS.htmlBytes), + digest: z.string().regex(/^[a-f0-9]{64}$/), global: z.boolean(), projects: z.array(z.string().min(1).max(4096)).max(128) }).strict() +const StoreSchema = z.object({ version: z.literal(1), records: z.array(RecordSchema).max(PANEL_EXTENSION_LIMITS.installed) }).strict() +type Stored = z.infer + +export class PanelExtensionStore { + private tail: Promise = Promise.resolve() + constructor(private readonly directory: string, private readonly changed: () => void = () => {}) {} + + async list(project: string): Promise { + return this.serialize(async () => (await this.read()).map(record => ({ manifest: record.manifest, digest: record.digest, + global: record.global, project: record.projects.includes(project), enabled: record.global || record.projects.includes(project) }))) + } + + install(pkg: PanelExtensionPackage, previousDigest?: string): Promise { + return this.mutate(records => { + const previous = records.find(record => record.manifest.id === pkg.manifest.id) + if (previous?.digest !== previousDigest) throw new PanelExtensionError("conflict") + if (previous?.digest === pkg.digest) return records + // Replacing code revokes every grant, even if its declared permissions are unchanged. + return [...records.filter(record => record !== previous), { ...pkg, global: false, projects: [] }] + }) + } + + activate(id: string, digest: string, project: string, scope: "global" | "project", enabled: boolean): Promise { + return this.mutate(records => records.map(record => { + if (record.manifest.id !== id) return record + if (record.digest !== digest) throw new PanelExtensionError("conflict") + return scope === "global" ? { ...record, global: enabled } : { ...record, + projects: enabled ? [...new Set([...record.projects, project])] : record.projects.filter(value => value !== project) } + }), id) + } + + remove(id: string, digest: string): Promise { + return this.mutate(records => records.filter(record => { + if (record.manifest.id !== id) return true + if (record.digest !== digest) throw new PanelExtensionError("conflict") + return false + }), id) + } + + async panel(id: string, digest: string, project: string): Promise { + return this.serialize(async () => { + const record = (await this.read()).find(record => record.manifest.id === id) + if (!record) throw new PanelExtensionError("missing") + if (record.digest !== digest) throw new PanelExtensionError("conflict") + if (!record.global && !record.projects.includes(project)) throw new PanelExtensionError("disabled") + return record.html + }) + } + + private async read(): Promise { + try { + const file = path.join(this.directory, "installed.json") + if ((await fs.stat(file)).size > PANEL_EXTENSION_LIMITS.storageBytes) throw new PanelExtensionError("limit") + const parsed = StoreSchema.parse(JSON.parse(await fs.readFile(file, "utf8"))) + if (new Set(parsed.records.map(record => record.manifest.id)).size !== parsed.records.length) throw new PanelExtensionError("invalid") + return parsed.records + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return [] + // Corrupt state is never silently replaced by an empty catalogue. + throw new PanelExtensionError("unavailable") + } + } + + private mutate(update: (records: Stored[]) => Stored[], requiredId?: string): Promise { + return this.serialize(async () => { + const records = await this.read() + if (requiredId && !records.some(record => record.manifest.id === requiredId)) throw new PanelExtensionError("missing") + const next = update(records) + if (!StoreSchema.safeParse({ version: 1, records: next }).success) throw new PanelExtensionError("limit") + const text = JSON.stringify({ version: 1, records: next }) + if (Buffer.byteLength(text) > PANEL_EXTENSION_LIMITS.storageBytes) throw new PanelExtensionError("limit") + await fs.mkdir(this.directory, { recursive: true }) + const temporary = path.join(this.directory, `${randomUUID()}.tmp`) + try { + await fs.writeFile(temporary, text, { flag: "wx", mode: 0o600 }) + await fs.rename(temporary, path.join(this.directory, "installed.json")) + } finally { await fs.rm(temporary, { force: true }) } + this.changed() + }) + } + + private serialize(operation: () => Promise): Promise { + // ponytail: one profile-wide IO queue; separate package files if catalogue throughput ever matters. + // Reads join writes too: Windows cannot atomically replace an open catalogue. + const work = this.tail.then(operation) + this.tail = work.catch(() => {}) + return work + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 3a7be35a6..aeca3865d 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -37,6 +37,8 @@ import { registerRemoteProxyRoutes } from "./routes/remote-proxy" import { registerSideCarRoutes } from "./routes/sidecars" import { registerPreviewRoutes } from "./routes/previews" import { registerUsageRoutes } from "./routes/usage" +import { registerPanelExtensionRoutes } from "./routes/panel-extensions" +import type { PanelExtensionStore } from "../panel-extensions/store" import { registerPluginControlRoutes } from "./routes/plugin-controls" import { PluginControls } from "../opencode/plugin-controls" import { WebSearchSettings } from "../opencode/websearch-settings" @@ -94,6 +96,7 @@ interface HttpServerDeps { logger: Logger nativeParent: NativeParent automationBridgeToken: string + panelExtensions?: PanelExtensionStore } interface HttpServerStartResult { @@ -155,7 +158,7 @@ export function createHttpServer(deps: HttpServerDeps) { ...base, params: redactSecrets(request.params), query: redactSecrets(request.query), - body: typeof request.body === "string" ? "" : redactSecrets(request.body), + body: typeof request.body === "string" || request.url.startsWith("/api/panel-extensions") ? "" : redactSecrets(request.body), }, "HTTP request payload") } done() @@ -353,6 +356,7 @@ export function createHttpServer(deps: HttpServerDeps) { }) app.addHook("onClose", async () => developerCdp.close()) registerUsageRoutes(app, { workspaceManager: deps.workspaceManager }) + if (deps.panelExtensions) registerPanelExtensionRoutes(app, { store: deps.panelExtensions, workspaceManager: deps.workspaceManager }) registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger }) setupSideCarWebSocketProxy(app, { diff --git a/packages/server/src/server/routes/panel-extensions.ts b/packages/server/src/server/routes/panel-extensions.ts new file mode 100644 index 000000000..a4fd7ae30 --- /dev/null +++ b/packages/server/src/server/routes/panel-extensions.ts @@ -0,0 +1,67 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify" +import { z } from "zod" +import type { WorkspaceManager } from "../../workspaces/manager" +import type { PanelExtensionStore } from "../../panel-extensions/store" +import { PanelExtensionError, readPanelExtensionArchive } from "../../panel-extensions/archive" +import { PANEL_EXTENSION_LIMITS } from "../../panel-extensions/contract" + +const Digest = z.string().regex(/^[a-f0-9]{64}$/) +const Scope = z.object({ instanceId: z.string().min(1).max(256) }).strict() +const Archive = z.object({ archiveBase64: z.string().min(1).max(Math.ceil(PANEL_EXTENSION_LIMITS.archiveBytes / 3) * 4) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) }) +const Id = z.object({ id: z.string().regex(/^[a-z][a-z0-9-]{1,39}\.[a-z][a-z0-9-]{1,39}$/) }).strict() + +export function registerPanelExtensionRoutes(app: FastifyInstance, deps: { + store: PanelExtensionStore; workspaceManager: Pick +}) { + const protect = (operation: (request: FastifyRequest) => Promise) => async (request: FastifyRequest, reply: FastifyReply) => { + reply.header("Cache-Control", "no-store") + try { return await operation(request) } + catch (error) { + const code = error instanceof PanelExtensionError ? error.code : error instanceof z.ZodError ? "invalid" : "unavailable" + const status = { invalid: 400, conflict: 409, limit: 413, missing: 404, disabled: 403, unavailable: 503 }[code] + return reply.code(status).send({ error: `panel-extension-${code}` }) + } + } + const project = (request: FastifyRequest) => { + const { instanceId } = Scope.parse(request.query) + const workspace = deps.workspaceManager.get(instanceId) + if (!workspace) throw new PanelExtensionError("missing") + // Exact opened physical folder, not an opaque native project ID or inferred sibling. + return workspace.path + } + const options = { bodyLimit: Math.ceil(PANEL_EXTENSION_LIMITS.archiveBytes / 3) * 4 + 4096 } + app.post("/api/panel-extensions/inspect", options, protect(async request => { + const body = Archive.strict().parse(request.body) + const { manifest, digest } = await readPanelExtensionArchive(Buffer.from(body.archiveBase64, "base64")) + return { manifest, digest } + })) + app.get("/api/panel-extensions", protect(async request => deps.store.list(project(request)))) + app.post("/api/panel-extensions", options, protect(async request => { + const body = Archive.extend({ digest: Digest, previousDigest: Digest.optional(), acknowledged: z.literal(true) }).strict().parse(request.body) + const pkg = await readPanelExtensionArchive(Buffer.from(body.archiveBase64, "base64")) + if (pkg.digest !== body.digest) throw new PanelExtensionError("conflict") + await deps.store.install(pkg, body.previousDigest) + return { installed: true } + })) + app.patch("/api/panel-extensions/:id", protect(async request => { + const folder = project(request) + const { id } = Id.parse(request.params) + const body = z.object({ digest: Digest, scope: z.enum(["global", "project"]), enabled: z.boolean() }).strict().parse(request.body) + await deps.store.activate(id, body.digest, folder, body.scope, body.enabled) + return { updated: true } + })) + app.delete("/api/panel-extensions/:id", protect(async request => { + const { id } = Id.parse(request.params) + const { digest } = z.object({ digest: Digest }).strict().parse(request.body) + await deps.store.remove(id, digest) + return { removed: true } + })) + app.get("/api/panel-extensions/:id/panel", protect(async request => { + const query = Scope.extend({ digest: Digest }).parse(request.query) + const { id } = Id.parse(request.params) + const workspace = deps.workspaceManager.get(query.instanceId) + if (!workspace) throw new PanelExtensionError("missing") + return { html: await deps.store.panel(id, query.digest, workspace.path) } + })) +} diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 9b3bde0b9..08869d913 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -502,6 +502,14 @@ dependencies = [ "tao", ] +[[package]] +name = "codenomad-panel-extension-fixture" +version = "0.0.0" +dependencies = [ + "tauri", + "tauri-build", +] + [[package]] name = "codenomad-tauri" version = "0.20.1" diff --git a/packages/tauri-app/Cargo.toml b/packages/tauri-app/Cargo.toml index eee31246b..a1c17b724 100644 --- a/packages/tauri-app/Cargo.toml +++ b/packages/tauri-app/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["src-tauri", "tests/windows-input", "tests/macos-window"] +members = ["src-tauri", "tests/windows-input", "tests/macos-window", "tests/panel-extension"] default-members = ["src-tauri"] resolver = "2" diff --git a/packages/tauri-app/tests/panel-extension/.gitignore b/packages/tauri-app/tests/panel-extension/.gitignore new file mode 100644 index 000000000..82a1a965d --- /dev/null +++ b/packages/tauri-app/tests/panel-extension/.gitignore @@ -0,0 +1,2 @@ +/gen/ +/permissions/autogenerated/ diff --git a/packages/tauri-app/tests/panel-extension/Cargo.toml b/packages/tauri-app/tests/panel-extension/Cargo.toml new file mode 100644 index 000000000..4b07aa138 --- /dev/null +++ b/packages/tauri-app/tests/panel-extension/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "codenomad-panel-extension-fixture" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +tauri = { version = "2.5.2", features = ["unstable"] } + +[build-dependencies] +tauri-build = "2.5.2" diff --git a/packages/tauri-app/tests/panel-extension/assets/index.html b/packages/tauri-app/tests/panel-extension/assets/index.html new file mode 100644 index 000000000..9112cb84a --- /dev/null +++ b/packages/tauri-app/tests/panel-extension/assets/index.html @@ -0,0 +1 @@ +Isolated extension fixture diff --git a/packages/tauri-app/tests/panel-extension/build.rs b/packages/tauri-app/tests/panel-extension/build.rs new file mode 100644 index 000000000..c5ae10196 --- /dev/null +++ b/packages/tauri-app/tests/panel-extension/build.rs @@ -0,0 +1,5 @@ +fn main() { + tauri_build::try_build(tauri_build::Attributes::new().app_manifest( + tauri_build::AppManifest::new().commands(&["probe"]), + )).expect("fixture permissions"); +} diff --git a/packages/tauri-app/tests/panel-extension/src/main.rs b/packages/tauri-app/tests/panel-extension/src/main.rs new file mode 100644 index 000000000..4160e27b5 --- /dev/null +++ b/packages/tauri-app/tests/panel-extension/src/main.rs @@ -0,0 +1,23 @@ +#[tauri::command] +fn probe(role: String) { println!("NATIVE:{role}"); } + +fn main() { + let url = std::env::args().nth(1).expect("isolated fixture URL"); + let profile = std::env::args().nth(2).expect("isolated browser profile"); + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![probe]) + .setup(move |app| { + tauri::WebviewWindowBuilder::new(app, "probe", tauri::WebviewUrl::External(url.parse()?)) + .title("Isolated panel extension fixture") + .data_directory(std::path::PathBuf::from(profile)) + .build()?; + let handle = app.handle().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(6)); + handle.exit(0); + }); + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("isolated native fixture"); +} diff --git a/packages/tauri-app/tests/panel-extension/tauri.conf.json b/packages/tauri-app/tests/panel-extension/tauri.conf.json new file mode 100644 index 000000000..17238809d --- /dev/null +++ b/packages/tauri-app/tests/panel-extension/tauri.conf.json @@ -0,0 +1,16 @@ +{ + "productName": "Isolated panel extension fixture", + "version": "0.0.0", + "identifier": "test.codenomad.panel-extension", + "build": { "frontendDist": "assets" }, + "app": { + "windows": [], + "withGlobalTauri": true, + "security": { "capabilities": [{ + "identifier": "probe", "windows": ["probe"], + "remote": { "urls": ["http://127.0.0.1:*"] }, + "permissions": ["allow-probe"] + }] } + }, + "bundle": { "active": false, "icon": ["../../src-tauri/icon.ico"] } +} diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 2d259758b..f47c4d3a2 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -34,6 +34,12 @@ import { FILES_PANEL_MIGRATION_KEY, mergeFilesPanelCustomization } from "./files import { loadRightPanelPluginManifests, type RightPanelPluginLoadError } from "./plugin-manifest" import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" +import { useI18n } from "../../../../lib/i18n" +import { useTheme } from "../../../../lib/theme" +import { ExtensionPanel } from "../../../panel-extensions/extension-panel" +import { ExtensionManager } from "../../../panel-extensions/extension-manager" +import { usePanelExtensions } from "../../../panel-extensions/use-panel-extensions" +import type { RightPanelModule } from "./registry" function RightPanelTabFallback() { return

@@ -94,6 +100,9 @@ interface RightPanelProps { } const RightPanel: Component = (props) => { + const { locale } = useI18n() + const theme = useTheme() + const extensions = usePanelExtensions(() => props.instanceId, props.isActive) const savedTab = readStoredRightPanelTab("files") const [rightPanelTab, setRightPanelTab] = createSignal(savedTab === "git-changes" ? "files" : savedTab) const defaultStatusSectionIds = CORE_STATUS_SECTION_ITEMS.map((section) => section.id) @@ -227,7 +236,14 @@ const RightPanel: Component = (props) => { }, ) - const rightPanelModules = createMemo(() => rightPanelPluginRuntime.modules) + const externalModules = createMemo(() => extensions.entries().filter(entry => entry.enabled).map(entry => { + const id = `extension:${entry.manifest.id}` + return { id, displayNameKey: "", origin: "external", tabs: [{ id, labelKey: "", label: entry.manifest.name, order: 1000, + render: () => , + }] } + })) + const rightPanelModules = createMemo(() => [...rightPanelPluginRuntime.modules, ...externalModules()]) const rightPanelPluginErrors = createMemo(() => rightPanelPluginRuntime.errors) const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs")) const visibleRightPanelTabs = createMemo(() => @@ -296,7 +312,7 @@ const RightPanel: Component = (props) => { active={rightPanelTab() === tab.id} tabId={tabId(tab.id)} panelId={tabPanelId(tab.id)} - label={props.t(tab.labelKey)} + label={tab.label ?? props.t(tab.labelKey)} dragTitle={props.t("instanceShell.rightPanel.customize.dragToReorder")} tabIndex={rightPanelTab() === tab.id ? 0 : -1} onSelect={() => setRightPanelTab(tab.id)} @@ -317,7 +333,7 @@ const RightPanel: Component = (props) => {
{(tab) => { - const label = () => props.t(tab.labelKey) + const label = () => tab.label ?? props.t(tab.labelKey) const visible = () => tab.alwaysVisible || !rightPanelCustomization().hiddenTabIds.includes(tab.id) return ( <> @@ -394,6 +410,7 @@ const RightPanel: Component = (props) => { > {props.t("instanceShell.rightPanel.customize.reset")} + props.instanceId} controller={extensions} />
diff --git a/packages/ui/src/components/instance/shell/right-panel/registry.ts b/packages/ui/src/components/instance/shell/right-panel/registry.ts index 47aba6356..004febe61 100644 --- a/packages/ui/src/components/instance/shell/right-panel/registry.ts +++ b/packages/ui/src/components/instance/shell/right-panel/registry.ts @@ -3,6 +3,8 @@ import type { JSX } from "solid-js" export interface RightPanelItem { id: string labelKey: string + /** External author label; never registered in the application's i18n catalogue. */ + label?: string order: number alwaysVisible?: boolean } @@ -21,7 +23,7 @@ export interface RightPanelModule { id: string displayNameKey: string descriptionKey?: string - origin: "first-party" + origin: "first-party" | "external" tabs?: readonly RightPanelTabModule[] statusSections?: readonly RightPanelSectionModule[] } diff --git a/packages/ui/src/components/panel-extensions/extension-manager.tsx b/packages/ui/src/components/panel-extensions/extension-manager.tsx new file mode 100644 index 000000000..c55265bef --- /dev/null +++ b/packages/ui/src/components/panel-extensions/extension-manager.tsx @@ -0,0 +1,92 @@ +import { For, Show, createEffect, createSignal, type Accessor } from "solid-js" +import type { PanelExtensionManifest } from "../../../../server/src/api-types" +import { PANEL_EXTENSION_LIMITS } from "../../../../server/src/panel-extensions/contract" +import { panelExtensionsApi } from "../../lib/panel-extensions-api" +import { useI18n } from "../../lib/i18n" +import type { PanelExtensionsController } from "./use-panel-extensions" + +export function ExtensionManager(props: { instanceId: Accessor; controller: PanelExtensionsController }) { + const { t } = useI18n() + const [busy, setBusy] = createSignal(false), [failed, setFailed] = createSignal(false), [acknowledged, setAcknowledged] = createSignal(false) + const [preview, setPreview] = createSignal<{ manifest: PanelExtensionManifest; digest: string; archive: string; previousDigest?: string }>() + const [removal, setRemoval] = createSignal<{ id: string; digest: string }>() + createEffect(() => { + const pending = removal() + if (pending && !props.controller.entries().some(entry => entry.manifest.id === pending.id && entry.digest === pending.digest)) setRemoval(undefined) + }) + let picker!: HTMLInputElement + const run = async (operation: () => Promise) => { + if (busy()) return + setBusy(true); setFailed(false) + try { await operation(); await props.controller.refresh() } catch { setFailed(true) } + finally { setBusy(false) } + } + const inspect = async (file: File | undefined) => { + if (!file) return + setPreview(undefined); setAcknowledged(false) + await run(async () => { + if (file.size > PANEL_EXTENSION_LIMITS.archiveBytes) throw new Error("limit") + const archive = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result).split(",")[1]) + reader.onerror = reject + reader.readAsDataURL(file) + }) + const result = await panelExtensionsApi.inspect(archive) + const previousDigest = props.controller.entries().find(entry => entry.manifest.id === result.manifest.id)?.digest + setPreview({ ...result, archive, previousDigest }) + }) + } + return
+

{t("panelExtensions.title")}

+ { + const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; void inspect(file) + }} /> + + +

{t("panelExtensions.error")}

+ +
+ {pkg =>
+ {pkg().manifest.name} {pkg().manifest.version} +

{pkg().manifest.author} · {pkg().manifest.license} · API {pkg().manifest.apiVersion}

+

{pkg().manifest.repository}

+ {pkg().digest} +

{t("panelExtensions.permission")}

+

{t("panelExtensions.warning")}

+ +
+ + +
+
}
+ {entry =>
+ {entry.manifest.name} {entry.manifest.version} +
+ + + +
+ +

{t("panelExtensions.removeWarning")}

+ + +
+
}
+
+} diff --git a/packages/ui/src/components/panel-extensions/extension-panel.tsx b/packages/ui/src/components/panel-extensions/extension-panel.tsx new file mode 100644 index 000000000..279def21d --- /dev/null +++ b/packages/ui/src/components/panel-extensions/extension-panel.tsx @@ -0,0 +1,45 @@ +import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import type { PanelExtensionContext, PanelExtensionSummary } from "../../../../server/src/api-types" +import { panelExtensionsApi } from "../../lib/panel-extensions-api" +import { useI18n } from "../../lib/i18n" +import { PANEL_EXTENSION_SANDBOX, panelExtensionDocument } from "./frame-document" + +export function ExtensionPanel(props: { + entry: PanelExtensionSummary; instanceId: string; active: boolean; context: PanelExtensionContext +}) { + const identity = createMemo(() => props.active ? JSON.stringify([props.instanceId, props.entry.digest, props.context.sessionId]) : null) + return {_identity => } +} + +function PanelFrame(props: { entry: PanelExtensionSummary; instanceId: string; context: PanelExtensionContext }) { + const { t } = useI18n() + const [document, setDocument] = createSignal() + const [failed, setFailed] = createSignal(false) + let frame: HTMLIFrameElement | undefined, port: MessagePort | undefined, disposed = false, initialized = false + let authenticated = false + const handshake = crypto.randomUUID() + const controller = new AbortController() + void panelExtensionsApi.panel(props.instanceId, props.entry.manifest.id, props.entry.digest, controller.signal).then(result => { + if (!disposed) setDocument(panelExtensionDocument(result.html, handshake)) + }).catch(() => { if (!disposed) setFailed(true) }) + const publish = () => { const context = { ...props.context }; if (authenticated) port?.postMessage({ type: "context", context }) } + createEffect(publish) + onCleanup(() => { disposed = true; controller.abort(); port?.close() }) + const loaded = () => { + // Never reconnect the capability channel after self-navigation/reload. + if (initialized) { authenticated = false; port?.close(); setDocument(undefined); setFailed(true); return } + initialized = true + const channel = new MessageChannel() + port = channel.port1 + port.onmessage = event => { + if (!disposed && !authenticated && event.data?.type === "ready" && event.data.handshake === handshake) { authenticated = true; publish() } + } + frame?.contentWindow?.postMessage({ type: "codenomad:init" }, "*", [channel.port2]) + } + return
+

{t("panelExtensions.error")}

+ {source => `) +}) +const profile = await mkdtemp(path.join(process.env.TEMP || os.tmpdir(), "opencode-panel-native-")) +try { + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + const child = spawn(path.join(target, "debug/codenomad-panel-extension-fixture.exe"), [ + `http://127.0.0.1:${server.address().port}`, profile, + ], { cwd: workspace, env, stdio: ["ignore", "pipe", "pipe"] }) + let output = "" + child.stdout.on("data", chunk => { output += chunk }) + child.stderr.on("data", chunk => { output += chunk }) + const status = await new Promise((resolve, reject) => { child.on("error", reject); child.on("close", resolve) }) + assert.equal(status, 0, output) + assert.match(output, /NATIVE:parent/, "Positive native control must succeed") + assert.doesNotMatch(output, /NATIVE:(?:child|raw-child|message-child)/, "Author code must not reach native commands") + assert.equal(reports.length, 1, JSON.stringify(reports)) + assert.equal(reports[0].parent, "blocked") + assert.equal(reports[0].network, "blocked") + assert.equal(reports[0].context, "native-session") + console.log(JSON.stringify({ nativeParent: "allowed", nativeChild: "blocked", ...reports[0] }, null, 2)) +} finally { + await new Promise(resolve => server.close(resolve)) + // WebView2 releases its child-process lock shortly after the host exits. + await rm(profile, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }) +}