From df8b10ef3757dad793190ca2c4c5205960be9f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 6 Oct 2026 12:08:18 +0200 Subject: [PATCH 1/2] feat(extensions): distribute standalone right-panel addons Allow users to inspect and install author-owned GitHub release ZIPs without rebuilding CodeNomad or touching the shared OpenCode daemon. Require an exact manifest/API contract and trust confirmation, start disabled, persist profile-wide or exact-folder grants, and revoke every activation when replacing code. Keep external HTML outside the primary renderer behind an opaque sandbox, restrictive CSP and per-mount context channel. Fence panel responses and author ports across session/project changes, disconnects, revocation and replacement; bind removal consent to the reviewed package digest so another window cannot redirect it to newer code. Document immutable GitHub release/checksum rules and public API compatibility with an independent starter. Add bounded ZIP/store/route regressions, real right-panel browser coverage, and an isolated Tauri/WebView2 native-command denial fixture with positive parent control. Keep the assets-history capability, marketplaces and automatic updates out of this distribution PR. --- .../codenomad-architecture-guide/SKILL.md | 5 + AGENTS.md | 1 + dev-docs/PANEL_EXTENSIONS.md | 166 +++++++++++++++++ examples/panel-extension/LICENSE | 21 +++ examples/panel-extension/README.md | 26 +++ examples/panel-extension/manifest.json | 10 ++ examples/panel-extension/panel.html | 18 ++ packages/server/src/api-types.ts | 1 + packages/server/src/index.ts | 6 + .../src/panel-extensions/archive-fixture.ts | 27 +++ .../server/src/panel-extensions/archive.ts | 68 +++++++ .../server/src/panel-extensions/contract.ts | 29 +++ .../src/panel-extensions/extension.test.ts | 94 ++++++++++ packages/server/src/panel-extensions/store.ts | 98 ++++++++++ packages/server/src/server/http-server.ts | 6 +- .../src/server/routes/panel-extensions.ts | 67 +++++++ packages/tauri-app/Cargo.lock | 8 + packages/tauri-app/Cargo.toml | 2 +- .../tests/panel-extension/.gitignore | 2 + .../tests/panel-extension/Cargo.toml | 11 ++ .../tests/panel-extension/assets/index.html | 1 + .../tauri-app/tests/panel-extension/build.rs | 5 + .../tests/panel-extension/src/main.rs | 23 +++ .../tests/panel-extension/tauri.conf.json | 16 ++ .../instance/shell/right-panel/RightPanel.tsx | 23 ++- .../instance/shell/right-panel/registry.ts | 4 +- .../panel-extensions/extension-manager.tsx | 92 ++++++++++ .../panel-extensions/extension-panel.tsx | 45 +++++ .../panel-extensions/frame-document.ts | 29 +++ .../panel-extensions/use-panel-extensions.ts | 34 ++++ .../ui/src/lib/i18n/messages/de/instance.ts | 13 ++ .../ui/src/lib/i18n/messages/en/instance.ts | 13 ++ .../ui/src/lib/i18n/messages/es/instance.ts | 13 ++ .../ui/src/lib/i18n/messages/fr/instance.ts | 13 ++ .../ui/src/lib/i18n/messages/he/instance.ts | 13 ++ .../ui/src/lib/i18n/messages/ja/instance.ts | 13 ++ .../ui/src/lib/i18n/messages/ne/instance.ts | 13 ++ .../ui/src/lib/i18n/messages/ru/instance.ts | 13 ++ .../ui/src/lib/i18n/messages/tr/instance.ts | 13 ++ .../src/lib/i18n/messages/zh-Hans/instance.ts | 13 ++ packages/ui/src/lib/panel-extensions-api.ts | 26 +++ packages/ui/src/styles/panels.css | 1 + .../ui/src/styles/panels/panel-extensions.css | 56 ++++++ .../browser/fixtures/panel-extensions.tsx | 25 +++ .../ui/tests/browser/fixtures/tab-chrome.tsx | 3 +- .../ui/tests/browser/panel-extensions.test.ts | 168 ++++++++++++++++++ scripts/test-panel-extension-native.mjs | 78 ++++++++ 47 files changed, 1418 insertions(+), 7 deletions(-) create mode 100644 dev-docs/PANEL_EXTENSIONS.md create mode 100644 examples/panel-extension/LICENSE create mode 100644 examples/panel-extension/README.md create mode 100644 examples/panel-extension/manifest.json create mode 100644 examples/panel-extension/panel.html create mode 100644 packages/server/src/panel-extensions/archive-fixture.ts create mode 100644 packages/server/src/panel-extensions/archive.ts create mode 100644 packages/server/src/panel-extensions/contract.ts create mode 100644 packages/server/src/panel-extensions/extension.test.ts create mode 100644 packages/server/src/panel-extensions/store.ts create mode 100644 packages/server/src/server/routes/panel-extensions.ts create mode 100644 packages/tauri-app/tests/panel-extension/.gitignore create mode 100644 packages/tauri-app/tests/panel-extension/Cargo.toml create mode 100644 packages/tauri-app/tests/panel-extension/assets/index.html create mode 100644 packages/tauri-app/tests/panel-extension/build.rs create mode 100644 packages/tauri-app/tests/panel-extension/src/main.rs create mode 100644 packages/tauri-app/tests/panel-extension/tauri.conf.json create mode 100644 packages/ui/src/components/panel-extensions/extension-manager.tsx create mode 100644 packages/ui/src/components/panel-extensions/extension-panel.tsx create mode 100644 packages/ui/src/components/panel-extensions/frame-document.ts create mode 100644 packages/ui/src/components/panel-extensions/use-panel-extensions.ts create mode 100644 packages/ui/src/lib/panel-extensions-api.ts create mode 100644 packages/ui/src/styles/panels/panel-extensions.css create mode 100644 packages/ui/tests/browser/fixtures/panel-extensions.tsx create mode 100644 packages/ui/tests/browser/panel-extensions.test.ts create mode 100644 scripts/test-panel-extension-native.mjs 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 }) +} From ac2e85b9b3060c9361bfcc331387e27c89bb9e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 6 Oct 2026 23:55:09 +0200 Subject: [PATCH 2/2] feat(extensions): browse and install the official GitHub addon catalogue List searchable online addons inside right-panel customization and let users inspect an exact GitHub Release before explicit trust confirmation. Keep local ZIP installation available offline, incompatible APIs unselectable, and installed addons independent of catalogue availability. Publish the curated index and standalone distribution demo in NeuralNomadsAI/CodeNomad-Extensions. Keep discovery metadata-only and display snapshots coalesced. Re-read the authoritative index and verify exact archive SHA-256 plus manifest for inspection and installation; reject withdrawn or changed selections. Bound credential-free HTTPS downloads by host, redirect count, byte budget and deadline. Preserve previous-digest replacement consent and revoke activation on changed code. Cover online search/consent, incompatible APIs, withdrawal, offline ZIP fallback, manifest/hash tampering, redirects and oversized responses with server and real-component browser regressions. Supply the existing Status fixture with the theme context required by the right panel. UI/server typechecks, focused tests, live isolated GitHub download and Windows Tauri/WebView2 security checks pass; independent gatekeeper review reports zero P1/P2 findings. --- AGENTS.md | 2 +- dev-docs/PANEL_EXTENSIONS.md | 63 ++++++++-- packages/server/src/api-types.ts | 2 +- .../src/panel-extensions/catalog.test.ts | 115 ++++++++++++++++++ .../server/src/panel-extensions/catalog.ts | 86 +++++++++++++ .../server/src/panel-extensions/contract.ts | 13 ++ .../src/server/routes/panel-extensions.ts | 20 ++- .../panel-extensions/extension-catalog.tsx | 55 +++++++++ .../panel-extensions/extension-manager.tsx | 22 +++- .../ui/src/lib/i18n/messages/de/instance.ts | 10 ++ .../ui/src/lib/i18n/messages/en/instance.ts | 10 ++ .../ui/src/lib/i18n/messages/es/instance.ts | 10 ++ .../ui/src/lib/i18n/messages/fr/instance.ts | 10 ++ .../ui/src/lib/i18n/messages/he/instance.ts | 10 ++ .../ui/src/lib/i18n/messages/ja/instance.ts | 10 ++ .../ui/src/lib/i18n/messages/ne/instance.ts | 10 ++ .../ui/src/lib/i18n/messages/ru/instance.ts | 10 ++ .../ui/src/lib/i18n/messages/tr/instance.ts | 10 ++ .../src/lib/i18n/messages/zh-Hans/instance.ts | 10 ++ packages/ui/src/lib/panel-extensions-api.ts | 9 +- .../ui/src/styles/panels/panel-extensions.css | 17 +++ .../tests/browser/fixtures/status-panel.tsx | 3 +- .../ui/tests/browser/panel-extensions.test.ts | 74 ++++++++++- 23 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 packages/server/src/panel-extensions/catalog.test.ts create mode 100644 packages/server/src/panel-extensions/catalog.ts create mode 100644 packages/ui/src/components/panel-extensions/extension-catalog.tsx diff --git a/AGENTS.md b/AGENTS.md index 17b51e184..fa34ddd72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +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. +- External panel extensions use `components/panel-extensions/` and `styles/panels/panel-extensions.css`. The official online catalogue is in `NeuralNomadsAI/CodeNomad-Extensions`; metadata discovery never downloads author code. Inspect/install revalidate the exact index/manifest/digest and use bounded credential-free HTTPS GitHub release downloads, never client-provided URLs. 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 index 96409c476..7736b512a 100644 --- a/dev-docs/PANEL_EXTENSIONS.md +++ b/dev-docs/PANEL_EXTENSIONS.md @@ -1,9 +1,18 @@ # 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. +OpenCode plugins. Open **Customize right panel → Panel extensions → Available +online** to browse/search the official catalogue, choose an addon, review the +downloaded package and confirm installation. No manual ZIP download, CodeNomad +rebuild or shared OpenCode service restart is involved. Manual ZIP installation +remains available for offline/unlisted packages. + +The official index lives in +[`NeuralNomadsAI/CodeNomad-Extensions`](https://github.com/NeuralNomadsAI/CodeNomad-Extensions). +That repository may hold **multiple** official addons plus the catalogue. Other +authors can host addons in their own repositories; the curated catalogue points +to their exact release assets. Separate means separate from the main CodeNomad +application repository, not a mandatory repository per addon. This first distribution contract intentionally exposes only session identity and appearance. It does **not** implement #801's gallery or grant transcript/image/file @@ -80,7 +89,7 @@ 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, +There is no arbitrary 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. @@ -147,14 +156,48 @@ 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. +directory and other files). Author-owned repositories remain supported. Submit a +catalogue PR to the official repository with the full manifest, plain-text +description, exact tag/ZIP filename and SHA-256; see its `CONTRIBUTING.md`. + +## Online catalogue and download boundary + +The backend fetches only the fixed public HTTPS index +`https://raw.githubusercontent.com/NeuralNomadsAI/CodeNomad-Extensions/main/catalog.json`. +Opening the manager loads metadata only, not every addon. Display reads coalesce +and cache for 30 seconds; Refresh bypasses the snapshot. A failed online read never +disables installed addons or local ZIP installation. The UI shows unsupported +API/permission entries as incompatible and disables their install action. + +Selecting an entry reads a fresh index and downloads that **exact** tagged asset. +The hash and full ZIP manifest must match the catalogue. Confirmation repeats the +fresh index/download verification: withdrawal, changed digest/metadata, corrupt +bytes, a changed installed target or missing consent cannot silently install a +different package. Updates remain explicit version changes, never automatic. + +Index schema 1 is `{ schemaVersion: 1, extensions: [...] }`. Each entry has +`manifest`, `description`, `digest` and `release: { tag, asset }`; unknown fields +and duplicate addon IDs are rejected. Limits are 128 entries and 256 KiB UTF-8. +Packages use the same existing ZIP/storage budgets and disabled-first policy. + +Clients submit addon ID/digest, **not a URL**. Repository/tag/asset URLs are derived +from the validated index. Downloads allow only HTTPS `github.com` and its exact +public release CDN hosts `release-assets.githubusercontent.com` and +`objects.githubusercontent.com`, with at most three redirects and a 15-second +deadline. Credentials, nonstandard ports, arbitrary hosts/protocols, redirected +catalogues and oversized streamed bodies fail closed. No GitHub token, CodeNomad +cookie, directory, session content or provider secret is sent upstream. + +The reviewed index is the integrity source, not a cryptographic author signature. +Removing an index entry blocks future online installation, not installed copies +or explicit local ZIP installation. A compromised approved author/index is not +made safe by a checksum: user trust, minimal permissions and isolation still apply. ## Checks ```sh node --import tsx --test packages/server/src/panel-extensions/extension.test.ts +node --import tsx --test packages/server/src/panel-extensions/catalog.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 @@ -162,5 +205,7 @@ 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. +preservation. Catalogue checks cover SSRF/redirect/budget rejection, manifest/hash +verification, metadata-only discovery and withdrawn selections after warm reads. +Rendered tests use the real right panel, installer, routes and event dispatcher +for online browse/search/consent, offline fallback, incompatibility and lifecycle. diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 5182b89e4..d0e95567a 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -7,7 +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 { PanelExtensionManifest, PanelExtensionSummary, PanelExtensionContext, PanelExtensionCatalog, PanelExtensionCatalogEntry } from "./panel-extensions/contract" export type { GitHistoryCommit, GitHistoryPage, GitCommitFile, GitCommitDetails, GitCommitDiff } from "./git-history-types" /** diff --git a/packages/server/src/panel-extensions/catalog.test.ts b/packages/server/src/panel-extensions/catalog.test.ts new file mode 100644 index 000000000..d3b7c187f --- /dev/null +++ b/packages/server/src/panel-extensions/catalog.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { createHash } from "node:crypto" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import Fastify from "fastify" +import { createPanelExtensionCatalog, PANEL_EXTENSION_CATALOG_URL } from "./catalog" +import { fixtureArchive, fixtureManifest } from "./archive-fixture" +import { PanelExtensionStore } from "./store" +import { registerPanelExtensionRoutes } from "../server/routes/panel-extensions" + +const archive = fixtureArchive() +const digest = createHash("sha256").update(archive).digest("hex") +const entry = { manifest: fixtureManifest, description: "Example panel", digest, release: { tag: "v1.0.0", asset: "example.session-1.0.0.zip" } } +const index = (entries: unknown[] = [entry]) => JSON.stringify({ schemaVersion: 1, extensions: entries }) +const binary = (bytes: Buffer) => new Uint8Array(bytes).buffer +const fetcher = (handler: (url: string, init?: RequestInit) => Response | Promise) => + ((url: string | URL | Request, init?: RequestInit) => Promise.resolve(handler(String(url), init))) as typeof fetch + +test("catalogue discovery fetches metadata only, coalesces display reads and shows unsupported APIs safely", async () => { + const calls: string[] = [] + const catalog = createPanelExtensionCatalog(fetcher((url, init) => { + calls.push(url) + assert.equal(init?.redirect, "manual"); assert.equal(init?.credentials, "omit") + assert.equal(new Headers(init?.headers).has("Authorization"), false) + return new Response(index([entry, { ...entry, manifest: { ...fixtureManifest, id: "other.example", apiVersion: 2 } }])) + })) + const [first, second] = await Promise.all([catalog.list(), catalog.list()]) + assert.equal(first, second) + assert.deepEqual(first.entries.map(value => value.compatible), [true, false]) + await catalog.list() + assert.deepEqual(calls, [PANEL_EXTENSION_CATALOG_URL]) + await assert.rejects(catalog.inspect("other.example", digest), { code: "invalid" }) + assert.equal(calls.length, 2) +}) + +test("inspection verifies exact manifest/hash and allows only bounded HTTPS GitHub release redirects", async () => { + const calls: string[] = [] + const asset = "https://release-assets.githubusercontent.com/github-production-release-asset/test?signature=public" + const catalog = createPanelExtensionCatalog(fetcher(url => { + calls.push(url) + if (url === PANEL_EXTENSION_CATALOG_URL) return new Response(index()) + if (url === asset) return new Response(binary(archive)) + return new Response(null, { status: 302, headers: { location: asset } }) + })) + const pkg = await catalog.inspect(fixtureManifest.id, digest) + assert.equal(pkg.html, "

Example

") + assert.deepEqual(calls, [PANEL_EXTENSION_CATALOG_URL, "https://github.com/example/session/releases/download/v1.0.0/example.session-1.0.0.zip", asset]) + for (const location of ["http://github.com/a", "https://127.0.0.1/private", "https://github.com.evil.test/a", "https://github.com:444/a", "https://user:secret@github.com/a"]) { + const seen: string[] = [] + const blocked = createPanelExtensionCatalog(fetcher(url => { + seen.push(url) + return url === PANEL_EXTENSION_CATALOG_URL ? new Response(index()) : new Response(null, { status: 302, headers: { location } }) + })) + await assert.rejects(blocked.inspect(fixtureManifest.id, digest), { code: "invalid" }) + assert.equal(seen.length, 2, "Forbidden redirect is never requested") + } +}) + +test("warm display snapshots never authorize withdrawn, changed or tampered packages", async () => { + let current = index(), bytes = archive + const catalog = createPanelExtensionCatalog(fetcher(url => new Response(url === PANEL_EXTENSION_CATALOG_URL ? current : binary(bytes)))) + await catalog.list() + current = index([]) + await assert.rejects(catalog.inspect(fixtureManifest.id, digest), { code: "missing" }) + current = index([{ ...entry, digest: "0".repeat(64) }]) + await assert.rejects(catalog.inspect(fixtureManifest.id, digest), { code: "conflict" }) + current = index(); bytes = fixtureArchive("

Wrong bytes

") + await assert.rejects(catalog.inspect(fixtureManifest.id, digest), { code: "conflict" }) + bytes = fixtureArchive("

Example

", { author: "Different author" }) + const changedDigest = createHash("sha256").update(bytes).digest("hex") + current = index([{ ...entry, digest: changedDigest }]) + await assert.rejects(catalog.inspect(fixtureManifest.id, changedDigest), { code: "conflict" }) +}) + +test("invalid catalogues, duplicate identities, oversized bodies and redirect loops fail closed", async () => { + for (const body of ["invalid", index([entry, entry]), index([{ ...entry, release: { ...entry.release, asset: "../outside.zip" } }]), + JSON.stringify({ schemaVersion: 2, extensions: [] }), index([{ ...entry, url: "https://evil.test" }])]) { + const catalog = createPanelExtensionCatalog(fetcher(() => new Response(body))) + await assert.rejects(catalog.list()) + } + await assert.rejects(createPanelExtensionCatalog(fetcher(() => new Response("x".repeat(256 * 1024 + 1)))).list(), { code: "limit" }) + const huge = createPanelExtensionCatalog(fetcher(url => new Response(url === PANEL_EXTENSION_CATALOG_URL ? index() : binary(Buffer.alloc(2 * 1024 * 1024 + 1))))) + await assert.rejects(huge.inspect(fixtureManifest.id, digest), { code: "limit" }) + let calls = 0 + const loop = createPanelExtensionCatalog(fetcher(url => { + calls++ + return url === PANEL_EXTENSION_CATALOG_URL ? new Response(index()) : new Response(null, { status: 302, headers: { location: url } }) + })) + await assert.rejects(loop.inspect(fixtureManifest.id, digest), { code: "invalid" }) + assert.equal(calls, 5) +}) + +test("catalogue routes require exact selection and consent, revalidate at install, and never accept URLs", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-catalog-route-")), store = new PanelExtensionStore(root) + let current = index() + const app = Fastify() + registerPanelExtensionRoutes(app, { store, workspaceManager: { get: () => undefined }, + catalog: createPanelExtensionCatalog(fetcher(url => new Response(url === PANEL_EXTENSION_CATALOG_URL ? current : binary(archive)))) }) + try { + assert.equal((await app.inject("/api/panel-extensions/catalog")).json().entries[0].compatible, true) + const selection = { id: fixtureManifest.id, digest } + assert.equal((await app.inject({ method: "POST", url: "/api/panel-extensions/catalog/inspect", payload: selection })).statusCode, 200) + assert.deepEqual(await store.list("/repo"), []) + assert.equal((await app.inject({ method: "POST", url: "/api/panel-extensions/catalog/install", payload: selection })).statusCode, 400) + assert.equal((await app.inject({ method: "POST", url: "/api/panel-extensions/catalog/install", payload: { ...selection, acknowledged: true, url: "http://localhost/secret" } })).statusCode, 400) + current = index([]) + assert.equal((await app.inject({ method: "POST", url: "/api/panel-extensions/catalog/install", payload: { ...selection, acknowledged: true } })).statusCode, 404) + assert.deepEqual(await store.list("/repo"), []) + current = index() + assert.equal((await app.inject({ method: "POST", url: "/api/panel-extensions/catalog/install", payload: { ...selection, acknowledged: true } })).statusCode, 200) + assert.equal((await store.list("/repo"))[0].enabled, false) + } finally { await app.close(); await rm(root, { recursive: true, force: true }) } +}) diff --git a/packages/server/src/panel-extensions/catalog.ts b/packages/server/src/panel-extensions/catalog.ts new file mode 100644 index 000000000..105062839 --- /dev/null +++ b/packages/server/src/panel-extensions/catalog.ts @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto" +import { isDeepStrictEqual } from "node:util" +import { z } from "zod" +import { ManifestSchema, PanelExtensionError, readPanelExtensionArchive } from "./archive" +import { PANEL_EXTENSION_API_VERSION, PANEL_EXTENSION_LIMITS, type PanelExtensionCatalog } from "./contract" + +export const PANEL_EXTENSION_CATALOG_URL = "https://raw.githubusercontent.com/NeuralNomadsAI/CodeNomad-Extensions/main/catalog.json" +const CatalogSchema = z.object({ schemaVersion: z.literal(1), extensions: z.array(z.object({ + manifest: ManifestSchema.extend({ apiVersion: z.number().int().min(1).max(100), permissions: z.array(z.string().min(1).max(80)).max(8) }), + description: z.string().trim().min(1).max(600), + digest: z.string().regex(/^[a-f0-9]{64}$/), + release: z.object({ tag: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/), + asset: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,159}\.zip$/) }).strict(), +}).strict()).max(128) }).strict() + +export function createPanelExtensionCatalog(fetcher: typeof globalThis.fetch = globalThis.fetch) { + let cached: { value: PanelExtensionCatalog; expires: number } | undefined + let pending: Promise | undefined + const load = async (): Promise => { + try { + const bytes = await download(PANEL_EXTENSION_CATALOG_URL, 256 * 1024, true) + const parsed = CatalogSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))) + if (new Set(parsed.extensions.map(entry => entry.manifest.id)).size !== parsed.extensions.length) throw new PanelExtensionError("invalid") + return { source: PANEL_EXTENSION_CATALOG_URL, entries: parsed.extensions.map(entry => ({ ...entry, + compatible: entry.manifest.apiVersion === PANEL_EXTENSION_API_VERSION && isDeepStrictEqual(entry.manifest.permissions, ["session.context"]), + })) } + } catch (error) { throw error instanceof PanelExtensionError ? error : new PanelExtensionError("unavailable") } + } + const list = (fresh = false): Promise => { + // Display snapshots only. Inspection and installation never trust this cache. + if (fresh) return load() + if (cached && cached.expires > Date.now()) return Promise.resolve(cached.value) + if (!pending) { + pending = load().then(value => { cached = { value, expires: Date.now() + 30_000 }; return value }).finally(() => { pending = undefined }) + } + return pending + } + const inspect = async (id: string, digest: string) => { + const entry = (await list(true)).entries.find(entry => entry.manifest.id === id) + if (!entry) throw new PanelExtensionError("missing") + if (entry.digest !== digest) throw new PanelExtensionError("conflict") + if (!entry.compatible) throw new PanelExtensionError("invalid") + const url = `${entry.manifest.repository.replace(/\/$/, "")}/releases/download/${encodeURIComponent(entry.release.tag)}/${encodeURIComponent(entry.release.asset)}` + const bytes = await download(url, PANEL_EXTENSION_LIMITS.archiveBytes) + if (createHash("sha256").update(bytes).digest("hex") !== digest) throw new PanelExtensionError("conflict") + const pkg = await readPanelExtensionArchive(bytes) + if (!isDeepStrictEqual(pkg.manifest, entry.manifest)) throw new PanelExtensionError("conflict") + return pkg + } + + async function download(initial: string, limit: number, catalogue = false): Promise { + const signal = AbortSignal.timeout(15_000) + let next = initial + try { + for (let redirects = 0; redirects <= 3; redirects++) { + const url = new URL(next) + const allowed = catalogue ? url.href === PANEL_EXTENSION_CATALOG_URL : ["github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"].includes(url.hostname) + if (!allowed || url.protocol !== "https:" || url.port || url.username || url.password || url.hash) throw new PanelExtensionError("invalid") + const response = await fetcher(url.href, { redirect: "manual", signal, credentials: "omit", headers: { Accept: catalogue ? "application/json" : "application/octet-stream" } }) + if ([301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel() + const target = response.headers.get("location") + if (!target || catalogue) throw new PanelExtensionError("invalid") + next = new URL(target, url).href + continue + } + if (!response.ok || !response.body) { await response.body?.cancel(); throw new PanelExtensionError("unavailable") } + if (Number(response.headers.get("content-length")) > limit) { await response.body.cancel(); throw new PanelExtensionError("limit") } + const reader = response.body.getReader(), chunks: Uint8Array[] = [] + let size = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > limit) throw new PanelExtensionError("limit") + chunks.push(value) + } + return Buffer.concat(chunks, size) + } finally { await reader.cancel(); reader.releaseLock() } + } + throw new PanelExtensionError("invalid") + } catch (error) { throw error instanceof PanelExtensionError ? error : new PanelExtensionError("unavailable") } + } + return { list, inspect } +} diff --git a/packages/server/src/panel-extensions/contract.ts b/packages/server/src/panel-extensions/contract.ts index 915cfb392..331fd32f2 100644 --- a/packages/server/src/panel-extensions/contract.ts +++ b/packages/server/src/panel-extensions/contract.ts @@ -27,3 +27,16 @@ export interface PanelExtensionContext { locale: string appearance: "light" | "dark" } + +export interface PanelExtensionCatalogEntry { + manifest: Omit & { apiVersion: number; permissions: string[] } + description: string + digest: string + release: { tag: string; asset: string } + compatible: boolean +} + +export interface PanelExtensionCatalog { + source: string + entries: PanelExtensionCatalogEntry[] +} diff --git a/packages/server/src/server/routes/panel-extensions.ts b/packages/server/src/server/routes/panel-extensions.ts index a4fd7ae30..3d1759160 100644 --- a/packages/server/src/server/routes/panel-extensions.ts +++ b/packages/server/src/server/routes/panel-extensions.ts @@ -4,6 +4,7 @@ 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" +import { createPanelExtensionCatalog } from "../../panel-extensions/catalog" const Digest = z.string().regex(/^[a-f0-9]{64}$/) const Scope = z.object({ instanceId: z.string().min(1).max(256) }).strict() @@ -12,8 +13,10 @@ const Archive = z.object({ archiveBase64: z.string().min(1).max(Math.ceil(PANEL_ 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 + store: PanelExtensionStore; workspaceManager: Pick; catalog?: ReturnType }) { + const catalog = deps.catalog ?? createPanelExtensionCatalog() + const Selection = Id.extend({ digest: Digest }) const protect = (operation: (request: FastifyRequest) => Promise) => async (request: FastifyRequest, reply: FastifyReply) => { reply.header("Cache-Control", "no-store") try { return await operation(request) } @@ -31,6 +34,21 @@ export function registerPanelExtensionRoutes(app: FastifyInstance, deps: { return workspace.path } const options = { bodyLimit: Math.ceil(PANEL_EXTENSION_LIMITS.archiveBytes / 3) * 4 + 4096 } + app.get("/api/panel-extensions/catalog", protect(async request => { + const query = z.object({ refresh: z.literal("true").optional() }).strict().parse(request.query) + return catalog.list(query.refresh === "true") + })) + app.post("/api/panel-extensions/catalog/inspect", protect(async request => { + const { id, digest } = Selection.strict().parse(request.body) + const pkg = await catalog.inspect(id, digest) + return { manifest: pkg.manifest, digest: pkg.digest } + })) + app.post("/api/panel-extensions/catalog/install", protect(async request => { + const body = Selection.extend({ previousDigest: Digest.optional(), acknowledged: z.literal(true) }).strict().parse(request.body) + const pkg = await catalog.inspect(body.id, body.digest) + await deps.store.install(pkg, body.previousDigest) + return { installed: true } + })) 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")) diff --git a/packages/ui/src/components/panel-extensions/extension-catalog.tsx b/packages/ui/src/components/panel-extensions/extension-catalog.tsx new file mode 100644 index 000000000..b84928640 --- /dev/null +++ b/packages/ui/src/components/panel-extensions/extension-catalog.tsx @@ -0,0 +1,55 @@ +import { For, Show, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import type { PanelExtensionCatalogEntry, PanelExtensionSummary } from "../../../../server/src/api-types" +import { panelExtensionsApi } from "../../lib/panel-extensions-api" +import { useI18n } from "../../lib/i18n" + +export function ExtensionCatalog(props: { + installed: Accessor; busy: Accessor; verified: Accessor + inspect: (entry: PanelExtensionCatalogEntry) => void +}) { + const { t } = useI18n() + const [entries, setEntries] = createSignal([]) + const [loading, setLoading] = createSignal(false), [failed, setFailed] = createSignal(false), [query, setQuery] = createSignal("") + let generation = 0, controller: AbortController | undefined + const load = async (refresh = false) => { + controller?.abort(); controller = new AbortController() + const current = ++generation + setLoading(true); setFailed(false) + try { + const catalog = await panelExtensionsApi.catalog(refresh, controller.signal) + if (current === generation) setEntries(catalog.entries) + } catch { if (current === generation) { setEntries([]); setFailed(true) } } + finally { if (current === generation) setLoading(false) } + } + onMount(() => void load()) + onCleanup(() => { generation++; controller?.abort() }) + const filtered = createMemo(() => entries().filter(entry => + `${entry.manifest.name} ${entry.manifest.id} ${entry.description}`.toLocaleLowerCase().includes(query().trim().toLocaleLowerCase()))) + return
+
+

{t("panelExtensions.catalog.title")}

+ +
+ setQuery(event.currentTarget.value)} /> +

{t("panelExtensions.catalog.loading")}

+

{t("panelExtensions.catalog.error")}

+

{t("panelExtensions.catalog.empty")}

+ {entry => { + const installed = () => props.installed().find(value => value.manifest.id === entry.manifest.id) + const same = () => installed()?.digest === entry.digest + return
+ {entry.manifest.name} {entry.manifest.version} +

{entry.description}

+ {entry.manifest.author} +
+ +
+
+ }}
+
+} diff --git a/packages/ui/src/components/panel-extensions/extension-manager.tsx b/packages/ui/src/components/panel-extensions/extension-manager.tsx index c55265bef..1aa7bf921 100644 --- a/packages/ui/src/components/panel-extensions/extension-manager.tsx +++ b/packages/ui/src/components/panel-extensions/extension-manager.tsx @@ -1,14 +1,16 @@ import { For, Show, createEffect, createSignal, type Accessor } from "solid-js" -import type { PanelExtensionManifest } from "../../../../server/src/api-types" +import type { PanelExtensionManifest, PanelExtensionCatalogEntry } 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" +import { ExtensionCatalog } from "./extension-catalog" 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 [preview, setPreview] = createSignal<{ manifest: PanelExtensionManifest; digest: string; + source: { kind: "zip"; archive: string } | { kind: "catalog" }; previousDigest?: string }>() const [removal, setRemoval] = createSignal<{ id: string; digest: string }>() createEffect(() => { const pending = removal() @@ -34,7 +36,15 @@ export function ExtensionManager(props: { instanceId: Accessor; controll }) const result = await panelExtensionsApi.inspect(archive) const previousDigest = props.controller.entries().find(entry => entry.manifest.id === result.manifest.id)?.digest - setPreview({ ...result, archive, previousDigest }) + setPreview({ ...result, source: { kind: "zip", archive }, previousDigest }) + }) + } + const inspectCatalog = (entry: PanelExtensionCatalogEntry) => { + setPreview(undefined); setAcknowledged(false) + void run(async () => { + const previousDigest = props.controller.entries().find(value => value.manifest.id === entry.manifest.id)?.digest + const result = await panelExtensionsApi.inspectCatalog(entry.manifest.id, entry.digest) + setPreview({ ...result, source: { kind: "catalog" }, previousDigest }) }) } return
@@ -59,12 +69,16 @@ export function ExtensionManager(props: { instanceId: Accessor; controll
} + +

{t("panelExtensions.catalog.installedTitle")}

{entry =>
{entry.manifest.name} {entry.manifest.version}
diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts index 7ce262d80..679427330 100644 --- a/packages/ui/src/lib/i18n/messages/de/instance.ts +++ b/packages/ui/src/lib/i18n/messages/de/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "Online verfügbar", + "panelExtensions.catalog.search": "Erweiterungen suchen", + "panelExtensions.catalog.loading": "Offizieller Katalog wird geladen…", + "panelExtensions.catalog.error": "Online-Katalog nicht verfügbar. Installierte Erweiterungen und ZIP-Installation bleiben verfügbar.", + "panelExtensions.catalog.empty": "Keine passenden Erweiterungen.", + "panelExtensions.catalog.install": "Installieren…", + "panelExtensions.catalog.replace": "Version ändern…", + "panelExtensions.catalog.installed": "Installiert", + "panelExtensions.catalog.incompatible": "Inkompatibel (API {apiVersion})", + "panelExtensions.catalog.installedTitle": "Installierte Erweiterungen", "panelExtensions.title": "Panel-Erweiterungen", "panelExtensions.install": "Aus ZIP installieren…", "panelExtensions.error": "Erweiterung nicht verfügbar. Paket und API-Version prüfen und erneut versuchen.", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index 0d5701fa7..75b56947f 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "Available online", + "panelExtensions.catalog.search": "Search extensions", + "panelExtensions.catalog.loading": "Loading the official catalogue…", + "panelExtensions.catalog.error": "Online catalogue unavailable. Installed extensions and ZIP installation remain available.", + "panelExtensions.catalog.empty": "No matching extensions.", + "panelExtensions.catalog.install": "Install…", + "panelExtensions.catalog.replace": "Change version…", + "panelExtensions.catalog.installed": "Installed", + "panelExtensions.catalog.incompatible": "Incompatible (API {apiVersion})", + "panelExtensions.catalog.installedTitle": "Installed extensions", "panelExtensions.title": "Panel extensions", "panelExtensions.install": "Install from ZIP…", "panelExtensions.error": "Extension unavailable. Check its package/API version and try again.", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index 1011498de..cfb8b4e34 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "Disponibles en línea", + "panelExtensions.catalog.search": "Buscar extensiones", + "panelExtensions.catalog.loading": "Cargando el catálogo oficial…", + "panelExtensions.catalog.error": "Catálogo en línea no disponible. Las extensiones instaladas y la instalación por ZIP siguen disponibles.", + "panelExtensions.catalog.empty": "No hay extensiones coincidentes.", + "panelExtensions.catalog.install": "Instalar…", + "panelExtensions.catalog.replace": "Cambiar versión…", + "panelExtensions.catalog.installed": "Instalada", + "panelExtensions.catalog.incompatible": "Incompatible (API {apiVersion})", + "panelExtensions.catalog.installedTitle": "Extensiones instaladas", "panelExtensions.title": "Extensiones del panel", "panelExtensions.install": "Instalar desde ZIP…", "panelExtensions.error": "Extensión no disponible. Comprueba el paquete y la versión de API e inténtalo de nuevo.", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index 344cd2eba..74d19cf13 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "Disponibles en ligne", + "panelExtensions.catalog.search": "Rechercher des extensions", + "panelExtensions.catalog.loading": "Chargement du catalogue officiel…", + "panelExtensions.catalog.error": "Catalogue en ligne indisponible. Les extensions installées et l’installation par ZIP restent disponibles.", + "panelExtensions.catalog.empty": "Aucune extension correspondante.", + "panelExtensions.catalog.install": "Installer…", + "panelExtensions.catalog.replace": "Changer de version…", + "panelExtensions.catalog.installed": "Installée", + "panelExtensions.catalog.incompatible": "Incompatible (API {apiVersion})", + "panelExtensions.catalog.installedTitle": "Extensions installées", "panelExtensions.title": "Extensions du panneau", "panelExtensions.install": "Installer depuis un ZIP…", "panelExtensions.error": "Extension indisponible. Vérifiez son paquet et sa version d’API, puis réessayez.", diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index 0204234b0..ba00364ee 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "זמינות ברשת", + "panelExtensions.catalog.search": "חיפוש הרחבות", + "panelExtensions.catalog.loading": "טוען את הקטלוג הרשמי…", + "panelExtensions.catalog.error": "הקטלוג המקוון אינו זמין. ההרחבות המותקנות וההתקנה מקובץ ZIP עדיין זמינות.", + "panelExtensions.catalog.empty": "לא נמצאו הרחבות תואמות.", + "panelExtensions.catalog.install": "התקנה…", + "panelExtensions.catalog.replace": "שינוי גרסה…", + "panelExtensions.catalog.installed": "מותקנת", + "panelExtensions.catalog.incompatible": "לא תואמת (API {apiVersion})", + "panelExtensions.catalog.installedTitle": "הרחבות מותקנות", "panelExtensions.title": "הרחבות לוח", "panelExtensions.install": "התקנה מקובץ ZIP…", "panelExtensions.error": "ההרחבה אינה זמינה. יש לבדוק את החבילה ואת גרסת ה-API ולנסות שוב.", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index 92e272336..ee5064ac1 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "オンラインで利用可能", + "panelExtensions.catalog.search": "拡張機能を検索", + "panelExtensions.catalog.loading": "公式カタログを読み込み中…", + "panelExtensions.catalog.error": "オンラインカタログを利用できません。インストール済みの拡張機能と ZIP からのインストールは引き続き利用できます。", + "panelExtensions.catalog.empty": "一致する拡張機能はありません。", + "panelExtensions.catalog.install": "インストール…", + "panelExtensions.catalog.replace": "バージョンを変更…", + "panelExtensions.catalog.installed": "インストール済み", + "panelExtensions.catalog.incompatible": "非互換(API {apiVersion})", + "panelExtensions.catalog.installedTitle": "インストール済みの拡張機能", "panelExtensions.title": "パネル拡張機能", "panelExtensions.install": "ZIP からインストール…", "panelExtensions.error": "拡張機能を利用できません。パッケージと API バージョンを確認して再試行してください。", diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts index 23661400e..f18762f66 100644 --- a/packages/ui/src/lib/i18n/messages/ne/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "अनलाइन उपलब्ध", + "panelExtensions.catalog.search": "विस्तार खोज्नुहोस्", + "panelExtensions.catalog.loading": "आधिकारिक सूची लोड हुँदैछ…", + "panelExtensions.catalog.error": "अनलाइन सूची उपलब्ध छैन। स्थापित विस्तार र ZIP बाट स्थापना अझै उपलब्ध छन्।", + "panelExtensions.catalog.empty": "मिल्ने विस्तार भेटिएन।", + "panelExtensions.catalog.install": "स्थापना गर्नुहोस्…", + "panelExtensions.catalog.replace": "संस्करण बदल्नुहोस्…", + "panelExtensions.catalog.installed": "स्थापित", + "panelExtensions.catalog.incompatible": "असंगत (API {apiVersion})", + "panelExtensions.catalog.installedTitle": "स्थापित विस्तारहरू", "panelExtensions.title": "प्यानल विस्तारहरू", "panelExtensions.install": "ZIP बाट स्थापना गर्नुहोस्…", "panelExtensions.error": "विस्तार उपलब्ध छैन। प्याकेज र API संस्करण जाँचेर फेरि प्रयास गर्नुहोस्।", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index bbee35f8d..1b8d77ddc 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "Доступны онлайн", + "panelExtensions.catalog.search": "Поиск расширений", + "panelExtensions.catalog.loading": "Загрузка официального каталога…", + "panelExtensions.catalog.error": "Онлайн-каталог недоступен. Установленные расширения и установка из ZIP по-прежнему доступны.", + "panelExtensions.catalog.empty": "Подходящие расширения не найдены.", + "panelExtensions.catalog.install": "Установить…", + "panelExtensions.catalog.replace": "Сменить версию…", + "panelExtensions.catalog.installed": "Установлено", + "panelExtensions.catalog.incompatible": "Несовместимо (API {apiVersion})", + "panelExtensions.catalog.installedTitle": "Установленные расширения", "panelExtensions.title": "Расширения панели", "panelExtensions.install": "Установить из ZIP…", "panelExtensions.error": "Расширение недоступно. Проверьте пакет и версию API и повторите попытку.", diff --git a/packages/ui/src/lib/i18n/messages/tr/instance.ts b/packages/ui/src/lib/i18n/messages/tr/instance.ts index 9c0303af3..57e4daa62 100644 --- a/packages/ui/src/lib/i18n/messages/tr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/tr/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "Çevrimiçi kullanılabilir", + "panelExtensions.catalog.search": "Uzantı ara", + "panelExtensions.catalog.loading": "Resmî katalog yükleniyor…", + "panelExtensions.catalog.error": "Çevrimiçi katalog kullanılamıyor. Yüklü uzantılar ve ZIP kurulumu kullanılabilir durumda.", + "panelExtensions.catalog.empty": "Eşleşen uzantı yok.", + "panelExtensions.catalog.install": "Yükle…", + "panelExtensions.catalog.replace": "Sürümü değiştir…", + "panelExtensions.catalog.installed": "Yüklü", + "panelExtensions.catalog.incompatible": "Uyumsuz (API {apiVersion})", + "panelExtensions.catalog.installedTitle": "Yüklü uzantılar", "panelExtensions.title": "Panel uzantıları", "panelExtensions.install": "ZIP dosyasından yükle…", "panelExtensions.error": "Uzantı kullanılamıyor. Paketi ve API sürümünü kontrol edip tekrar deneyin.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index 55117deee..1592b825b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -1,4 +1,14 @@ export const instanceMessages = { + "panelExtensions.catalog.title": "在线可用", + "panelExtensions.catalog.search": "搜索扩展", + "panelExtensions.catalog.loading": "正在加载官方目录…", + "panelExtensions.catalog.error": "在线目录不可用。已安装的扩展和 ZIP 安装仍可使用。", + "panelExtensions.catalog.empty": "没有匹配的扩展。", + "panelExtensions.catalog.install": "安装…", + "panelExtensions.catalog.replace": "更换版本…", + "panelExtensions.catalog.installed": "已安装", + "panelExtensions.catalog.incompatible": "不兼容(API {apiVersion})", + "panelExtensions.catalog.installedTitle": "已安装的扩展", "panelExtensions.title": "面板扩展", "panelExtensions.install": "从 ZIP 安装…", "panelExtensions.error": "扩展不可用。请检查软件包和 API 版本后重试。", diff --git a/packages/ui/src/lib/panel-extensions-api.ts b/packages/ui/src/lib/panel-extensions-api.ts index dc0761797..51ffc7f4d 100644 --- a/packages/ui/src/lib/panel-extensions-api.ts +++ b/packages/ui/src/lib/panel-extensions-api.ts @@ -1,4 +1,4 @@ -import type { PanelExtensionManifest, PanelExtensionSummary } from "../../../server/src/api-types" +import type { PanelExtensionManifest, PanelExtensionSummary, PanelExtensionCatalog } from "../../../server/src/api-types" import { CODENOMAD_API_BASE } from "./api-base" import { authenticatedFetch } from "./auth-recovery" @@ -11,6 +11,13 @@ async function request(suffix: string, init?: RequestInit): Promise { } const query = (instanceId: string) => `?${new URLSearchParams({ instanceId })}` export const panelExtensionsApi = { + catalog: (refresh: boolean, signal: AbortSignal) => request(`/catalog${refresh ? "?refresh=true" : ""}`, { signal }), + inspectCatalog: (id: string, digest: string) => request<{ manifest: PanelExtensionManifest; digest: string }>("/catalog/inspect", { + method: "POST", body: JSON.stringify({ id, digest }), + }), + installCatalog: (id: string, digest: string, previousDigest?: string) => request("/catalog/install", { + method: "POST", body: JSON.stringify({ id, digest, previousDigest, acknowledged: true }), + }), list: (instanceId: string, signal?: AbortSignal) => request(query(instanceId), { signal }), inspect: (archiveBase64: string) => request<{ manifest: PanelExtensionManifest; digest: string }>("/inspect", { method: "POST", body: JSON.stringify({ archiveBase64 }), diff --git a/packages/ui/src/styles/panels/panel-extensions.css b/packages/ui/src/styles/panels/panel-extensions.css index 9efe69f0a..b15606238 100644 --- a/packages/ui/src/styles/panels/panel-extensions.css +++ b/packages/ui/src/styles/panels/panel-extensions.css @@ -54,3 +54,20 @@ align-items: center; gap: var(--space-xs); } + +.panel-extension-catalog { + margin-block: var(--space-md); +} + +.panel-extension-catalog h3 { + margin: 0; +} + +.panel-extension-search { + width: 100%; + margin-top: var(--space-sm); + padding: var(--space-xs); + background: var(--surface-base); + color: var(--text-primary); + border: 1px solid var(--border-base); +} diff --git a/packages/ui/tests/browser/fixtures/status-panel.tsx b/packages/ui/tests/browser/fixtures/status-panel.tsx index 8fa2b9faa..48266c033 100644 --- a/packages/ui/tests/browser/fixtures/status-panel.tsx +++ b/packages/ui/tests/browser/fixtures/status-panel.tsx @@ -3,6 +3,7 @@ import { createSignal } from "solid-js" import RightPanel from "../../../src/components/instance/shell/right-panel/RightPanel" import { ConfigProvider } from "../../../src/stores/preferences" import { I18nProvider, useI18n } from "../../../src/lib/i18n" +import { ThemeProvider } from "../../../src/lib/theme" import { setSessions } from "../../../src/stores/session-state" import { initializeClientState, readClientLayoutValue } from "../../../src/stores/client-state" import { RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY } from "../../../src/components/instance/shell/storage" @@ -31,7 +32,7 @@ function Fixture() { } await initializeClientState() -render(() => , document.getElementById("root")!) +render(() => , document.getElementById("root")!) ;(window as any).statusFixture = { customization: () => JSON.parse(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY) ?? "{}"), update: () => setActiveSession(current => ({ ...current, model: { ...current.model }, diff --git a/packages/ui/tests/browser/panel-extensions.test.ts b/packages/ui/tests/browser/panel-extensions.test.ts index 8f9520d6a..736d3067f 100644 --- a/packages/ui/tests/browser/panel-extensions.test.ts +++ b/packages/ui/tests/browser/panel-extensions.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import { createHash } from "node:crypto" import { after, before, test } from "node:test" import { mkdtemp, rm } from "node:fs/promises" import os from "node:os" @@ -12,16 +13,29 @@ import { PanelExtensionStore } from "../../../server/src/panel-extensions/store" import { readPanelExtensionArchive } from "../../../server/src/panel-extensions/archive" import { fixtureArchive, fixtureManifest } from "../../../server/src/panel-extensions/archive-fixture" import { registerPanelExtensionRoutes } from "../../../server/src/server/routes/panel-extensions" +import { createPanelExtensionCatalog, PANEL_EXTENSION_CATALOG_URL } from "../../../server/src/panel-extensions/catalog" const app = Fastify() let server: ViteDevServer, browser: Browser, url: string, root: string, store: PanelExtensionStore const example = `

` +const onlineArchive = fixtureArchive(example) +const onlineDigest = createHash("sha256").update(onlineArchive).digest("hex") +const onlineEntry = { manifest: fixtureManifest, description: "A catalogue example", digest: onlineDigest, release: { tag: "v1.0.0", asset: "example.session-1.0.0.zip" } } +let catalogEntries = [onlineEntry], catalogOffline = false, archiveRequests = 0 before(async () => { root = await mkdtemp(path.join(os.tmpdir(), "opencode-panel-browser-")) store = new PanelExtensionStore(root) - registerPanelExtensionRoutes(app, { store, workspaceManager: { get: id => ["first", "second"].includes(id) ? { id, path: `/${id}` } as any : undefined } }) + const catalog = createPanelExtensionCatalog((async (url: string | URL | Request) => { + if (String(url) === PANEL_EXTENSION_CATALOG_URL) { + if (catalogOffline) return new Response("Offline", { status: 503 }) + return new Response(JSON.stringify({ schemaVersion: 1, extensions: catalogEntries })) + } + archiveRequests++ + return new Response(new Uint8Array(onlineArchive).buffer) + }) as typeof fetch) + registerPanelExtensionRoutes(app, { store, catalog, workspaceManager: { get: id => ["first", "second"].includes(id) ? { id, path: `/${id}` } as any : undefined } }) const address = await app.listen({ host: "127.0.0.1", port: 0 }) server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", plugins: [solid(), { name: "extension-fixture", configureServer(vite) { @@ -61,6 +75,64 @@ async function installDirect(html: string) { return pkg } +test("online catalogue lists without downloading code; explicit consent installs an addon tab", async () => { + const view = await page(), before = archiveRequests + try { + await view.getByRole("button", { name: "Customize right panel" }).click() + const catalogue = view.getByRole("region", { name: "Available online" }) + await catalogue.getByText("A catalogue example", { exact: true }).waitFor() + assert.equal(archiveRequests, before) + await catalogue.getByRole("searchbox", { name: "Search extensions" }).fill("no-such-addon") + await catalogue.getByText("No matching extensions.").waitFor() + await catalogue.getByRole("searchbox", { name: "Search extensions" }).fill("session") + await catalogue.getByRole("button", { name: "Install…", exact: true }).click() + await view.getByRole("checkbox", { name: "I trust this package and its author" }).waitFor() + assert.deepEqual(await store.list("/first"), []) + assert.equal(archiveRequests, before + 1) + await view.getByRole("checkbox", { name: "I trust this package and its author" }).check() + await view.getByRole("button", { name: "Install disabled", exact: true }).click() + await catalogue.getByRole("button", { name: "Installed", exact: true }).waitFor() + assert.equal(await catalogue.getByRole("button", { name: "Installed", exact: true }).isDisabled(), true) + await view.getByRole("checkbox", { name: "This folder", exact: true }).check() + await view.getByRole("tab", { name: "Session example", exact: true }).click() + await view.frameLocator('iframe[title="Session example"]').locator("#context").filter({ hasText: "session-a" }).waitFor() + assert.equal(archiveRequests, before + 2, "Install re-reads the approved package") + await store.remove(fixtureManifest.id, onlineDigest) + } finally { await view.close() } +}) + +test("withdrawn online selection cannot install after its trust preview was approved", async () => { + const view = await page() + try { + await view.getByRole("button", { name: "Customize right panel" }).click() + await view.getByRole("region", { name: "Available online" }).getByRole("button", { name: "Install…", exact: true }).click() + await view.getByRole("checkbox", { name: "I trust this package and its author" }).check() + catalogEntries = [] + await view.getByRole("button", { name: "Install disabled", exact: true }).click() + await view.getByRole("alert").filter({ hasText: "Extension unavailable" }).waitFor() + assert.deepEqual(await store.list("/first"), []) + } finally { catalogEntries = [onlineEntry]; await view.close() } +}) + +test("incompatible catalogue entries stay unselectable and offline discovery does not block ZIP installation", async () => { + catalogEntries = [onlineEntry, { ...onlineEntry, manifest: { ...fixtureManifest, id: "future.example", name: "Future panel", apiVersion: 99 } }] + const view = await page(), before = archiveRequests + try { + await view.getByRole("button", { name: "Customize right panel" }).click() + const catalogue = view.getByRole("region", { name: "Available online" }) + await catalogue.getByRole("button", { name: "Refresh", exact: true }).click() + await catalogue.getByRole("button", { name: "Incompatible (API 99)", exact: true }).waitFor() + assert.equal(await catalogue.getByRole("button", { name: "Incompatible (API 99)", exact: true }).isDisabled(), true) + assert.equal(archiveRequests, before) + catalogOffline = true + await catalogue.getByRole("button", { name: "Refresh", exact: true }).click() + await catalogue.getByRole("alert").filter({ hasText: "Online catalogue unavailable" }).waitFor() + assert.equal(await view.getByRole("button", { name: "Install from ZIP…" }).isDisabled(), false) + await view.locator('input[type="file"]').setInputFiles({ name: "local.zip", mimeType: "application/zip", buffer: fixtureArchive(example) }) + await view.getByRole("checkbox", { name: "I trust this package and its author" }).waitFor() + } finally { catalogOffline = false; catalogEntries = [onlineEntry]; await view.close() } +}) + test("manual ZIP consent, scoped tab, context switch, revoke, replacement and removal use real routes", async () => { const view = await page() try {