diff --git a/plugins/sdk/dev-host/README.md b/plugins/sdk/dev-host/README.md index cc6378e448..2ec6cfe5e3 100644 --- a/plugins/sdk/dev-host/README.md +++ b/plugins/sdk/dev-host/README.md @@ -42,6 +42,8 @@ When `ui_watch` is configured, its stdout must emit a standalone `DBX_UI_BUILD_S Workbench contributions can be opened without a connection, including frontend-only plugins. Connection providers generate forms from declared fields, defaults, options and bindings. Port zero remains valid at the framework layer; plugins validate their own connection semantics. +Workbench entries, tabs and connection rows display their contribution's `icon`, falling back to the plugin-level `icon`. Icon paths are local to the plugin project (not the UI root); remote URLs and paths escaping the project are rejected. Missing or unreadable icons retain the generic host icons. Custom connection icons retain a separate connection-status dot. + Connection testing, connecting and disconnecting use the standard lifecycle methods. Custom RPC methods and results are forwarded without interpreting their business meaning. Workbench tabs are keyed by contribution and optional connection ID, retain their iframe when switched, and reuse an existing tab when opened again. Closing the last tab for a connection disconnects it after confirmation. Lifecycle replies with `success: false` are failures, matching the real host. Browser pages have independent workbench ownership. After a page's event stream disconnects, its frames remain for a 30-second reconnect grace period, then are removed; connections still used by another page remain open. Saved connection configuration is unaffected. Manifest localization is performed only by the shell; bootstrap returns one unmodified Manifest. diff --git a/plugins/sdk/dev-host/server.mjs b/plugins/sdk/dev-host/server.mjs index ea5987ab2f..8ef1368713 100644 --- a/plugins/sdk/dev-host/server.mjs +++ b/plugins/sdk/dev-host/server.mjs @@ -223,6 +223,16 @@ export async function createMockHost(options) { } async function api(session, route, p) { if (route === "/api/bridge") return bridge(session, p); + if (route === "/api/icon") { + const contribution = p.contributionId === undefined ? undefined : manifest.contributions?.find((item) => item.id === p.contributionId); + if (p.contributionId !== undefined && !contribution) throw new Error("Unknown icon contribution"); + const icon = contribution?.icon || manifest.icon; + if (!icon) return null; + if (typeof icon !== "string" || /[:\\\0]/.test(icon) || icon.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("Invalid plugin icon path"); + const asset = await readAsset(project, icon); + if (!asset.contentType.startsWith("image/")) throw new Error("Plugin icon is not an image"); + return asset; + } if (route === "/api/frame-document") { const f = frameFor(session, p.frameId); const asset = await readAsset(uiRoot, entry); @@ -340,6 +350,7 @@ export async function createMockHost(options) { "/api/bridge", "/api/frame-document", "/api/workbenches/open", + "/api/icon", "/api/connections/edit", "/api/connections/save", "/api/connections/test", diff --git a/plugins/sdk/dev-host/tests/server.test.mjs b/plugins/sdk/dev-host/tests/server.test.mjs index 612c232fea..2e5b80859b 100644 --- a/plugins/sdk/dev-host/tests/server.test.mjs +++ b/plugins/sdk/dev-host/tests/server.test.mjs @@ -168,10 +168,11 @@ test("refresh cleanup does not disconnect a connection still used by another pag assert.equal(closed.value.connections[0].connected, false); }); async function fixture(t, frontend = false, overrides = {}) { + const { pluginManifest = manifest, ...hostOverrides } = overrides; const root = await mkdtemp(join(tmpdir(), "dbx-mock-test-")); await mkdir(join(root, "ui")); await writeFile(join(root, "ui/index.html"), "
Test"); - const actual = frontend ? { ...manifest, entrypoints: { ui: manifest.entrypoints.ui }, contributions: manifest.contributions.filter((c) => c.type === "workbench") } : manifest; + const actual = frontend ? { ...pluginManifest, entrypoints: { ui: pluginManifest.entrypoints.ui }, contributions: pluginManifest.contributions.filter((c) => c.type === "workbench") } : pluginManifest; await writeFile(join(root, "manifest.json"), JSON.stringify(actual)); const host = await createMockHost({ project: root, @@ -186,7 +187,7 @@ async function fixture(t, frontend = false, overrides = {}) { buildBackend: async () => { throw new Error("Intentional build failure"); }, - ...overrides, + ...hostOverrides, }); t.after(async () => { await host.close(); @@ -375,6 +376,69 @@ test("asset reader rejects traversal and symlinks outside the UI root", async (t assert.equal(Buffer.from((await readAsset(join(root, "ui"), "index.html")).dataBase64, "base64").toString(), await readFile(join(root, "ui/index.html"), "utf8")); }); +test("icons use contribution overrides and plugin fallback from the project root", async (t) => { + const pluginManifest = { + ...manifest, + icon: "assets/plugin.svg", + contributions: [...manifest.contributions.map((contribution) => ({ ...contribution, icon: `assets/${contribution.type}.svg` })), { type: "workbench", id: "example.fallback", label: "Fallback" }], + }; + const { root, request } = await fixture(t, false, { pluginManifest }); + await mkdir(join(root, "assets")); + for (const name of ["plugin", "connection-provider", "workbench"]) { + await writeFile(join(root, `assets/${name}.svg`), ``); + } + for (const [contributionId, name] of [ + [undefined, "plugin"], + ["example.connection", "connection-provider"], + ["example.main", "workbench"], + ["example.fallback", "plugin"], + ]) { + const result = await request("icon", { contributionId, path: "manifest.json" }); + assert.equal(result.status, 200); + assert.equal(result.value.contentType, "image/svg+xml"); + assert.equal(Buffer.from(result.value.dataBase64, "base64").toString(), await readFile(join(root, `assets/${name}.svg`), "utf8")); + } +}); + +test("icons retain browser session protection and reject unknown contributions", async (t) => { + const { request } = await fixture(t); + assert.equal((await request("icon", {}, { Origin: "https://evil.example" })).status, 403); + assert.equal((await request("icon", {}, { "X-Mock-Csrf": "wrong" })).status, 403); + assert.equal((await request("icon", {}, { Cookie: "" })).status, 403); + assert.equal((await request("icon", { contributionId: "missing" })).status, 400); +}); + +test("plugins without icons return null without preventing workbench startup", async (t) => { + const { request } = await fixture(t, true); + assert.deepEqual(await request("icon"), { status: 200, value: null }); + assert.deepEqual(await request("icon", { contributionId: "example.main" }), { status: 200, value: null }); + assert.equal((await request("workbenches/open", { contributionId: "example.main" })).status, 200); +}); + +test("icon reads reject unsafe paths, missing files, non-images, symlink escapes and oversized files", async (t) => { + const paths = ["../outside.svg", "/outside.svg", "assets/../manifest.json", "assets\\icon.svg", "https://example.com/icon.svg", "data:image/svg+xml;base64,PHN2Zy8+", "assets/missing.svg", "manifest.json", "assets/link.svg", "assets/large.svg"]; + const pluginManifest = { + ...manifest, + contributions: paths.map((icon, index) => ({ type: "workbench", id: `example.icon-${index}`, label: "Icon", icon })), + }; + const { root, request, host } = await fixture(t, true, { pluginManifest }); + const outside = await mkdtemp(join(tmpdir(), "dbx-icon-outside-")); + t.after(() => rm(outside, { recursive: true, force: true })); + await mkdir(join(root, "assets")); + await writeFile(join(outside, "private.svg"), "private-icon-content"); + await symlink(join(outside, "private.svg"), join(root, "assets/link.svg")); + await writeFile(join(root, "assets/large.svg"), Buffer.alloc(8 * 1024 * 1024 + 1)); + for (const contribution of pluginManifest.contributions) { + const result = await request("icon", { contributionId: contribution.id }); + assert.equal(result.status, 400, contribution.icon); + assert.equal(result.value, undefined); + assert.ok(!JSON.stringify(result).includes(root)); + assert.ok(!JSON.stringify(result).includes("private-icon-content")); + } + assert.equal(host.sidecar.state, "frontend"); + assert.equal((await request("workbenches/open", { contributionId: "example.icon-0" })).status, 200); +}); + test("UI root overrides and port collision fallback work without changing the plugin manifest", async (t) => { const { root, host } = await fixture(t); await mkdir(join(root, "alternate-ui")); diff --git a/plugins/sdk/dev-host/ui/App.vue b/plugins/sdk/dev-host/ui/App.vue index 0583d0bb3c..909865633b 100644 --- a/plugins/sdk/dev-host/ui/App.vue +++ b/plugins/sdk/dev-host/ui/App.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue" import { Plus, Upload, Plug, Unplug, Pencil, Trash2, X, RefreshCw, Sun, Moon, Terminal, Settings2, Languages } from "lucide-vue-next"; import { hostMessage } from "./messages.js"; import DebugPanel from "./DebugPanel.vue"; +import PluginIcon from "./PluginIcon.vue"; import { localizeManifest, translate } from "./i18n.js"; const rawManifest = ref(), connections = ref([]), @@ -73,6 +74,15 @@ const editor = ref(), draft = ref(), editing = ref(false); const windows = new Map(); +const iconRequests = new Map(); +function loadIcon(contributionId) { + if (!iconRequests.has(contributionId)) + iconRequests.set( + contributionId, + api("icon", { contributionId }).catch(() => null), + ); + return iconRequests.get(contributionId); +} let csrf, stream, finishAsk; const providers = computed(() => manifest.value?.contributions.filter((c) => c.type === "connection-provider") || []); const workbenches = computed(() => manifest.value?.contributions.filter((c) => c.type === "workbench") || []); @@ -345,7 +355,7 @@ onBeforeUnmount(() => {