()
+ 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)
+ }} />
+ picker.click()}>{t("panelExtensions.install")}
+
+ {t("panelExtensions.error")}
+ void run(() => props.controller.refresh())}>
+ {t("instanceShell.rightPanel.actions.refresh")}
+
+
+ {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")}
+
setAcknowledged(event.currentTarget.checked)} />{t("panelExtensions.trust")}
+
+ void run(async () => {
+ await panelExtensionsApi.install(pkg().archive, pkg().digest, pkg().previousDigest)
+ setPreview(undefined)
+ })}>{t("panelExtensions.confirm")}
+ setPreview(undefined)}>{t("panelExtensions.cancel")}
+
+
}
+ {entry =>
+
{entry.manifest.name} {entry.manifest.version}
+
+ {
+ const enabled = event.currentTarget.checked
+ void run(() => panelExtensionsApi.activate(props.instanceId(), entry.manifest.id, entry.digest, "global", enabled))
+ }} />{t("panelExtensions.global")}
+ {
+ const enabled = event.currentTarget.checked
+ void run(() => panelExtensionsApi.activate(props.instanceId(), entry.manifest.id, entry.digest, "project", enabled))
+ }} />{t("panelExtensions.project")}
+ setRemoval({ id: entry.manifest.id, digest: entry.digest })}>{t("panelExtensions.remove")}
+
+
+ {t("panelExtensions.removeWarning")}
+ void run(async () => {
+ const pending = removal()
+ if (!pending) return
+ await panelExtensionsApi.remove(pending.id, pending.digest); setRemoval(undefined)
+ })}>{t("panelExtensions.removeConfirm")}
+ setRemoval(undefined)}>{t("panelExtensions.cancel")}
+
+
}
+
+}
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 => }
+
+}
diff --git a/packages/ui/src/components/panel-extensions/frame-document.ts b/packages/ui/src/components/panel-extensions/frame-document.ts
new file mode 100644
index 000000000..5110eb1eb
--- /dev/null
+++ b/packages/ui/src/components/panel-extensions/frame-document.ts
@@ -0,0 +1,29 @@
+// Put policy and bridge before all author bytes. Additional CSPs can only restrict it.
+export const PANEL_EXTENSION_SANDBOX = "allow-scripts"
+export const PANEL_EXTENSION_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'"
+
+const bridge = (handshake: string) => `(() => {
+ let context = null;
+ const listeners = new Set();
+ Object.defineProperty(window, "codenomad", { value: Object.freeze({
+ apiVersion: 1,
+ getContext: () => context,
+ onContext: listener => { listeners.add(listener); if (context) listener(context); return () => listeners.delete(listener); }
+ }) });
+ const initialize = event => {
+ if (event.source !== parent || event.data?.type !== "codenomad:init" || event.ports.length !== 1) return;
+ window.removeEventListener("message", initialize);
+ const port = event.ports[0];
+ port.onmessage = event => {
+ if (event.data?.type !== "context") return;
+ context = Object.freeze(event.data.context);
+ for (const listener of listeners) { try { listener(context); } catch {} }
+ };
+ port.postMessage({ type: "ready", handshake: ${JSON.stringify(handshake)} });
+ };
+ window.addEventListener("message", initialize);
+})();`
+
+export function panelExtensionDocument(html: string, handshake: string): string {
+ return ` ${html}`
+}
diff --git a/packages/ui/src/components/panel-extensions/use-panel-extensions.ts b/packages/ui/src/components/panel-extensions/use-panel-extensions.ts
new file mode 100644
index 000000000..9e64fa8b0
--- /dev/null
+++ b/packages/ui/src/components/panel-extensions/use-panel-extensions.ts
@@ -0,0 +1,34 @@
+import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
+import type { PanelExtensionSummary } from "../../../../server/src/api-types"
+import { panelExtensionsApi } from "../../lib/panel-extensions-api"
+import { serverEvents } from "../../lib/server-events"
+
+export function usePanelExtensions(instanceId: Accessor, active: Accessor) {
+ const [entries, setEntries] = createSignal([])
+ const [failed, setFailed] = createSignal(false)
+ const [verified, setVerified] = createSignal(false)
+ let generation = 0, controller: AbortController | undefined
+ let connected = true
+ const clear = () => { generation++; controller?.abort(); setVerified(false) }
+ const refresh = async () => {
+ clear()
+ if (!active() || !connected) return
+ const current = generation, id = instanceId()
+ controller = new AbortController()
+ try {
+ const entries = await panelExtensionsApi.list(id, controller.signal)
+ if (current === generation && id === instanceId() && active()) { setEntries(entries); setFailed(false); setVerified(true) }
+ } catch { if (current === generation && active()) { setEntries([]); setFailed(true) } }
+ }
+ createEffect(() => { instanceId(); active(); setEntries([]); void refresh() })
+ onCleanup(serverEvents.on("storage.stateChanged", event => {
+ if ("owner" in event && event.owner === "panelExtensions") void refresh()
+ }))
+ onCleanup(serverEvents.onTransportStatus(status => {
+ connected = status === "connected"
+ if (!connected) clear(); else void refresh()
+ }))
+ onCleanup(clear)
+ return { entries, failed, verified, refresh }
+}
+export type PanelExtensionsController = ReturnType
diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts
index 24dcd26d0..7ce262d80 100644
--- a/packages/ui/src/lib/i18n/messages/de/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/de/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "panelExtensions.title": "Panel-Erweiterungen",
+ "panelExtensions.install": "Aus ZIP installieren…",
+ "panelExtensions.error": "Erweiterung nicht verfügbar. Paket und API-Version prüfen und erneut versuchen.",
+ "panelExtensions.permission": "Berechtigung: nur aktuelle Sitzungskennung, Sprache und Darstellung.",
+ "panelExtensions.warning": "Nur vertrauenswürdige Autoren installieren. Neuer oder ersetzter Code ist zunächst deaktiviert. Ersetzen widerruft die Aktivierung in allen Projekten.",
+ "panelExtensions.trust": "Ich vertraue diesem Paket und seinem Autor",
+ "panelExtensions.confirm": "Deaktiviert installieren",
+ "panelExtensions.cancel": "Abbrechen",
+ "panelExtensions.global": "Alle Projekte",
+ "panelExtensions.project": "Dieser Ordner",
+ "panelExtensions.remove": "Entfernen…",
+ "panelExtensions.removeWarning": "Diese Erweiterung und ihre Aktivierungseinstellungen aus diesem CodeNomad-Profil entfernen?",
+ "panelExtensions.removeConfirm": "Entfernen bestätigen",
"instanceTabs.new.title": "Neue Instanz (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "Neue Instanz",
"instanceTabs.remote.title": "Remote-Verbindung",
diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts
index 1868cac2e..0d5701fa7 100644
--- a/packages/ui/src/lib/i18n/messages/en/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/en/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "panelExtensions.title": "Panel extensions",
+ "panelExtensions.install": "Install from ZIP…",
+ "panelExtensions.error": "Extension unavailable. Check its package/API version and try again.",
+ "panelExtensions.permission": "Permission: current session identifier, language and appearance only.",
+ "panelExtensions.warning": "Only install authors you trust. New or replacement code starts disabled. Replacing an extension revokes its activation in every project.",
+ "panelExtensions.trust": "I trust this package and its author",
+ "panelExtensions.confirm": "Install disabled",
+ "panelExtensions.cancel": "Cancel",
+ "panelExtensions.global": "All projects",
+ "panelExtensions.project": "This folder",
+ "panelExtensions.remove": "Remove…",
+ "panelExtensions.removeWarning": "Remove this extension and its activation settings from this CodeNomad profile?",
+ "panelExtensions.removeConfirm": "Confirm removal",
"instanceTabs.new.title": "New instance (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "New instance",
"instanceTabs.remote.title": "Remote connect",
diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts
index ca5d71771..1011498de 100644
--- a/packages/ui/src/lib/i18n/messages/es/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/es/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "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.",
+ "panelExtensions.permission": "Permiso: solo identificador de sesión actual, idioma y apariencia.",
+ "panelExtensions.warning": "Instala solo autores de confianza. El código nuevo o reemplazado empieza desactivado. Reemplazar revoca la activación en todos los proyectos.",
+ "panelExtensions.trust": "Confío en este paquete y en su autor",
+ "panelExtensions.confirm": "Instalar desactivada",
+ "panelExtensions.cancel": "Cancelar",
+ "panelExtensions.global": "Todos los proyectos",
+ "panelExtensions.project": "Esta carpeta",
+ "panelExtensions.remove": "Eliminar…",
+ "panelExtensions.removeWarning": "¿Eliminar esta extensión y sus ajustes de activación de este perfil de CodeNomad?",
+ "panelExtensions.removeConfirm": "Confirmar eliminación",
"instanceTabs.new.title": "Nueva instancia (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "Nueva instancia",
"instanceTabs.remote.title": "Conexión remota",
diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts
index a7e2ca79e..344cd2eba 100644
--- a/packages/ui/src/lib/i18n/messages/fr/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "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.",
+ "panelExtensions.permission": "Permission : identifiant de la session actuelle, langue et apparence uniquement.",
+ "panelExtensions.warning": "Installez uniquement des auteurs de confiance. Tout nouveau code est désactivé. Remplacer une extension révoque son activation dans tous les projets.",
+ "panelExtensions.trust": "Je fais confiance à ce paquet et à son auteur",
+ "panelExtensions.confirm": "Installer sans activer",
+ "panelExtensions.cancel": "Annuler",
+ "panelExtensions.global": "Tous les projets",
+ "panelExtensions.project": "Ce dossier",
+ "panelExtensions.remove": "Supprimer…",
+ "panelExtensions.removeWarning": "Supprimer cette extension et ses réglages d’activation de ce profil CodeNomad ?",
+ "panelExtensions.removeConfirm": "Confirmer la suppression",
"instanceTabs.new.title": "Nouvelle instance (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "Nouvelle instance",
"instanceTabs.remote.title": "Connexion à distance",
diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts
index 9d7a21940..0204234b0 100644
--- a/packages/ui/src/lib/i18n/messages/he/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/he/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "panelExtensions.title": "הרחבות לוח",
+ "panelExtensions.install": "התקנה מקובץ ZIP…",
+ "panelExtensions.error": "ההרחבה אינה זמינה. יש לבדוק את החבילה ואת גרסת ה-API ולנסות שוב.",
+ "panelExtensions.permission": "הרשאה: מזהה השיחה הנוכחית, שפה ומראה בלבד.",
+ "panelExtensions.warning": "יש להתקין רק מחברים מהימנים. קוד חדש או מוחלף מתחיל מושבת. החלפה מבטלת את ההפעלה בכל הפרויקטים.",
+ "panelExtensions.trust": "אני סומך על החבילה ועל המחבר שלה",
+ "panelExtensions.confirm": "התקנה ללא הפעלה",
+ "panelExtensions.cancel": "ביטול",
+ "panelExtensions.global": "כל הפרויקטים",
+ "panelExtensions.project": "תיקייה זו",
+ "panelExtensions.remove": "הסרה…",
+ "panelExtensions.removeWarning": "להסיר את ההרחבה ואת הגדרות ההפעלה מפרופיל CodeNomad זה?",
+ "panelExtensions.removeConfirm": "אישור הסרה",
"instanceTabs.new.title": "מופע חדש (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "מופע חדש",
"instanceTabs.remote.title": "חיבור מרוחק",
diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts
index ada169906..92e272336 100644
--- a/packages/ui/src/lib/i18n/messages/ja/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "panelExtensions.title": "パネル拡張機能",
+ "panelExtensions.install": "ZIP からインストール…",
+ "panelExtensions.error": "拡張機能を利用できません。パッケージと API バージョンを確認して再試行してください。",
+ "panelExtensions.permission": "権限:現在のセッション ID、言語、外観のみ。",
+ "panelExtensions.warning": "信頼できる作者のみインストールしてください。新規・置換コードは無効で開始します。置換すると全プロジェクトの有効化が解除されます。",
+ "panelExtensions.trust": "このパッケージと作者を信頼します",
+ "panelExtensions.confirm": "無効のままインストール",
+ "panelExtensions.cancel": "キャンセル",
+ "panelExtensions.global": "すべてのプロジェクト",
+ "panelExtensions.project": "このフォルダー",
+ "panelExtensions.remove": "削除…",
+ "panelExtensions.removeWarning": "この CodeNomad プロファイルから拡張機能と有効化設定を削除しますか?",
+ "panelExtensions.removeConfirm": "削除を確認",
"instanceTabs.new.title": "新しいインスタンス (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "新しいインスタンス",
"instanceTabs.remote.title": "リモート接続",
diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts
index bf5351d72..23661400e 100644
--- a/packages/ui/src/lib/i18n/messages/ne/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "panelExtensions.title": "प्यानल विस्तारहरू",
+ "panelExtensions.install": "ZIP बाट स्थापना गर्नुहोस्…",
+ "panelExtensions.error": "विस्तार उपलब्ध छैन। प्याकेज र API संस्करण जाँचेर फेरि प्रयास गर्नुहोस्।",
+ "panelExtensions.permission": "अनुमति: हालको सत्रको पहिचान, भाषा र रूप मात्र।",
+ "panelExtensions.warning": "विश्वसनीय लेखकका विस्तार मात्र स्थापना गर्नुहोस्। नयाँ वा प्रतिस्थापित कोड निष्क्रिय रहन्छ। प्रतिस्थापनले सबै परियोजनामा सक्रियता रद्द गर्छ।",
+ "panelExtensions.trust": "म यो प्याकेज र यसको लेखकलाई विश्वास गर्छु",
+ "panelExtensions.confirm": "निष्क्रिय रूपमा स्थापना गर्नुहोस्",
+ "panelExtensions.cancel": "रद्द गर्नुहोस्",
+ "panelExtensions.global": "सबै परियोजनाहरू",
+ "panelExtensions.project": "यो फोल्डर",
+ "panelExtensions.remove": "हटाउनुहोस्…",
+ "panelExtensions.removeWarning": "यो CodeNomad प्रोफाइलबाट विस्तार र यसको सक्रियता सेटिङ हटाउने?",
+ "panelExtensions.removeConfirm": "हटाउने पुष्टि गर्नुहोस्",
"instanceTabs.new.title": "नयाँ उदाहरण (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "नयाँ उदाहरण",
"instanceTabs.remote.title": "रिमोट जडान",
diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts
index 91f3ba290..bbee35f8d 100644
--- a/packages/ui/src/lib/i18n/messages/ru/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "panelExtensions.title": "Расширения панели",
+ "panelExtensions.install": "Установить из ZIP…",
+ "panelExtensions.error": "Расширение недоступно. Проверьте пакет и версию API и повторите попытку.",
+ "panelExtensions.permission": "Разрешение: только идентификатор текущего сеанса, язык и оформление.",
+ "panelExtensions.warning": "Устанавливайте только доверенных авторов. Новый или заменённый код отключён. Замена отменяет активацию во всех проектах.",
+ "panelExtensions.trust": "Я доверяю этому пакету и его автору",
+ "panelExtensions.confirm": "Установить без активации",
+ "panelExtensions.cancel": "Отмена",
+ "panelExtensions.global": "Все проекты",
+ "panelExtensions.project": "Эта папка",
+ "panelExtensions.remove": "Удалить…",
+ "panelExtensions.removeWarning": "Удалить расширение и настройки активации из этого профиля CodeNomad?",
+ "panelExtensions.removeConfirm": "Подтвердить удаление",
"instanceTabs.new.title": "Новый экземпляр (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "Новый экземпляр",
"instanceTabs.remote.title": "Удаленное подключение",
diff --git a/packages/ui/src/lib/i18n/messages/tr/instance.ts b/packages/ui/src/lib/i18n/messages/tr/instance.ts
index 3615c5d62..9c0303af3 100644
--- a/packages/ui/src/lib/i18n/messages/tr/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/tr/instance.ts
@@ -1,4 +1,17 @@
export const instanceMessages = {
+ "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.",
+ "panelExtensions.permission": "İzin: yalnızca geçerli oturum kimliği, dil ve görünüm.",
+ "panelExtensions.warning": "Yalnızca güvendiğiniz yazarları yükleyin. Yeni veya değiştirilen kod devre dışı başlar. Değiştirme, tüm projelerde etkinleştirmeyi iptal eder.",
+ "panelExtensions.trust": "Bu pakete ve yazarına güveniyorum",
+ "panelExtensions.confirm": "Devre dışı yükle",
+ "panelExtensions.cancel": "İptal",
+ "panelExtensions.global": "Tüm projeler",
+ "panelExtensions.project": "Bu klasör",
+ "panelExtensions.remove": "Kaldır…",
+ "panelExtensions.removeWarning": "Bu uzantı ve etkinleştirme ayarları bu CodeNomad profilinden kaldırılsın mı?",
+ "panelExtensions.removeConfirm": "Kaldırmayı onayla",
"instanceTabs.new.title": "Yeni instance (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "Yeni instance",
"instanceTabs.remote.title": "Uzak bağlantı",
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 b1cbace50..55117deee 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,17 @@
export const instanceMessages = {
+ "panelExtensions.title": "面板扩展",
+ "panelExtensions.install": "从 ZIP 安装…",
+ "panelExtensions.error": "扩展不可用。请检查软件包和 API 版本后重试。",
+ "panelExtensions.permission": "权限:仅当前会话标识符、语言和外观。",
+ "panelExtensions.warning": "仅安装可信作者的扩展。新建或替换的代码默认禁用。替换扩展会撤销所有项目中的启用设置。",
+ "panelExtensions.trust": "我信任此软件包及其作者",
+ "panelExtensions.confirm": "安装但不启用",
+ "panelExtensions.cancel": "取消",
+ "panelExtensions.global": "所有项目",
+ "panelExtensions.project": "此文件夹",
+ "panelExtensions.remove": "移除…",
+ "panelExtensions.removeWarning": "从此 CodeNomad 配置中移除扩展及其启用设置?",
+ "panelExtensions.removeConfirm": "确认移除",
"instanceTabs.new.title": "新建实例 (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "新建实例",
"instanceTabs.remote.title": "远程连接",
diff --git a/packages/ui/src/lib/panel-extensions-api.ts b/packages/ui/src/lib/panel-extensions-api.ts
new file mode 100644
index 000000000..dc0761797
--- /dev/null
+++ b/packages/ui/src/lib/panel-extensions-api.ts
@@ -0,0 +1,26 @@
+import type { PanelExtensionManifest, PanelExtensionSummary } from "../../../server/src/api-types"
+import { CODENOMAD_API_BASE } from "./api-base"
+import { authenticatedFetch } from "./auth-recovery"
+
+async function request(suffix: string, init?: RequestInit): Promise {
+ const response = await authenticatedFetch(`${CODENOMAD_API_BASE ?? ""}/api/panel-extensions${suffix}`, {
+ ...init, headers: { "Content-Type": "application/json" }, credentials: "include",
+ })
+ if (!response.ok) throw new Error("Panel extension request failed")
+ return response.json()
+}
+const query = (instanceId: string) => `?${new URLSearchParams({ instanceId })}`
+export const panelExtensionsApi = {
+ list: (instanceId: string, signal?: AbortSignal) => request(query(instanceId), { signal }),
+ inspect: (archiveBase64: string) => request<{ manifest: PanelExtensionManifest; digest: string }>("/inspect", {
+ method: "POST", body: JSON.stringify({ archiveBase64 }),
+ }),
+ install: (archiveBase64: string, digest: string, previousDigest?: string) => request("", {
+ method: "POST", body: JSON.stringify({ archiveBase64, digest, previousDigest, acknowledged: true }),
+ }),
+ activate: (instanceId: string, id: string, digest: string, scope: "global" | "project", enabled: boolean) =>
+ request(`/${encodeURIComponent(id)}${query(instanceId)}`, { method: "PATCH", body: JSON.stringify({ digest, scope, enabled }) }),
+ remove: (id: string, digest: string) => request(`/${encodeURIComponent(id)}`, { method: "DELETE", body: JSON.stringify({ digest }) }),
+ panel: (instanceId: string, id: string, digest: string, signal: AbortSignal) =>
+ request<{ html: string }>(`/${encodeURIComponent(id)}/panel?${new URLSearchParams({ instanceId, digest })}`, { signal }),
+}
diff --git a/packages/ui/src/styles/panels.css b/packages/ui/src/styles/panels.css
index c8ad14b4d..3acdfe87a 100644
--- a/packages/ui/src/styles/panels.css
+++ b/packages/ui/src/styles/panels.css
@@ -9,6 +9,7 @@
@import "./components/session-tree.css";
@import "./components/transcript-filters.css";
@import "./panels/right-panel.css";
+@import "./panels/panel-extensions.css";
@import "./panels/control-hover.css";
@import "./panels/git-history.css";
@import "./panels/workspace-tree.css";
diff --git a/packages/ui/src/styles/panels/panel-extensions.css b/packages/ui/src/styles/panels/panel-extensions.css
new file mode 100644
index 000000000..9efe69f0a
--- /dev/null
+++ b/packages/ui/src/styles/panels/panel-extensions.css
@@ -0,0 +1,56 @@
+.panel-extension-surface {
+ height: 100%;
+ min-height: 12rem;
+}
+
+.panel-extension-surface iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ min-height: 12rem;
+ border: 0;
+ background: var(--surface-base);
+}
+
+.panel-extension-manager {
+ margin-top: var(--space-md);
+ padding-top: var(--space-sm);
+ border-top: 1px solid var(--border-base);
+}
+
+.right-panel-customization-popover:has(.panel-extension-manager) {
+ max-height: min(40rem, calc(100dvh - 8rem));
+ overflow-y: auto;
+}
+
+.panel-extension-manager h3 {
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-sm);
+}
+
+.panel-extension-row,
+.panel-extension-review {
+ margin-top: var(--space-sm);
+ padding: var(--space-sm);
+ background: var(--surface-secondary);
+ overflow-wrap: anywhere;
+}
+
+.panel-extension-review code {
+ display: block;
+ font-size: var(--font-size-xs);
+}
+
+.panel-extension-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-sm);
+ margin-top: var(--space-sm);
+}
+
+.panel-extension-manager label {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+}
diff --git a/packages/ui/tests/browser/fixtures/panel-extensions.tsx b/packages/ui/tests/browser/fixtures/panel-extensions.tsx
new file mode 100644
index 000000000..09fabed75
--- /dev/null
+++ b/packages/ui/tests/browser/fixtures/panel-extensions.tsx
@@ -0,0 +1,25 @@
+import { createSignal } from "solid-js"
+import { render } from "solid-js/web"
+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 { serverEvents } from "../../../src/lib/server-events"
+import "../../../src/index.css"
+
+function Fixture() {
+ const { t } = useI18n()
+ const [instanceId, setInstanceId] = createSignal("first"), [sessionId, setSessionId] = createSignal("session-a")
+ const [active, setActive] = createSignal(true)
+ ;(window as any).extensionFixture = { instance: setInstanceId, session: setSessionId, active: setActive,
+ changed: () => (serverEvents as any).dispatchBatch([{ type: "storage.stateChanged", owner: "panelExtensions", value: {} }]),
+ transport: (value: string) => (serverEvents as any).emitTransportStatus(value),
+ }
+ return
+ null} isPhoneLayout={() => false}
+ rightDrawerWidth={() => 550} rightDrawerWidthInitialized={() => true} onCloseRightDrawer={() => {}}
+ promptInputApi={() => null} setContentEl={() => {}} />
+
+}
+render(() => , document.getElementById("root")!)
diff --git a/packages/ui/tests/browser/fixtures/tab-chrome.tsx b/packages/ui/tests/browser/fixtures/tab-chrome.tsx
index 50fb9bd9a..17e5124f3 100644
--- a/packages/ui/tests/browser/fixtures/tab-chrome.tsx
+++ b/packages/ui/tests/browser/fixtures/tab-chrome.tsx
@@ -4,6 +4,7 @@ import InstanceTabs from "../../../src/components/instance-tabs"
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 { applyColorScheme, normalizeColorScheme } from "../../../src/lib/theme-scheme"
import type { AppTabRecord } from "../../../src/stores/app-tabs"
import type { Instance } from "../../../src/types/instance"
@@ -52,4 +53,4 @@ function Fixture() {
>
}
-render(() => , document.getElementById("root")!)
+render(() => , document.getElementById("root")!)
diff --git a/packages/ui/tests/browser/panel-extensions.test.ts b/packages/ui/tests/browser/panel-extensions.test.ts
new file mode 100644
index 000000000..8f9520d6a
--- /dev/null
+++ b/packages/ui/tests/browser/panel-extensions.test.ts
@@ -0,0 +1,168 @@
+import assert from "node:assert/strict"
+import { after, before, test } from "node:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+import { chromium, type Browser, type Page } from "playwright"
+import { createServer, type ViteDevServer } from "vite"
+import solid from "vite-plugin-solid"
+import Fastify from "fastify"
+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"
+
+const app = Fastify()
+let server: ViteDevServer, browser: Browser, url: string, root: string, store: PanelExtensionStore
+const example = `
`
+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 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) {
+ vite.middlewares.use("/fixture", async (_req, res) => {
+ res.setHeader("Content-Type", "text/html")
+ res.end(await vite.transformIndexHtml("/fixture", '
'))
+ })
+ } }], resolve: { dedupe: ["solid-js"] },
+ server: { host: "127.0.0.1", port: 0, hmr: false, watch: null, proxy: { "/api/panel-extensions": address } },
+ })
+ await server.listen()
+ url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
+ browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
+})
+after(async () => { await browser?.close(); await server?.close(); await app.close(); await rm(root, { recursive: true, force: true }) })
+
+async function page() {
+ const page = await browser.newPage({ locale: "en-US" })
+ await page.addInitScript(() => {
+ ;(window as any).EventSource = class { static OPEN = 1; readyState = 1; addEventListener() {} removeEventListener() {} close() {} }
+ })
+ await page.route("**/api/**", route => {
+ const pathname = new URL(route.request().url()).pathname
+ if (pathname.startsWith("/api/panel-extensions")) return route.continue()
+ if (pathname.includes("/settings/")) return route.fulfill({ json: { locale: "en" } })
+ return route.fulfill({ json: { entries: [], files: [], records: [], messages: [], data: [], outline: [] } })
+ })
+ await page.goto(url)
+ await page.waitForFunction(() => Boolean((window as any).extensionFixture))
+ return page
+}
+async function changed(page: Page) { await page.evaluate(() => (window as any).extensionFixture.changed()) }
+async function installDirect(html: string) {
+ const pkg = await readPanelExtensionArchive(fixtureArchive(html))
+ await store.install(pkg)
+ await store.activate(pkg.manifest.id, pkg.digest, "/first", "project", true)
+ return pkg
+}
+
+test("manual ZIP consent, scoped tab, context switch, revoke, replacement and removal use real routes", async () => {
+ const view = await page()
+ try {
+ await view.getByRole("button", { name: "Customize right panel" }).click()
+ await view.getByRole("button", { name: "Install from ZIP…" }).waitFor()
+ await view.locator('input[type="file"]').setInputFiles({ name: "example.zip", mimeType: "application/zip", buffer: fixtureArchive(example) })
+ await view.getByText("I trust this package and its author", { exact: true }).waitFor()
+ assert.equal(await view.getByRole("button", { name: "Install disabled" }).isDisabled(), true)
+ await view.getByRole("checkbox", { name: "I trust this package and its author" }).check()
+ await view.getByRole("button", { name: "Install disabled" }).click()
+ await view.getByRole("checkbox", { name: "This folder", exact: true }).waitFor()
+ assert.equal(await view.getByRole("tab", { name: "Session example", exact: true }).count(), 0)
+ 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()
+ await view.evaluate(() => (window as any).extensionFixture.session("session-b"))
+ await view.frameLocator('iframe[title="Session example"]').locator("#context").filter({ hasText: "session-b" }).waitFor()
+ await view.screenshot({ path: path.join(root, "extension-panel.png") })
+ await view.evaluate(() => (window as any).extensionFixture.instance("second"))
+ await view.getByRole("tab", { name: "Session example", exact: true }).waitFor({ state: "detached" })
+ assert.equal(await view.locator("iframe").count(), 0)
+ await view.evaluate(() => (window as any).extensionFixture.instance("first"))
+ await view.getByRole("tab", { name: "Session example", exact: true }).click()
+ await view.locator('iframe[title="Session example"]').waitFor()
+ const entry = (await store.list("/first"))[0]
+ await store.activate(entry.manifest.id, entry.digest, "/first", "project", false); await changed(view)
+ await view.locator('iframe[title="Session example"]').waitFor({ state: "detached" })
+ await store.activate(entry.manifest.id, entry.digest, "/first", "global", true); await changed(view)
+ await view.getByRole("tab", { name: "Session example", exact: true }).click()
+ await view.locator('iframe[title="Session example"]').waitFor()
+ const replacement = await readPanelExtensionArchive(fixtureArchive(example, { version: "1.1.0" }))
+ await store.install(replacement, entry.digest); await changed(view)
+ await view.locator('iframe[title="Session example"]').waitFor({ state: "detached" })
+ assert.equal((await store.list("/second"))[0].enabled, false)
+ await store.remove(entry.manifest.id, replacement.digest); await changed(view)
+ assert.deepEqual(await store.list("/first"), [])
+ } finally { await view.close() }
+})
+
+test("removal consent cannot follow a package replaced by another window", async () => {
+ const first = await installDirect(example)
+ const view = await page()
+ try {
+ await view.getByRole("button", { name: "Customize right panel" }).click()
+ await view.getByRole("button", { name: "Remove…", exact: true }).click()
+ await view.getByRole("button", { name: "Confirm removal", exact: true }).waitFor()
+ const replacement = await readPanelExtensionArchive(fixtureArchive(example, { version: "1.1.0" }))
+ await store.install(replacement, first.digest); await changed(view)
+ await view.getByText("Session example 1.1.0", { exact: true }).waitFor()
+ assert.equal(await view.getByRole("button", { name: "Confirm removal", exact: true }).count(), 0)
+ assert.equal((await store.list("/first"))[0].digest, replacement.digest)
+ await view.getByRole("button", { name: "Remove…", exact: true }).click()
+ await view.getByRole("button", { name: "Confirm removal", exact: true }).click()
+ await view.getByRole("button", { name: "Remove…", exact: true }).waitFor({ state: "detached" })
+ assert.deepEqual(await store.list("/first"), [])
+ } finally { await view.close() }
+})
+
+test("author code cannot access parent DOM/storage, native bridges, network APIs, nested frames or global RPC", async () => {
+ const pkg = await installDirect(`
`)
+ const view = await page()
+ try {
+ await view.getByRole("tab", { name: "Session example", exact: true }).click()
+ const output = view.frameLocator('iframe[title="Session example"]').locator("#security")
+ await output.filter({ hasText: '"network":"blocked"' }).waitFor()
+ assert.deepEqual(JSON.parse(await output.innerText()), { parent: "blocked", storage: "blocked", cookie: "blocked", electron: "undefined", tauri: "undefined", network: "blocked" })
+ assert.equal(await view.locator("iframe").getAttribute("sandbox"), "allow-scripts")
+ await store.remove(pkg.manifest.id, pkg.digest)
+ } finally { await view.close() }
+})
+
+test("late panel reads cannot publish into a different session or survive hidden/reconnected demand", async () => {
+ const pkg = await installDirect(example)
+ const view = await page()
+ try {
+ let release!: () => Promise, started!: () => void
+ const pending = new Promise(resolve => { started = resolve })
+ await view.route("**/api/panel-extensions/*/panel?*", async route => {
+ const response = await route.fetch(); release = () => route.fulfill({ response }); started()
+ }, { times: 1 })
+ await view.getByRole("tab", { name: "Session example", exact: true }).click(); await pending
+ await view.evaluate(() => (window as any).extensionFixture.session("session-new"))
+ const context = view.frameLocator('iframe[title="Session example"]').locator("#context")
+ await context.filter({ hasText: "session-new" }).waitFor()
+ await release()
+ assert.match(await context.innerText(), /session-new/)
+ await view.evaluate(() => (window as any).extensionFixture.transport("disconnected"))
+ await view.locator("iframe").waitFor({ state: "detached" })
+ await changed(view)
+ assert.equal(await view.locator("iframe").count(), 0)
+ await view.evaluate(() => (window as any).extensionFixture.transport("connected"))
+ await view.frameLocator('iframe[title="Session example"]').locator("#context").filter({ hasText: "session-new" }).waitFor()
+ await view.evaluate(() => (window as any).extensionFixture.active(false))
+ await view.locator("iframe").waitFor({ state: "detached" })
+ await store.remove(pkg.manifest.id, pkg.digest)
+ } finally { await view.close() }
+})
diff --git a/scripts/test-panel-extension-native.mjs b/scripts/test-panel-extension-native.mjs
new file mode 100644
index 000000000..e367510fa
--- /dev/null
+++ b/scripts/test-panel-extension-native.mjs
@@ -0,0 +1,78 @@
+// No CodeNomad backend/profile, shared daemon or desktop instance is involved.
+import assert from "node:assert/strict"
+import { createServer } from "node:http"
+import { spawn, spawnSync } from "node:child_process"
+import { mkdtemp, rm } from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+import { tsImport } from "tsx/esm/api"
+
+assert.equal(process.platform, "win32", "Run the WebView2 regression on Windows")
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
+const workspace = path.join(root, "packages/tauri-app")
+const target = path.resolve(process.env.CARGO_TARGET_DIR || path.join(workspace, "target"))
+const env = { ...process.env, CARGO_TARGET_DIR: target }
+delete env.NODE_OPTIONS
+delete env.CODENOMAD_NATIVE_PARENT
+const build = spawnSync("cargo", ["build", "-p", "codenomad-panel-extension-fixture"], {
+ cwd: workspace, env, encoding: "utf8", timeout: 300_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
+})
+assert.equal(build.error, undefined, String(build.error))
+assert.equal(build.status, 0, build.stdout + build.stderr)
+const { panelExtensionDocument } = await tsImport(new URL("../packages/ui/src/components/panel-extensions/frame-document.ts", import.meta.url).href, import.meta.url)
+const document = panelExtensionDocument(`External panel
`, "fixture-handshake")
+const reports = []
+const server = createServer((request, response) => {
+ if (request.url === "/report") {
+ let body = ""
+ request.on("data", chunk => { body += chunk })
+ request.on("end", () => { reports.push(JSON.parse(body)); response.end("ok") })
+ return
+ }
+ response.setHeader("Content-Type", "text/html")
+ response.end(``)
+})
+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 })
+}