From d0024d9f60d661cfe4f7c66489216c124f997c73 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Wed, 5 Aug 2026 21:26:15 -0700 Subject: [PATCH 1/2] Refactor web extensions onto contribution registry --- server/extensions/webUi.ts | 333 ++++++++++++++----------------------- tests/extensions.test.ts | 32 ++++ 2 files changed, 155 insertions(+), 210 deletions(-) diff --git a/server/extensions/webUi.ts b/server/extensions/webUi.ts index f2b9a85..b372873 100644 --- a/server/extensions/webUi.ts +++ b/server/extensions/webUi.ts @@ -39,26 +39,34 @@ type PendingExtensionUiRequest = { }; const pendingExtensionUiRequests = new Map(); -type WebFooterState = { - footers: Map; -}; +type WebContribution = + | { version: 1; key: string; slot: "footer"; kind: "static"; view: PiWebFooter } + | { version: 1; key: string; slot: "header-action"; kind: "rendered"; source: PiWebHeaderAction } + | { version: 1; key: string; slot: "artifact-action"; kind: "rendered"; source: PiWebArtifactAction } + | { version: 1; key: string; slot: "git-tab"; kind: "rendered"; source: PiWebGitTab }; -type WebHeaderActionState = { - actions: Map; -}; +/** Canonical per-runtime registry. Legacy surfaces below are wire adapters over it. */ +const webContributionStates = new WeakMap>(); -type WebGitTabState = { - tabs: Map; -}; +function contributionId(slot: WebContribution["slot"], key: string) { + return `${slot}\0${key}`; +} -type WebArtifactActionState = { - actions: Map; -}; +function contributionState(value: any) { + const session = value as object; + let state = webContributionStates.get(session); + if (!state) { + state = new Map(); + webContributionStates.set(session, state); + } + return state; +} -const webFooterStates = new WeakMap(); -const webHeaderActionStates = new WeakMap(); -const webGitTabStates = new WeakMap(); -const webArtifactActionStates = new WeakMap(); +function contributionsFor(value: any, slot: S) { + return Array.from(contributionState(value).values()).filter( + (entry): entry is Extract => entry.slot === slot, + ); +} // Process-global extension-settings schema registry: decoupled from any one // session. Values live in settingsStore (global). A schema stays registered @@ -325,61 +333,18 @@ async function getExtensionSettings(id: string): Promise { return { schemaVersion: 0, values: {} }; } -function getWebFooterState(value: any): WebFooterState { - const key = value as object; - let state = webFooterStates.get(key); - if (!state) { - state = { footers: new Map() }; - webFooterStates.set(key, state); - } - return state; -} - -function cleanFooterKey(value: unknown) { +function cleanContributionKey(value: unknown) { if (typeof value !== "string") return undefined; const cleaned = value.trim().slice(0, 80).replace(/[^a-zA-Z0-9_.:-]/g, "-"); return cleaned || undefined; } -const cleanHeaderActionKey = cleanFooterKey; -const cleanGitTabKey = cleanFooterKey; - function cleanHeaderActionText(value: unknown, maxLength = 200) { if (typeof value !== "string") return undefined; const cleaned = value.replace(/[\u0000-\u001F\u007F]/g, "").trim(); return cleaned ? cleaned.slice(0, maxLength) : undefined; } -function getWebHeaderActionState(value: any): WebHeaderActionState { - const key = value as object; - let state = webHeaderActionStates.get(key); - if (!state) { - state = { actions: new Map() }; - webHeaderActionStates.set(key, state); - } - return state; -} - -function getWebArtifactActionState(value: any): WebArtifactActionState { - const key = value as object; - let state = webArtifactActionStates.get(key); - if (!state) { - state = { actions: new Map() }; - webArtifactActionStates.set(key, state); - } - return state; -} - -function getWebGitTabState(value: any): WebGitTabState { - const key = value as object; - let state = webGitTabStates.get(key); - if (!state) { - state = { tabs: new Map() }; - webGitTabStates.set(key, state); - } - return state; -} - function cleanFooterText(value: unknown, maxLength = 2_000) { if (typeof value !== "string") return undefined; const cleaned = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trimEnd(); @@ -404,126 +369,87 @@ function normalizePiWebFooter(value: unknown): PiWebFooter | undefined { return undefined; } -function webFooterEntries(value: any) { - return Array.from(getWebFooterState(value).footers.entries()).map(([key, footer]) => ({ key, footer })); -} - -function broadcastWebFooters(value: any) { - const webFooters = webFooterEntries(value); - deps.emit({ - type: "web_footer_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webFooters, - }); - return webFooters; -} - -function webHeaderActionEntries(value: any) { - return Array.from(getWebHeaderActionState(value).actions.entries()).map(([key, action]) => ({ - key, - icon: cleanHeaderActionText(action.icon, 80), - title: cleanHeaderActionText(action.title) || key, - label: cleanHeaderActionText(action.label), - })); -} - -function broadcastWebHeaderActions(value: any) { - const webHeaderActions = webHeaderActionEntries(value); - deps.emit({ - type: "web_header_actions_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webHeaderActions, - }); - return webHeaderActions; -} - -function webArtifactActionEntries(value: any) { - return Array.from(getWebArtifactActionState(value).actions.entries()).map(([key, action]) => ({ - key, - title: cleanHeaderActionText(action.title) || key, - label: cleanHeaderActionText(action.label, 80), - kinds: Array.isArray(action.kinds) ? action.kinds.filter((kind) => kind === "markdown" || kind === "html" || kind === "video") : undefined, - extensions: Array.isArray(action.extensions) ? action.extensions.flatMap((extension) => { - const cleaned = cleanHeaderActionText(extension, 30)?.toLowerCase(); - return cleaned && /^\.[a-z0-9]+$/.test(cleaned) ? [cleaned] : []; - }).slice(0, 20) : undefined, - })); -} - -function broadcastWebArtifactActions(value: any) { - const webArtifactActions = webArtifactActionEntries(value); - deps.emit({ type: "web_artifact_actions_changed", sessionId: value.sessionId, sessionFile: value.sessionFile, webArtifactActions }); - return webArtifactActions; -} - -function webGitTabEntries(value: any) { - return Array.from(getWebGitTabState(value).tabs.entries()).map(([key, tab]) => ({ - key, - title: cleanHeaderActionText(tab.title) || key, - label: cleanHeaderActionText(tab.label, 80), - })); -} - -function broadcastWebGitTabs(value: any) { - const webGitTabs = webGitTabEntries(value); - deps.emit({ - type: "web_git_tabs_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webGitTabs, - }); - return webGitTabs; +const contributionPolicies = { + footer: { + entries: (value: any) => contributionsFor(value, "footer").map(({ key, view: footer }) => ({ key, footer })), + event: "web_footer_changed", + field: "webFooters", + }, + "header-action": { + entries: (value: any) => contributionsFor(value, "header-action").map(({ key, source }) => ({ + key, icon: cleanHeaderActionText(source.icon, 80), title: cleanHeaderActionText(source.title) || key, + label: cleanHeaderActionText(source.label), + })), + event: "web_header_actions_changed", + field: "webHeaderActions", + }, + "artifact-action": { + entries: (value: any) => contributionsFor(value, "artifact-action").map(({ key, source }) => ({ + key, title: cleanHeaderActionText(source.title) || key, label: cleanHeaderActionText(source.label, 80), + kinds: Array.isArray(source.kinds) ? source.kinds.filter((kind) => kind === "markdown" || kind === "html" || kind === "video") : undefined, + extensions: Array.isArray(source.extensions) ? source.extensions.flatMap((extension) => { + const cleaned = cleanHeaderActionText(extension, 30)?.toLowerCase(); + return cleaned && /^\.[a-z0-9]+$/.test(cleaned) ? [cleaned] : []; + }).slice(0, 20) : undefined, + })), + event: "web_artifact_actions_changed", + field: "webArtifactActions", + }, + "git-tab": { + entries: (value: any) => contributionsFor(value, "git-tab").map(({ key, source }) => ({ + key, title: cleanHeaderActionText(source.title) || key, label: cleanHeaderActionText(source.label, 80), + })), + event: "web_git_tabs_changed", + field: "webGitTabs", + }, +} as const; + +type ContributionSlot = keyof typeof contributionPolicies; + +function broadcastContributions(value: any, slot: ContributionSlot) { + const policy = contributionPolicies[slot]; + const entries = policy.entries(value); + deps.emit({ type: policy.event, sessionId: value.sessionId, sessionFile: value.sessionFile, [policy.field]: entries }); + return entries; +} + +function setContribution(value: any, contribution: WebContribution | undefined, slot: ContributionSlot, keyValue: unknown) { + const key = cleanContributionKey(keyValue); + if (!key) return; + const id = contributionId(slot, key); + if (contribution) contributionState(value).set(id, contribution); + else contributionState(value).delete(id); + broadcastContributions(value, slot); } function createPiWebUi(value: any): PiWebUi { return { setFooter(key, footer) { - const footerKey = cleanFooterKey(key); - if (!footerKey) return; - const footerState = getWebFooterState(value); - const normalized = normalizePiWebFooter(footer); - if (normalized) footerState.footers.set(footerKey, normalized); - else footerState.footers.delete(footerKey); - broadcastWebFooters(value); + const cleanKey = cleanContributionKey(key); + if (!cleanKey) return; + const view = normalizePiWebFooter(footer); + setContribution(value, view ? { version: 1, key: cleanKey, slot: "footer", kind: "static", view } : undefined, "footer", cleanKey); }, setHeaderAction(key, action) { - const actionKey = cleanHeaderActionKey(key); - if (!actionKey) return; - const actionState = getWebHeaderActionState(value); - if (action && typeof action === "object" && typeof action.invoke === "function") { - actionState.actions.set(actionKey, action); - } else { - actionState.actions.delete(actionKey); - } - broadcastWebHeaderActions(value); + const cleanKey = cleanContributionKey(key); + if (!cleanKey) return; + const valid = action && typeof action === "object" && typeof action.invoke === "function"; + setContribution(value, valid ? { version: 1, key: cleanKey, slot: "header-action", kind: "rendered", source: action } : undefined, "header-action", cleanKey); }, setArtifactAction(key, action) { - const actionKey = cleanHeaderActionKey(key); - if (!actionKey) return; - const state = getWebArtifactActionState(value); - if (action && typeof action === "object" && typeof action.invoke === "function") state.actions.set(actionKey, action); - else state.actions.delete(actionKey); - broadcastWebArtifactActions(value); + const cleanKey = cleanContributionKey(key); + if (!cleanKey) return; + const valid = action && typeof action === "object" && typeof action.invoke === "function"; + setContribution(value, valid ? { version: 1, key: cleanKey, slot: "artifact-action", kind: "rendered", source: action } : undefined, "artifact-action", cleanKey); }, setGitTab(key, tab) { - const tabKey = cleanGitTabKey(key); - if (!tabKey) return; - const tabState = getWebGitTabState(value); - if (tab && typeof tab === "object" && typeof tab.render === "function") { - tabState.tabs.set(tabKey, tab); - } else { - tabState.tabs.delete(tabKey); - } - broadcastWebGitTabs(value); - }, - async registerSettings(schema) { - return registerSessionSettings(value, schema); - }, - async getSettings(id) { - return getExtensionSettings(id); + const cleanKey = cleanContributionKey(key); + if (!cleanKey) return; + const valid = tab && typeof tab === "object" && typeof tab.render === "function"; + setContribution(value, valid ? { version: 1, key: cleanKey, slot: "git-tab", kind: "rendered", source: tab } : undefined, "git-tab", cleanKey); }, + async registerSettings(schema) { return registerSessionSettings(value, schema); }, + async getSettings(id) { return getExtensionSettings(id); }, }; } @@ -701,11 +627,17 @@ async function bindWebExtensions(value: any) { } - async function invokeHeaderAction(value: any, keyValue: unknown) { - const key = cleanHeaderActionKey(keyValue); + function renderedContribution(value: any, slot: S, keyValue: unknown) { + const key = cleanContributionKey(keyValue); if (!key) throw new Error("key is required"); - const action = getWebHeaderActionState(value).actions.get(key); - if (!action) throw new Error("Header action not found"); + const contribution = contributionState(value).get(contributionId(slot, key)); + return { key, contribution: contribution?.slot === slot ? contribution as Extract : undefined }; + } + + async function invokeHeaderAction(value: any, keyValue: unknown) { + const { key, contribution } = renderedContribution(value, "header-action", keyValue); + if (!contribution) throw new Error("Header action not found"); + const action = contribution.source; const result = await action.invoke(); const markdown = cleanFooterText(result?.markdown, 200_000); if (!markdown) throw new Error("Header action returned no markdown"); @@ -713,63 +645,44 @@ async function bindWebExtensions(value: any) { } async function invokeArtifactAction(value: any, input: { key?: unknown; name?: unknown; path?: unknown; kind?: unknown }) { - const key = cleanHeaderActionKey(input.key); - if (!key) throw new Error("key is required"); - const action = getWebArtifactActionState(value).actions.get(key); - if (!action) throw new Error("Artifact action not found"); + const { key, contribution } = renderedContribution(value, "artifact-action", input.key); + if (!contribution) throw new Error("Artifact action not found"); + const action = contribution.source; const name = cleanHeaderActionText(input.name, 500); const path = cleanHeaderActionText(input.path, 2_000); const kind = input.kind === "markdown" || input.kind === "html" || input.kind === "video" ? input.kind : undefined; let pathName: string | undefined; - try { - if (path && path.startsWith("/api/artifacts/")) pathName = decodeURIComponent(path.slice("/api/artifacts/".length)).split("/").at(-1); - } catch { /* invalid encoded artifact path */ } + try { if (path?.startsWith("/api/artifacts/")) pathName = decodeURIComponent(path.slice(15)).split("/").at(-1); } catch { /* invalid encoding */ } if (!name || !path || !kind || pathName !== name) throw new Error("Invalid artifact context"); - if (Array.isArray(action.kinds) && action.kinds.length && !action.kinds.includes(kind)) throw new Error("Artifact action does not match this artifact"); - if (Array.isArray(action.extensions) && action.extensions.length && !action.extensions.some((extension) => typeof extension === "string" && name.toLowerCase().endsWith(extension.toLowerCase()))) throw new Error("Artifact action does not match this artifact"); + if (action.kinds?.length && !action.kinds.includes(kind)) throw new Error("Artifact action does not match this artifact"); + if (action.extensions?.length && !action.extensions.some((extension) => typeof extension === "string" && name.toLowerCase().endsWith(extension.toLowerCase()))) throw new Error("Artifact action does not match this artifact"); const result = await action.invoke({ name, path, kind }); const markdown = cleanFooterText(result?.markdown, 200_000); const message = cleanHeaderActionText(result?.message, 2_000); - const download = result?.download && typeof result.download === "object" - ? { path, filename: cleanHeaderActionText(result.download.filename, 500) || name } - : undefined; + const download = result?.download && typeof result.download === "object" ? { path, filename: cleanHeaderActionText(result.download.filename, 500) || name } : undefined; if (!markdown && !message && !download) throw new Error("Artifact action returned no result"); return { label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || key, ...(markdown ? { markdown } : {}), ...(message ? { message } : {}), ...(download ? { download } : {}) }; } async function invokeGitTab(value: any, input: { key?: unknown; action?: unknown; payload?: unknown; repo?: unknown }) { - const key = cleanGitTabKey(input.key); - if (!key) throw new Error("key is required"); - const tab = getWebGitTabState(value).tabs.get(key); - if (!tab) throw new Error("Git tab not found"); + const { key, contribution } = renderedContribution(value, "git-tab", input.key); + if (!contribution) throw new Error("Git tab not found"); + const tab = contribution.source; const repo = input.repo && typeof input.repo === "object" ? input.repo as Record : undefined; - const result = await tab.render({ - action: typeof input.action === "string" ? input.action : undefined, - payload: input.payload, - repo: repo ? { - path: typeof repo.path === "string" ? repo.path : undefined, - root: typeof repo.root === "string" ? repo.root : undefined, - branch: typeof repo.branch === "string" ? repo.branch : undefined, - } : undefined, - }); + const result = await tab.render({ action: typeof input.action === "string" ? input.action : undefined, payload: input.payload, repo: repo ? { + path: typeof repo.path === "string" ? repo.path : undefined, root: typeof repo.root === "string" ? repo.root : undefined, + branch: typeof repo.branch === "string" ? repo.branch : undefined, + } : undefined }); const html = cleanFooterText(result?.html, 500_000); - const rawContext = result?.composerContext && typeof result.composerContext === "object" - ? result.composerContext as Record - : undefined; + const rawContext = result?.composerContext && typeof result.composerContext === "object" ? result.composerContext as Record : undefined; const contextLabel = cleanHeaderActionText(rawContext?.label, 200); const contextContent = cleanFooterText(rawContext?.content, 200_000); const composerContext = contextLabel && contextContent ? { - ...(cleanHeaderActionText(rawContext?.id, 500) ? { id: cleanHeaderActionText(rawContext?.id, 500) } : {}), - label: contextLabel, - ...(cleanHeaderActionText(rawContext?.title, 500) ? { title: cleanHeaderActionText(rawContext?.title, 500) } : {}), - content: contextContent, + ...(cleanHeaderActionText(rawContext?.id, 500) ? { id: cleanHeaderActionText(rawContext?.id, 500) } : {}), label: contextLabel, + ...(cleanHeaderActionText(rawContext?.title, 500) ? { title: cleanHeaderActionText(rawContext?.title, 500) } : {}), content: contextContent, } : undefined; if (!html && !composerContext) throw new Error("Git tab returned no HTML or composer context"); - return { - title: cleanHeaderActionText(result?.title) || cleanHeaderActionText(tab.title) || key, - ...(html ? { html } : {}), - ...(composerContext ? { composerContext } : {}), - }; + return { title: cleanHeaderActionText(result?.title) || cleanHeaderActionText(tab.title) || key, ...(html ? { html } : {}), ...(composerContext ? { composerContext } : {}) }; } function respond(id: string, response: Record): boolean { @@ -781,7 +694,7 @@ async function bindWebExtensions(value: any) { return { bind: bindWebExtensions, - entries: (value: any) => ({ webFooters: webFooterEntries(value), webHeaderActions: webHeaderActionEntries(value), webArtifactActions: webArtifactActionEntries(value), webGitTabs: webGitTabEntries(value) }), + entries: (value: any) => ({ webFooters: contributionPolicies.footer.entries(value), webHeaderActions: contributionPolicies["header-action"].entries(value), webArtifactActions: contributionPolicies["artifact-action"].entries(value), webGitTabs: contributionPolicies["git-tab"].entries(value) }), invokeHeaderAction, invokeArtifactAction, invokeGitTab, diff --git a/tests/extensions.test.ts b/tests/extensions.test.ts index 6bfe6c6..6a8b32d 100644 --- a/tests/extensions.test.ts +++ b/tests/extensions.test.ts @@ -110,6 +110,38 @@ describe("bundled extension path discovery", () => { .rejects.toThrow("Invalid artifact context"); }); + it("keeps legacy surfaces isolated over one contribution registry", async () => { + let ui: any; + const emitted: any[] = []; + const bridge = createWebUiBridge({ + emit: (value) => emitted.push(value), clientCount: () => 1, acquireWorkLease: () => () => undefined, + createNewSession: async () => ({}), sessionCwd: () => process.cwd(), state: () => ({}), + }); + const session = { + sessionId: "session", sessionFile: "/tmp/session.jsonl", agent: { waitForIdle: async () => undefined }, + bindExtensions: async (options: any) => { ui = options.uiContext; }, + }; + await bridge.bind(session); + + ui.web.setFooter("shared", "ready"); + ui.web.setHeaderAction("shared", { title: "Summary", invoke: () => ({ markdown: "# Done" }) }); + ui.web.setGitTab("shared", { title: "Issues", render: () => ({ html: "

Open

" }) }); + + expect(bridge.entries(session)).toMatchObject({ + webFooters: [{ key: "shared", footer: { kind: "text", lines: ["ready"] } }], + webHeaderActions: [{ key: "shared", title: "Summary" }], + webGitTabs: [{ key: "shared", title: "Issues" }], + }); + await expect(bridge.invokeHeaderAction(session, "shared")).resolves.toMatchObject({ markdown: "# Done" }); + await expect(bridge.invokeGitTab(session, { key: "shared" })).resolves.toMatchObject({ html: "

Open

" }); + + ui.web.setHeaderAction("shared", undefined); + expect(bridge.entries(session).webHeaderActions).toEqual([]); + expect(bridge.entries(session).webFooters).toHaveLength(1); + expect(bridge.entries(session).webGitTabs).toHaveLength(1); + expect(emitted.at(-1)).toMatchObject({ type: "web_header_actions_changed", webHeaderActions: [] }); + }); + it("re-emits a footer when the same session id gets a new runtime", async () => { vi.useFakeTimers(); try { From ae7f653c87730893802fba9033bf2c86aecd1d8b Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Wed, 5 Aug 2026 22:13:54 -0700 Subject: [PATCH 2/2] Address contribution kernel review feedback --- server/extensions/webUi.ts | 87 +++++++++++++++++++++++++------------- tests/extensions.test.ts | 23 +++++++++- 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/server/extensions/webUi.ts b/server/extensions/webUi.ts index b372873..3d836bd 100644 --- a/server/extensions/webUi.ts +++ b/server/extensions/webUi.ts @@ -413,9 +413,15 @@ function broadcastContributions(value: any, slot: ContributionSlot) { return entries; } -function setContribution(value: any, contribution: WebContribution | undefined, slot: ContributionSlot, keyValue: unknown) { +function setContribution( + value: any, + slot: ContributionSlot, + keyValue: unknown, + create: (key: string) => WebContribution | undefined, +) { const key = cleanContributionKey(keyValue); if (!key) return; + const contribution = create(key); const id = contributionId(slot, key); if (contribution) contributionState(value).set(id, contribution); else contributionState(value).delete(id); @@ -425,28 +431,31 @@ function setContribution(value: any, contribution: WebContribution | undefined, function createPiWebUi(value: any): PiWebUi { return { setFooter(key, footer) { - const cleanKey = cleanContributionKey(key); - if (!cleanKey) return; - const view = normalizePiWebFooter(footer); - setContribution(value, view ? { version: 1, key: cleanKey, slot: "footer", kind: "static", view } : undefined, "footer", cleanKey); + setContribution(value, "footer", key, (cleanKey) => { + const view = normalizePiWebFooter(footer); + return view ? { version: 1, key: cleanKey, slot: "footer", kind: "static", view } : undefined; + }); }, setHeaderAction(key, action) { - const cleanKey = cleanContributionKey(key); - if (!cleanKey) return; - const valid = action && typeof action === "object" && typeof action.invoke === "function"; - setContribution(value, valid ? { version: 1, key: cleanKey, slot: "header-action", kind: "rendered", source: action } : undefined, "header-action", cleanKey); + setContribution(value, "header-action", key, (cleanKey) => ( + action && typeof action === "object" && typeof action.invoke === "function" + ? { version: 1, key: cleanKey, slot: "header-action", kind: "rendered", source: action } + : undefined + )); }, setArtifactAction(key, action) { - const cleanKey = cleanContributionKey(key); - if (!cleanKey) return; - const valid = action && typeof action === "object" && typeof action.invoke === "function"; - setContribution(value, valid ? { version: 1, key: cleanKey, slot: "artifact-action", kind: "rendered", source: action } : undefined, "artifact-action", cleanKey); + setContribution(value, "artifact-action", key, (cleanKey) => ( + action && typeof action === "object" && typeof action.invoke === "function" + ? { version: 1, key: cleanKey, slot: "artifact-action", kind: "rendered", source: action } + : undefined + )); }, setGitTab(key, tab) { - const cleanKey = cleanContributionKey(key); - if (!cleanKey) return; - const valid = tab && typeof tab === "object" && typeof tab.render === "function"; - setContribution(value, valid ? { version: 1, key: cleanKey, slot: "git-tab", kind: "rendered", source: tab } : undefined, "git-tab", cleanKey); + setContribution(value, "git-tab", key, (cleanKey) => ( + tab && typeof tab === "object" && typeof tab.render === "function" + ? { version: 1, key: cleanKey, slot: "git-tab", kind: "rendered", source: tab } + : undefined + )); }, async registerSettings(schema) { return registerSessionSettings(value, schema); }, async getSettings(id) { return getExtensionSettings(id); }, @@ -652,16 +661,23 @@ async function bindWebExtensions(value: any) { const path = cleanHeaderActionText(input.path, 2_000); const kind = input.kind === "markdown" || input.kind === "html" || input.kind === "video" ? input.kind : undefined; let pathName: string | undefined; - try { if (path?.startsWith("/api/artifacts/")) pathName = decodeURIComponent(path.slice(15)).split("/").at(-1); } catch { /* invalid encoding */ } + try { + if (path && path.startsWith("/api/artifacts/")) pathName = decodeURIComponent(path.slice("/api/artifacts/".length)).split("/").at(-1); + } catch { /* invalid encoded artifact path */ } if (!name || !path || !kind || pathName !== name) throw new Error("Invalid artifact context"); - if (action.kinds?.length && !action.kinds.includes(kind)) throw new Error("Artifact action does not match this artifact"); - if (action.extensions?.length && !action.extensions.some((extension) => typeof extension === "string" && name.toLowerCase().endsWith(extension.toLowerCase()))) throw new Error("Artifact action does not match this artifact"); + if (Array.isArray(action.kinds) && action.kinds.length && !action.kinds.includes(kind)) throw new Error("Artifact action does not match this artifact"); + if (Array.isArray(action.extensions) && action.extensions.length && !action.extensions.some((extension) => typeof extension === "string" && name.toLowerCase().endsWith(extension.toLowerCase()))) throw new Error("Artifact action does not match this artifact"); const result = await action.invoke({ name, path, kind }); const markdown = cleanFooterText(result?.markdown, 200_000); const message = cleanHeaderActionText(result?.message, 2_000); const download = result?.download && typeof result.download === "object" ? { path, filename: cleanHeaderActionText(result.download.filename, 500) || name } : undefined; if (!markdown && !message && !download) throw new Error("Artifact action returned no result"); - return { label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || key, ...(markdown ? { markdown } : {}), ...(message ? { message } : {}), ...(download ? { download } : {}) }; + return { + label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || key, + ...(markdown ? { markdown } : {}), + ...(message ? { message } : {}), + ...(download ? { download } : {}), + }; } async function invokeGitTab(value: any, input: { key?: unknown; action?: unknown; payload?: unknown; repo?: unknown }) { @@ -669,20 +685,33 @@ async function bindWebExtensions(value: any) { if (!contribution) throw new Error("Git tab not found"); const tab = contribution.source; const repo = input.repo && typeof input.repo === "object" ? input.repo as Record : undefined; - const result = await tab.render({ action: typeof input.action === "string" ? input.action : undefined, payload: input.payload, repo: repo ? { - path: typeof repo.path === "string" ? repo.path : undefined, root: typeof repo.root === "string" ? repo.root : undefined, - branch: typeof repo.branch === "string" ? repo.branch : undefined, - } : undefined }); + const result = await tab.render({ + action: typeof input.action === "string" ? input.action : undefined, + payload: input.payload, + repo: repo ? { + path: typeof repo.path === "string" ? repo.path : undefined, + root: typeof repo.root === "string" ? repo.root : undefined, + branch: typeof repo.branch === "string" ? repo.branch : undefined, + } : undefined, + }); const html = cleanFooterText(result?.html, 500_000); - const rawContext = result?.composerContext && typeof result.composerContext === "object" ? result.composerContext as Record : undefined; + const rawContext = result?.composerContext && typeof result.composerContext === "object" + ? result.composerContext as Record + : undefined; const contextLabel = cleanHeaderActionText(rawContext?.label, 200); const contextContent = cleanFooterText(rawContext?.content, 200_000); const composerContext = contextLabel && contextContent ? { - ...(cleanHeaderActionText(rawContext?.id, 500) ? { id: cleanHeaderActionText(rawContext?.id, 500) } : {}), label: contextLabel, - ...(cleanHeaderActionText(rawContext?.title, 500) ? { title: cleanHeaderActionText(rawContext?.title, 500) } : {}), content: contextContent, + ...(cleanHeaderActionText(rawContext?.id, 500) ? { id: cleanHeaderActionText(rawContext?.id, 500) } : {}), + label: contextLabel, + ...(cleanHeaderActionText(rawContext?.title, 500) ? { title: cleanHeaderActionText(rawContext?.title, 500) } : {}), + content: contextContent, } : undefined; if (!html && !composerContext) throw new Error("Git tab returned no HTML or composer context"); - return { title: cleanHeaderActionText(result?.title) || cleanHeaderActionText(tab.title) || key, ...(html ? { html } : {}), ...(composerContext ? { composerContext } : {}) }; + return { + title: cleanHeaderActionText(result?.title) || cleanHeaderActionText(tab.title) || key, + ...(html ? { html } : {}), + ...(composerContext ? { composerContext } : {}), + }; } function respond(id: string, response: Record): boolean { diff --git a/tests/extensions.test.ts b/tests/extensions.test.ts index 6a8b32d..492ec9b 100644 --- a/tests/extensions.test.ts +++ b/tests/extensions.test.ts @@ -108,6 +108,13 @@ describe("bundled extension path discovery", () => { .rejects.toThrow("does not match this artifact"); await expect(bridge.invokeArtifactAction(session, { key: "download", name: "page.html", path: "/api/artifacts/other.html", kind: "html" })) .rejects.toThrow("Invalid artifact context"); + + ui.web.setArtifactAction("malformed", { + title: "Malformed filters", kinds: "html", extensions: ".html", + invoke: () => ({ message: "invoked" }), + } as any); + await expect(bridge.invokeArtifactAction(session, { key: "malformed", name: "notes.md", path: "/api/artifacts/notes.md", kind: "markdown" })) + .resolves.toMatchObject({ message: "invoked" }); }); it("keeps legacy surfaces isolated over one contribution registry", async () => { @@ -123,12 +130,24 @@ describe("bundled extension path discovery", () => { }; await bridge.bind(session); + ui.web.setFooter("first", "one"); ui.web.setFooter("shared", "ready"); + ui.web.setFooter("last", "three"); + ui.web.setFooter("shared", "updated"); ui.web.setHeaderAction("shared", { title: "Summary", invoke: () => ({ markdown: "# Done" }) }); ui.web.setGitTab("shared", { title: "Issues", render: () => ({ html: "

Open

" }) }); + const broadcastsBeforeInvalidKey = emitted.length; + ui.web.setFooter("", "ignored"); + ui.web.setFooter("", undefined); + expect(emitted).toHaveLength(broadcastsBeforeInvalidKey); + expect(bridge.entries(session).webFooters.map(({ key }: { key: string }) => key)).toEqual(["first", "shared", "last"]); expect(bridge.entries(session)).toMatchObject({ - webFooters: [{ key: "shared", footer: { kind: "text", lines: ["ready"] } }], + webFooters: [ + { key: "first", footer: { kind: "text", lines: ["one"] } }, + { key: "shared", footer: { kind: "text", lines: ["updated"] } }, + { key: "last", footer: { kind: "text", lines: ["three"] } }, + ], webHeaderActions: [{ key: "shared", title: "Summary" }], webGitTabs: [{ key: "shared", title: "Issues" }], }); @@ -137,7 +156,7 @@ describe("bundled extension path discovery", () => { ui.web.setHeaderAction("shared", undefined); expect(bridge.entries(session).webHeaderActions).toEqual([]); - expect(bridge.entries(session).webFooters).toHaveLength(1); + expect(bridge.entries(session).webFooters).toHaveLength(3); expect(bridge.entries(session).webGitTabs).toHaveLength(1); expect(emitted.at(-1)).toMatchObject({ type: "web_header_actions_changed", webHeaderActions: [] }); });