From 2d6c1cdecb1d3f44b34842fcfd91237b1c44e7b3 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 21 Sep 2026 16:56:15 -0500 Subject: [PATCH] feat: add a permission-gated UI action registry and action slots for plugins Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal plugin offers and the ai plugin fills, gated on ai.services.use. --- plugins/ai/frontend/index.mjs | 58 +++- plugins/ai/manifest.json | 13 +- plugins/ssh-terminal/frontend/index.mjs | 8 + plugins/ssh-terminal/manifest.json | 6 + .../manifests/invalid-action-permission.json | 37 +++ .../__fixtures__/manifests/valid-actions.json | 52 +++ scripts/plugin-manifest.schema.json | 57 ++++ scripts/validate-plugin-manifest.cjs | 143 +++++++++ scripts/validate-plugin-manifest.test.ts | 12 + src/backend/database/routes/rbac.ts | 42 +++ src/backend/plugins/manifest.ts | 204 ++++++++++++ src/backend/tests/plugins/manifest.test.ts | 166 ++++++++++ src/ui/AppShell.tsx | 3 + src/ui/api/rbac-api.ts | 22 ++ src/ui/features/terminal/Terminal.tsx | 21 +- src/ui/features/terminal/TerminalAiPanel.tsx | 14 + src/ui/features/terminal/TerminalToolbar.tsx | 56 ++-- .../features/terminal/terminal-buffer-text.ts | 47 +++ src/ui/hooks/use-action-slot.ts | 37 +++ src/ui/hooks/use-permissions.ts | 104 ++++++ src/ui/lib/permissions.ts | 31 ++ src/ui/locales/en.json | 3 +- src/ui/shell/ActionSlot.tsx | 82 +++++ src/ui/shell/action-registry.ts | 203 ++++++++++++ src/ui/shell/pluginLoader.ts | 55 ++++ .../terminal/TerminalToolbar.test.tsx | 82 +++++ .../terminal/terminal-buffer-text.test.ts | 99 ++++++ src/ui/tests/lib/permissions.test.ts | 56 ++++ src/ui/tests/shell/action-slot.test.tsx | 298 ++++++++++++++++++ 29 files changed, 1971 insertions(+), 40 deletions(-) create mode 100644 scripts/__fixtures__/manifests/invalid-action-permission.json create mode 100644 scripts/__fixtures__/manifests/valid-actions.json create mode 100644 src/ui/features/terminal/terminal-buffer-text.ts create mode 100644 src/ui/hooks/use-action-slot.ts create mode 100644 src/ui/hooks/use-permissions.ts create mode 100644 src/ui/lib/permissions.ts create mode 100644 src/ui/shell/ActionSlot.tsx create mode 100644 src/ui/shell/action-registry.ts create mode 100644 src/ui/tests/features/terminal/terminal-buffer-text.test.ts create mode 100644 src/ui/tests/lib/permissions.test.ts create mode 100644 src/ui/tests/shell/action-slot.test.tsx diff --git a/plugins/ai/frontend/index.mjs b/plugins/ai/frontend/index.mjs index 9931a9d61..1c4a3d694 100644 --- a/plugins/ai/frontend/index.mjs +++ b/plugins/ai/frontend/index.mjs @@ -13,26 +13,56 @@ * tabUtils.tsx (railView === "ai", case "ai"), the same way the terminal's * tab is baked into Terminal.tsx rather than going through the generic tab * registry. What gates it is isTabTypeAvailable("ai") in - * src/ui/shell/pluginLoader.ts's BUILT_IN_TABS_BY_PLUGIN map, which is - * consulted directly by AppShell.tsx, CommandPalette.tsx and HostItem.tsx -- - * the same live mechanism ssh-terminal's "terminal" entry already uses. + * src/ui/shell/pluginLoader.ts's BUILT_IN_TABS_BY_PLUGIN map. * - * register()/unregister() exist for parity with the other first-party - * plugins' frontend halves. Nothing invokes them yet: there is no frontend - * plugin loader (see src/ui/tests/sidebar/plugin-extension-seam.test.tsx), - * and the "ai" surface does not need registerRailItem/registerTabComponent - * in the first place, since it is gated through BUILT_IN_TABS_BY_PLUGIN - * instead. + * What this file DOES contribute is the terminal toolbar's AI button, through + * the action registry: ai.openWithContext, declared in manifest.json and + * gated on ai.services.use. The button used to be hardwired into + * TerminalToolbar.tsx behind a showAiAssistant prop. + * + * There is still no frontend plugin loader, so nothing imports this file yet + * (see src/ui/tests/sidebar/plugin-extension-seam.test.tsx). Until one exists, + * pluginLoader.ts performs the same registration itself when the ai plugin is + * enabled. This file is the version that runs once a loader lands, and the two + * must be kept in step. */ export const id = "ai"; export const tabId = "ai"; +export const actionId = "ai.openWithContext"; -export async function register() { - // Nothing to register: "ai" is a built-in rail-view/tab gated through - // isTabTypeAvailable, not a runtime-registered surface. See the header. +/** Asks the focused terminal to open its AI panel seeded with `context`. */ +export function openWithContext(context) { + window.dispatchEvent( + new CustomEvent("termix:ai:openWithContext", { + detail: { context: typeof context === "string" ? context : "" }, + }), + ); } -export async function unregister() { - // Nothing to unregister; see register(). +export async function register({ + registerAction, + registerSlotContribution, + icons, +}) { + registerAction(actionId, openWithContext, { + permission: "ai.services.use", + pluginId: id, + }); + + registerSlotContribution("terminal.toolbar", { + actionId, + titleKey: "ai.assistant", + icon: icons.Bot, + kind: "button", + pluginId: id, + }); +} + +export async function unregister({ + unregisterAction, + unregisterSlotContribution, +}) { + unregisterSlotContribution("terminal.toolbar", actionId); + unregisterAction(actionId); } diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index f314ca8e4..e12516546 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -36,7 +36,18 @@ "defaultForRole": { "admin": ["ai.services.use"] } - } + }, + "actions": [ + { + "id": "ai.openWithContext", + "titleKey": "ai.assistant", + "handler": "openWithContext", + "icon": "Bot", + "permission": "ai.services.use", + "slot": "terminal.toolbar", + "kind": "button" + } + ] }, "sidecars": [] } diff --git a/plugins/ssh-terminal/frontend/index.mjs b/plugins/ssh-terminal/frontend/index.mjs index faac4d77d..ea44a68d4 100644 --- a/plugins/ssh-terminal/frontend/index.mjs +++ b/plugins/ssh-terminal/frontend/index.mjs @@ -12,10 +12,12 @@ export const id = "ssh-terminal"; export const tabId = "terminal"; +export const toolbarSlotId = "terminal.toolbar"; export async function register({ registerRailItem, registerTabComponent, + declareActionSlot, icons, }) { registerTabComponent(tabId, () => @@ -30,12 +32,18 @@ export async function register({ labelKey: "nav.terminal", kind: "tab", }); + + // Offers other plugins a place in the terminal toolbar. The terminal does + // not know or care who fills it. + declareActionSlot({ id: toolbarSlotId, accepts: ["button"] }); } export async function unregister({ unregisterRailItem, unregisterTabComponent, + undeclareActionSlot, }) { unregisterRailItem(tabId); unregisterTabComponent(tabId); + undeclareActionSlot(toolbarSlotId); } diff --git a/plugins/ssh-terminal/manifest.json b/plugins/ssh-terminal/manifest.json index 3a41ec934..a8432cd3f 100644 --- a/plugins/ssh-terminal/manifest.json +++ b/plugins/ssh-terminal/manifest.json @@ -24,6 +24,12 @@ "icon": "SquareTerminal", "openFrom": ["rail", "host-context-menu", "palette"] } + ], + "actionSlots": [ + { + "id": "terminal.toolbar", + "accepts": ["button"] + } ] }, "sidecars": [] diff --git a/scripts/__fixtures__/manifests/invalid-action-permission.json b/scripts/__fixtures__/manifests/invalid-action-permission.json new file mode 100644 index 000000000..84b7023ed --- /dev/null +++ b/scripts/__fixtures__/manifests/invalid-action-permission.json @@ -0,0 +1,37 @@ +{ + "id": "sample-bad-action", + "name": "Sample Bad Action", + "version": "1.0.0", + "description": "An action gated by a permission the plugin never declares.", + "author": { + "name": "Jane Doe" + }, + "license": "MIT", + "category": "Productivity", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": { + "backend": false, + "frontend": true, + "electron": false, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["ui.tab"], + "contributes": { + "permissionGroup": { + "group": "samplebadaction", + "permissions": ["samplebadaction.use"] + }, + "actions": [ + { + "id": "samplebadaction.open", + "titleKey": "sampleBadAction.open", + "handler": "open", + "permission": "samplebadaction.never_declared" + } + ] + }, + "sidecars": [] +} diff --git a/scripts/__fixtures__/manifests/valid-actions.json b/scripts/__fixtures__/manifests/valid-actions.json new file mode 100644 index 000000000..c5b4d3034 --- /dev/null +++ b/scripts/__fixtures__/manifests/valid-actions.json @@ -0,0 +1,52 @@ +{ + "id": "sample-actions", + "name": "Sample Actions", + "version": "1.0.0", + "description": "A plugin that contributes a UI action and offers a slot of its own.", + "author": { + "name": "Jane Doe" + }, + "license": "MIT", + "category": "Productivity", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": { + "backend": false, + "frontend": true, + "electron": false, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["ui.tab"], + "contributes": { + "permissionGroup": { + "group": "sampleactions", + "permissions": ["sampleactions.use"] + }, + "actions": [ + { + "id": "sampleactions.openWithContext", + "titleKey": "sampleActions.open", + "handler": "openWithContext", + "icon": "Sparkles", + "permission": "sampleactions.use", + "slot": "terminal.toolbar", + "kind": "button" + }, + { + "id": "sampleactions.ungated", + "titleKey": "sampleActions.ungated", + "handler": "ungated" + } + ], + "actionSlots": [ + { + "id": "sampleactions.panel", + "accepts": ["button"], + "descriptionKey": "sampleActions.panelDescription" + } + ] + }, + "sidecars": [] +} diff --git a/scripts/plugin-manifest.schema.json b/scripts/plugin-manifest.schema.json index 4c3379f50..2daa33331 100644 --- a/scripts/plugin-manifest.schema.json +++ b/scripts/plugin-manifest.schema.json @@ -207,6 +207,63 @@ "type": "string", "pattern": "^[a-z0-9-]+$", "description": "Mounted at /plugin-api///*." + }, + "actions": { + "type": "array", + "description": "Named frontend actions this plugin exposes for other plugins to invoke.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "titleKey", "handler"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$", + "description": "Dotted, globally unique action name, e.g. \"ai.openWithContext\"." + }, + "titleKey": { "type": "string", "minLength": 1 }, + "handler": { + "type": "string", + "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$", + "description": "Name of the frontend export implementing this action. The runtime binding is registerAction(id, fn)." + }, + "icon": { "type": "string", "minLength": 1 }, + "permission": { + "type": "string", + "minLength": 1, + "description": "Role permission a user needs. Must also appear in contributes.permissionGroup.permissions." + }, + "slot": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$", + "description": "Slot this action is intended for. Advisory; binding happens at runtime." + }, + "kind": { "type": "string", "enum": ["button"] } + } + } + }, + "actionSlots": { + "type": "array", + "description": "Places this plugin offers other plugins to contribute a UI element.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "accepts"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$", + "description": "Dotted slot name, e.g. \"terminal.toolbar\"." + }, + "accepts": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "enum": ["button"] }, + "description": "Contribution kinds this slot will render. Checked in the frontend at registration time." + }, + "descriptionKey": { "type": "string", "minLength": 1 } + } + } } } }, diff --git a/scripts/validate-plugin-manifest.cjs b/scripts/validate-plugin-manifest.cjs index 3d95e5524..41aa3f131 100644 --- a/scripts/validate-plugin-manifest.cjs +++ b/scripts/validate-plugin-manifest.cjs @@ -18,7 +18,12 @@ const ID_PATTERN = /^[a-z0-9-]+$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; const API_VERSION_PATTERN = /^[0-9]+$/; const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; +// Dotted like a service name, but segments may be camelCase: these name a +// frontend function ("ai.openWithContext"), not a lowercase service contract. +const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/; +const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"]; +const ACTION_CONTRIBUTION_KINDS = ["button"]; function loadSchema() { const raw = fs.readFileSync(SCHEMA_PATH, "utf8"); @@ -145,6 +150,20 @@ function validateManifest(manifest, schema) { } } + // Same rule for UI actions: a permission the catalog never sees is one no + // admin can grant, so the action would be invisible rather than denied. + const actions = manifest.contributes?.actions; + if (Array.isArray(actions)) { + for (const action of actions) { + if (!action || !action.permission) continue; + if (!Array.isArray(declared) || !declared.includes(action.permission)) { + errors.push( + `Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`, + ); + } + } + } + return errors; } @@ -305,6 +324,130 @@ function validateContributes(contributes) { } } + if ("actions" in contributes) { + errors.push(...validateActions(contributes.actions)); + } + + if ("actionSlots" in contributes) { + errors.push(...validateActionSlots(contributes.actionSlots)); + } + + return errors; +} + +function validateActions(actions) { + if (!Array.isArray(actions)) { + return ['Field "contributes.actions" must be an array']; + } + + const errors = []; + const seen = new Set(); + + actions.forEach((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`contributes.actions[${index}] must be an object`); + return; + } + + if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) { + errors.push( + `contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`, + ); + } else if (seen.has(entry.id)) { + errors.push( + `contributes.actions[${index}].id is a duplicate: "${entry.id}"`, + ); + } else { + seen.add(entry.id); + } + + if (typeof entry.titleKey !== "string" || entry.titleKey.length === 0) { + errors.push(`contributes.actions[${index}].titleKey is required`); + } + + if ( + typeof entry.handler !== "string" || + !HANDLER_PATTERN.test(entry.handler) + ) { + errors.push( + `contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${entry.handler}"`, + ); + } + + if ("icon" in entry && typeof entry.icon !== "string") { + errors.push(`contributes.actions[${index}].icon must be a string`); + } + + if ("permission" in entry && typeof entry.permission !== "string") { + errors.push(`contributes.actions[${index}].permission must be a string`); + } + + if ( + "slot" in entry && + (typeof entry.slot !== "string" || !ACTION_ID_PATTERN.test(entry.slot)) + ) { + errors.push( + `contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${entry.slot}"`, + ); + } + + if ("kind" in entry && !ACTION_CONTRIBUTION_KINDS.includes(entry.kind)) { + errors.push( + `contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${entry.kind}"`, + ); + } + }); + + return errors; +} + +function validateActionSlots(slots) { + if (!Array.isArray(slots)) { + return ['Field "contributes.actionSlots" must be an array']; + } + + const errors = []; + const seen = new Set(); + + slots.forEach((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`contributes.actionSlots[${index}] must be an object`); + return; + } + + if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) { + errors.push( + `contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`, + ); + } else if (seen.has(entry.id)) { + errors.push( + `contributes.actionSlots[${index}].id is a duplicate: "${entry.id}"`, + ); + } else { + seen.add(entry.id); + } + + if (!Array.isArray(entry.accepts) || entry.accepts.length === 0) { + errors.push( + `contributes.actionSlots[${index}].accepts must be a non-empty array`, + ); + } else { + for (const kind of entry.accepts) { + if (!ACTION_CONTRIBUTION_KINDS.includes(kind)) { + errors.push( + `contributes.actionSlots[${index}].accepts has unknown value: "${kind}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`, + ); + } + } + } + + if ("descriptionKey" in entry && typeof entry.descriptionKey !== "string") { + errors.push( + `contributes.actionSlots[${index}].descriptionKey must be a string`, + ); + } + }); + return errors; } diff --git a/scripts/validate-plugin-manifest.test.ts b/scripts/validate-plugin-manifest.test.ts index a3f10717a..198d8e083 100644 --- a/scripts/validate-plugin-manifest.test.ts +++ b/scripts/validate-plugin-manifest.test.ts @@ -50,6 +50,18 @@ describe("validate-plugin-manifest.cjs", () => { expect(output).toContain("sampleprovider.undeclared"); }); + it("accepts a manifest declaring actions and actionSlots", () => { + const { status, output } = runValidator("valid-actions.json"); + expect(status).toBe(0); + expect(output).toContain("Valid plugin manifest"); + }); + + it("rejects an action gated by an undeclared permission", () => { + const { status, output } = runValidator("invalid-action-permission.json"); + expect(status).not.toBe(0); + expect(output).toContain("samplebadaction.never_declared"); + }); + it("rejects a manifest with an unknown permission", () => { const { status, output } = runValidator("invalid-unknown-permission.json"); expect(status).not.toBe(0); diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index fbcc2ead4..de5380fe4 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -1242,6 +1242,48 @@ router.get( }, ); +/** + * @openapi + * /rbac/permissions/me: + * get: + * summary: Get the current user's effective role permissions + * description: > + * Returns the permission strings granted to the authenticated user + * through their roles, plus whether they are an admin. The frontend uses + * this to hide UI a user could not use. It is not a security boundary: + * every protected route checks again server side. + * tags: + * - RBAC + * responses: + * 200: + * description: The current user's permissions. + * 401: + * description: Not authenticated. + */ +router.get( + "/permissions/me", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const userId = req.userId!; + try { + // isAdmin travels with the list because hasPermission() falls back to it + // on the deny path. Without it the UI would hide things from an admin + // that the server would allow. + const [permissions, isAdmin] = await Promise.all([ + permissionManager.getUserPermissions(userId), + permissionManager.isAdmin(userId), + ]); + res.json({ permissions, isAdmin }); + } catch (error) { + databaseLogger.error("Failed to get current user permissions", error, { + operation: "get_my_permissions", + userId, + }); + res.status(500).json({ error: "Failed to get permissions" }); + } + }, +); + /** * @openapi * /rbac/roles/{id}: diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index 0ca8eb655..93285c448 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -52,6 +52,15 @@ export const PLUGIN_CATEGORIES = [ const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"]; +/** + * Contribution kinds a UI action slot can accept. Closed on purpose: a + * free-form list would validate nothing. Adding a kind is a one-line edit + * here and in the two other validators. + */ +export const ACTION_CONTRIBUTION_KINDS = ["button"] as const; + +export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number]; + const REQUIRED_TOP_LEVEL = [ "id", "name", @@ -72,6 +81,14 @@ const API_VERSION_PATTERN = /^[0-9]+$/; const API_PREFIX_PATTERN = /^[a-z0-9-]+$/; /** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */ const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; +/** + * Action and slot ids are dotted like service names, but each segment may be + * camelCase: these name a frontend function ("ai.openWithContext"), not a + * lowercase service contract, so SERVICE_PATTERN is too strict to reuse. + */ +const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/; +/** A handler names a JS export, so it follows identifier rules, not dotted ones. */ +const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; /** * The plugin SDK major version this build implements. A manifest asking for a @@ -114,6 +131,10 @@ export interface PluginManifest { settingsPanel?: { titleKey: string }; dashboardCards?: Array<{ id: string; titleKey: string }>; apiPrefix?: string; + /** Named frontend actions this plugin exposes for other plugins to invoke. */ + actions?: PluginActionContribution[]; + /** Places this plugin offers other plugins to contribute a UI element. */ + actionSlots?: PluginActionSlot[]; }; dependencies?: { plugins?: Record }; /** @@ -141,6 +162,49 @@ export interface PluginServiceRequire { optional?: boolean; } +/** + * A named frontend action, declared here and bound at runtime. + * + * The split matters: this manifest entry is inert JSON the server can read and + * audit without running any plugin code, while the function itself is supplied + * in the browser by registerAction(id, fn). `handler` records which frontend + * export is meant to end up under `id`, so the contract is readable from the + * manifest alone and a future frontend loader can wire module[handler] + * automatically. Today nothing reads `handler` at runtime. + */ +export interface PluginActionContribution { + /** Dotted and globally unique, e.g. "ai.openWithContext". */ + id: string; + titleKey: string; + /** Name of the frontend export implementing this action. */ + handler: string; + /** Lucide icon name, same convention as contributes.tabs[].icon. */ + icon?: string; + /** + * Role permission the acting user needs before the action is offered. + * Optional: an ungated action renders for everyone. When set, parseManifest + * requires it to be declared in this plugin's own permissionGroup. + */ + permission?: string; + /** Slot this action is intended for. Advisory; binding happens at runtime. */ + slot?: string; + kind?: ActionContributionKind; +} + +/** + * A place this plugin offers other plugins to contribute a UI element. + * + * A contribution's `kind` is checked against `accepts` in the frontend at + * registration time, not here: the slot is usually declared by a different + * plugin, and a manifest is validated on its own. + */ +export interface PluginActionSlot { + /** Dotted, e.g. "terminal.toolbar". */ + id: string; + accepts: ActionContributionKind[]; + descriptionKey?: string; +} + export function validateManifest(manifest: unknown): string[] { const errors: string[] = []; @@ -414,6 +478,133 @@ function validateContributes(contributes: Record): string[] { } } + if ("actions" in contributes) { + errors.push(...validateActions(contributes.actions)); + } + + if ("actionSlots" in contributes) { + errors.push(...validateActionSlots(contributes.actionSlots)); + } + + return errors; +} + +function validateActions(actions: unknown): string[] { + if (!Array.isArray(actions)) { + return ['Field "contributes.actions" must be an array']; + } + + const errors: string[] = []; + const seen = new Set(); + + actions.forEach((entry: unknown, index: number) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`contributes.actions[${index}] must be an object`); + return; + } + const e = entry as Record; + + if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) { + errors.push( + `contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`, + ); + } else if (seen.has(e.id)) { + errors.push(`contributes.actions[${index}].id is a duplicate: "${e.id}"`); + } else { + seen.add(e.id); + } + + if (typeof e.titleKey !== "string" || e.titleKey.length === 0) { + errors.push(`contributes.actions[${index}].titleKey is required`); + } + + if (typeof e.handler !== "string" || !HANDLER_PATTERN.test(e.handler)) { + errors.push( + `contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${String(e.handler)}"`, + ); + } + + if ("icon" in e && (typeof e.icon !== "string" || e.icon.length === 0)) { + errors.push(`contributes.actions[${index}].icon must be a string`); + } + + if ( + "permission" in e && + (typeof e.permission !== "string" || e.permission.length === 0) + ) { + errors.push(`contributes.actions[${index}].permission must be a string`); + } + + if ( + "slot" in e && + (typeof e.slot !== "string" || !ACTION_ID_PATTERN.test(e.slot)) + ) { + errors.push( + `contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${String(e.slot)}"`, + ); + } + + if ("kind" in e && !ACTION_CONTRIBUTION_KINDS.includes(e.kind as never)) { + errors.push( + `contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${String(e.kind)}"`, + ); + } + }); + + return errors; +} + +function validateActionSlots(slots: unknown): string[] { + if (!Array.isArray(slots)) { + return ['Field "contributes.actionSlots" must be an array']; + } + + const errors: string[] = []; + const seen = new Set(); + + slots.forEach((entry: unknown, index: number) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`contributes.actionSlots[${index}] must be an object`); + return; + } + const e = entry as Record; + + if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) { + errors.push( + `contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`, + ); + } else if (seen.has(e.id)) { + errors.push( + `contributes.actionSlots[${index}].id is a duplicate: "${e.id}"`, + ); + } else { + seen.add(e.id); + } + + if (!Array.isArray(e.accepts) || e.accepts.length === 0) { + errors.push( + `contributes.actionSlots[${index}].accepts must be a non-empty array`, + ); + } else { + for (const kind of e.accepts) { + if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) { + errors.push( + `contributes.actionSlots[${index}].accepts has unknown value: "${String(kind)}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`, + ); + } + } + } + + if ( + "descriptionKey" in e && + (typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0) + ) { + errors.push( + `contributes.actionSlots[${index}].descriptionKey must be a string`, + ); + } + }); + return errors; } @@ -453,6 +644,19 @@ export function parseManifest(raw: unknown): { } } + // Same rule for UI actions, for the same reason: a permission the catalog + // never sees is one no admin can grant, so the action would be invisible + // forever rather than merely denied. + for (const action of manifest.contributes?.actions ?? []) { + if (action.permission && !declaredPermissions.includes(action.permission)) { + return { + errors: [ + `Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`, + ], + }; + } + } + // Refused outright rather than ignored: a plugin that asked to run in-process // and was quietly downgraded to a worker would fail later in a confusing way, // and the manifest would still claim reach it does not have. diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 77568e63e..091f96f07 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -188,7 +188,173 @@ describe("plugin manifest validation", () => { ).toBe(true); }); + describe("actions / actionSlots", () => { + function withActions(contributes: Record) { + return validateManifest(validManifest({ contributes })); + } + + it("accepts a valid actions and actionSlots pair", () => { + const errors = withActions({ + actions: [ + { + id: "ai.openWithContext", + titleKey: "ai.assistant", + handler: "openWithContext", + icon: "Bot", + slot: "terminal.toolbar", + kind: "button", + }, + ], + actionSlots: [{ id: "terminal.toolbar", accepts: ["button"] }], + }); + expect(errors).toEqual([]); + }); + + it("allows a camelCase segment in an action id", () => { + const errors = withActions({ + actions: [ + { + id: "ai.openWithContext", + titleKey: "k", + handler: "openWithContext", + }, + ], + }); + expect(errors).toEqual([]); + }); + + it("rejects an action id that is not dotted", () => { + const errors = withActions({ + actions: [{ id: "notdotted", titleKey: "k", handler: "h" }], + }); + expect(errors.some((e) => e.includes("actions[0].id must match"))).toBe( + true, + ); + }); + + it("rejects a duplicate action id", () => { + const errors = withActions({ + actions: [ + { id: "a.one", titleKey: "k", handler: "h" }, + { id: "a.one", titleKey: "k", handler: "h" }, + ], + }); + expect( + errors.some((e) => e.includes('actions[1].id is a duplicate: "a.one"')), + ).toBe(true); + }); + + it("requires titleKey and a valid handler", () => { + const errors = withActions({ + actions: [{ id: "a.one", handler: "not a identifier" }], + }); + expect(errors.some((e) => e.includes("actions[0].titleKey"))).toBe(true); + expect(errors.some((e) => e.includes("actions[0].handler"))).toBe(true); + }); + + it("rejects an unknown contribution kind", () => { + const errors = withActions({ + actions: [ + { id: "a.one", titleKey: "k", handler: "h", kind: "dropdown" }, + ], + }); + expect( + errors.some((e) => e.includes("actions[0].kind must be one of")), + ).toBe(true); + }); + + it("rejects an unknown value in a slot's accepts", () => { + const errors = withActions({ + actionSlots: [{ id: "terminal.toolbar", accepts: ["menu"] }], + }); + expect( + errors.some((e) => + e.includes('actionSlots[0].accepts has unknown value: "menu"'), + ), + ).toBe(true); + }); + + it("rejects an empty accepts array", () => { + const errors = withActions({ + actionSlots: [{ id: "terminal.toolbar", accepts: [] }], + }); + expect( + errors.some((e) => + e.includes("actionSlots[0].accepts must be a non-empty array"), + ), + ).toBe(true); + }); + + it("rejects a duplicate slot id", () => { + const errors = withActions({ + actionSlots: [ + { id: "terminal.toolbar", accepts: ["button"] }, + { id: "terminal.toolbar", accepts: ["button"] }, + ], + }); + expect( + errors.some((e) => e.includes("actionSlots[1].id is a duplicate")), + ).toBe(true); + }); + }); + describe("parseManifest", () => { + it("refuses an action gated by an undeclared permission", () => { + const { manifest, errors } = parseManifest( + validManifest({ + contributes: { + permissionGroup: { group: "ai", permissions: ["ai.use"] }, + actions: [ + { + id: "ai.openWithContext", + titleKey: "k", + handler: "h", + permission: "ai.services.use", + }, + ], + }, + }), + ); + expect(manifest).toBeUndefined(); + expect(errors[0]).toContain( + 'Action "ai.openWithContext" is gated by "ai.services.use"', + ); + }); + + it("allows an action gated by a declared permission", () => { + const { manifest, errors } = parseManifest( + validManifest({ + contributes: { + permissionGroup: { + group: "ai", + permissions: ["ai.services.use"], + }, + actions: [ + { + id: "ai.openWithContext", + titleKey: "k", + handler: "h", + permission: "ai.services.use", + }, + ], + }, + }), + ); + expect(errors).toEqual([]); + expect(manifest?.contributes?.actions?.[0].id).toBe("ai.openWithContext"); + }); + + it("allows an ungated action with no permissionGroup at all", () => { + const { errors } = parseManifest( + validManifest({ + contributes: { + actions: [{ id: "a.one", titleKey: "k", handler: "h" }], + }, + }), + ); + expect(errors).toEqual([]); + }); + it("narrows a valid manifest", () => { const { manifest, errors } = parseManifest(validManifest()); expect(errors).toEqual([]); diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 0015130d0..e3791996e 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -27,6 +27,7 @@ import { import { createPortal } from "react-dom"; import { useIsMobile } from "@/hooks/use-mobile"; import { useAiAvailability } from "@/hooks/use-ai-availability"; +import { resetPermissionsCache } from "@/hooks/use-permissions"; import { MobileBottomBar } from "@/shell/MobileBottomBar"; import { AppRail, type RailView } from "@/sidebar/AppRail"; import { @@ -967,6 +968,8 @@ export function AppShell({ const manual = event instanceof CustomEvent && (event.detail as { manual?: boolean } | undefined)?.manual === true; + // Drop the cached grants so the next user never inherits them. + resetPermissionsCache(); onLogout(manual ? { manual: true } : undefined); }; window.addEventListener("termix:logout", handle); diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index 31d92c3fc..d3f2ca22c 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -54,6 +54,13 @@ export async function updateRole( ): Promise<{ role: Role }> { try { const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData); + if (roleData.permissions) { + // The editing admin may hold this role, so re-read our own grants + // rather than leaving gated UI stale until the next reload. + const { notifyPermissionsChanged } = + await import("@/hooks/use-permissions"); + notifyPermissionsChanged(); + } return response.data; } catch (error) { throw handleApiError(error, "update role"); @@ -277,6 +284,21 @@ export async function getPermissionsCatalog(): Promise<{ } } +export interface MyPermissions { + permissions: string[]; + isAdmin: boolean; +} + +/** The current user's own grants, for hiding UI they could not use. */ +export async function getMyPermissions(): Promise { + try { + const response = await rbacApi.get("/rbac/permissions/me"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch permissions"); + } +} + export async function getSharedHosts(): Promise<{ sharedHosts: Array<{ id: number; diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 3df6e614b..4dacc3156 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -55,6 +55,7 @@ import { resolveTerminalFontFamily, } from "@/lib/terminal-themes.ts"; import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts"; +import { getTerminalBufferText } from "./terminal-buffer-text.ts"; import { useTheme } from "@/components/theme-provider.tsx"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { getMacLineNavigationSequence } from "@/lib/mac-line-navigation"; @@ -463,6 +464,7 @@ const TerminalInner = forwardRef( const [autosuggestionStyle, setAutosuggestionStyle] = useState({}); const [aiAssistantOpen, setAiAssistantOpen] = useState(false); + const [aiAssistantContext, setAiAssistantContext] = useState(""); const { userEnabled: aiAssistantEnabledForUser } = useAiAvailability(); const isAiAssistantAvailable = aiAssistantEnabledForUser && host?.enableAiAssistant === true; @@ -733,6 +735,20 @@ const TerminalInner = forwardRef( setAiAssistantOpen((open) => !open); }, []); + // A plugin-contributed action (the toolbar's AI button) asks for the panel + // this way, so the contribution needs no reference into the terminal. + useEffect(() => { + if (!isFocusedPane) return; + const open = (event: Event) => { + const detail = (event as CustomEvent<{ context?: string }>).detail; + setAiAssistantContext(detail?.context ?? ""); + setAiAssistantOpen(true); + }; + window.addEventListener("termix:ai:openWithContext", open); + return () => + window.removeEventListener("termix:ai:openWithContext", open); + }, [isFocusedPane]); + const closeAiAssistant = useCallback(() => { setAiAssistantOpen(false); setTimeout(() => terminal?.focus(), 50); @@ -3844,8 +3860,8 @@ const TerminalInner = forwardRef( } }} isFocused={isFocusedPane} - showAiAssistant={isAiAssistantAvailable} - onToggleAiAssistant={toggleAiAssistant} + actionsEnabled={isAiAssistantAvailable} + getBufferText={() => getTerminalBufferText(terminal)} /> )} @@ -3854,6 +3870,7 @@ const TerminalInner = forwardRef( hostLabel={`${hostConfig.username}@${hostConfig.name || hostConfig.ip}`} hostId={hostConfig.id} activeTab={`terminal:${hostConfig.name || hostConfig.ip}`} + initialContext={aiAssistantContext} onClose={closeAiAssistant} onRunInTerminal={handleRunCommandInTerminal} /> diff --git a/src/ui/features/terminal/TerminalAiPanel.tsx b/src/ui/features/terminal/TerminalAiPanel.tsx index e5182ffdd..5c3e5a26e 100644 --- a/src/ui/features/terminal/TerminalAiPanel.tsx +++ b/src/ui/features/terminal/TerminalAiPanel.tsx @@ -43,6 +43,8 @@ interface TerminalAiPanelProps { hostLabel: string; hostId: number; activeTab?: string | null; + /** Terminal output to open with, from a contributed action. */ + initialContext?: string; onClose: () => void; onRunInTerminal: (command: string) => void; } @@ -57,6 +59,7 @@ export function TerminalAiPanel({ hostLabel, hostId, activeTab, + initialContext, onClose, onRunInTerminal, }: TerminalAiPanelProps) { @@ -73,6 +76,17 @@ export function TerminalAiPanel({ >({}); const conversationIdRef = useRef(null); const scrollRef = useRef(null); + + // Prefilled rather than sent: the user sees exactly what the terminal is + // about to hand the model, and can trim it or add a question first. + useEffect(() => { + if (!initialContext) return; + setInput((current) => + current + ? current + : t("ai.terminalContextPrefill", { output: initialContext }), + ); + }, [initialContext, t]); const textareaRef = useRef(null); const [position, setPosition] = useState( diff --git a/src/ui/features/terminal/TerminalToolbar.tsx b/src/ui/features/terminal/TerminalToolbar.tsx index b3dd3ca7c..741169e2a 100644 --- a/src/ui/features/terminal/TerminalToolbar.tsx +++ b/src/ui/features/terminal/TerminalToolbar.tsx @@ -7,7 +7,6 @@ import React, { } from "react"; import { useTranslation } from "react-i18next"; import { - Bot, ChevronDown, ClipboardPaste, GripVertical, @@ -26,6 +25,8 @@ import { } from "@/components/select"; import { cn } from "@/lib/utils"; +import { ActionSlot } from "@/shell/ActionSlot"; +import { useActionSlot } from "@/hooks/use-action-slot"; import { useIsMobile } from "@/hooks/use-mobile"; import { getSshActions } from "@/sidebar/tree/HostItem/HostItem"; import { @@ -50,6 +51,9 @@ type SelectedToolbarDensity = ToolbarDensity; const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density"; +/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */ +const TOOLBAR_SLOT_ID = "terminal.toolbar"; + const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [ { value: "icon" }, { value: "labeled" }, @@ -120,8 +124,14 @@ interface TerminalToolbarProps { onOpenTab?: (type: TabType) => void; onOpenFiles?: () => void; isFocused: boolean; - showAiAssistant?: boolean; - onToggleAiAssistant?: () => void; + /** + * Whether this host offers the toolbar's contributed actions at all. The + * slot itself is generic; the host-level AI flag is not something a + * contributing plugin can know about. + */ + actionsEnabled?: boolean; + /** Read at click time by contributions that want the visible scrollback. */ + getBufferText?: () => string; } export const TerminalToolbar: React.FC = ({ @@ -136,10 +146,13 @@ export const TerminalToolbar: React.FC = ({ onOpenTab, onOpenFiles, isFocused, - showAiAssistant, - onToggleAiAssistant, + actionsEnabled = true, + getBufferText, }) => { const { t } = useTranslation(); + // Drives both the measurement copy and the separator, so the toolbar sizes + // itself correctly whether or not anything contributed. + const slotContributions = useActionSlot(TOOLBAR_SLOT_ID); const [density, setDensity] = useState(readStoredDensity); const [responsiveDensity, setResponsiveDensity] = @@ -261,7 +274,8 @@ export const TerminalToolbar: React.FC = ({ isConnected, isMobile, isTmuxAttached, - showAiAssistant, + actionsEnabled, + slotContributions, t, ]); @@ -745,12 +759,13 @@ export const TerminalToolbar: React.FC = ({ {density !== "icon" && t("terminalToolbar.paste")} - {showAiAssistant && ( - - - {density !== "icon" && t("ai.assistant")} - - )} + {actionsEnabled && + slotContributions.map((contribution) => ( + + {contribution.icon && } + {density !== "icon" && t(contribution.titleKey)} + + ))} @@ -832,19 +847,16 @@ export const TerminalToolbar: React.FC = ({ > {imageButtons} - {showAiAssistant && ( + {actionsEnabled && slotContributions.length > 0 && ( <>
- + enabled={actionsEnabled} + hideLabels={effectiveDensity === "icon"} + context={() => [getBufferText?.() ?? ""]} + /> )}
diff --git a/src/ui/features/terminal/terminal-buffer-text.ts b/src/ui/features/terminal/terminal-buffer-text.ts new file mode 100644 index 000000000..9326c29e2 --- /dev/null +++ b/src/ui/features/terminal/terminal-buffer-text.ts @@ -0,0 +1,47 @@ +import type { Terminal } from "@xterm/xterm"; + +/** + * How much scrollback to hand out by default. Bounded on purpose: a + * long-running session holds megabytes, and nothing consuming this wants all + * of it. + */ +export const DEFAULT_BUFFER_CONTEXT_LINES = 200; + +/** + * Serializes the tail of a terminal's buffer to plain text. + * + * Wrapped rows are joined back onto the line they continue. xterm splits one + * logical line across several buffer rows when it wraps, so emitting a newline + * per row would chop long commands in half. + */ +export function getTerminalBufferText( + terminal: Terminal | null | undefined, + maxLines: number = DEFAULT_BUFFER_CONTEXT_LINES, +): string { + if (!terminal || maxLines <= 0) return ""; + + try { + const buffer = terminal.buffer.active; + const end = buffer.baseY + terminal.rows; + const start = Math.max(0, end - maxLines); + + const lines: string[] = []; + for (let i = start; i < end; i++) { + const line = buffer.getLine(i); + if (!line) continue; + const text = line.translateToString(true); + if (line.isWrapped && lines.length > 0) { + lines[lines.length - 1] += text; + } else { + lines.push(text); + } + } + + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines.join("\n"); + } catch { + // Called from a click handler. A disposed terminal returns nothing + // rather than breaking the click. + return ""; + } +} diff --git a/src/ui/hooks/use-action-slot.ts b/src/ui/hooks/use-action-slot.ts new file mode 100644 index 000000000..87d11fac1 --- /dev/null +++ b/src/ui/hooks/use-action-slot.ts @@ -0,0 +1,37 @@ +import { useMemo, useSyncExternalStore } from "react"; +import { + getSlotContributions, + subscribeToActionRegistry, + getActionPermission, + type SlotContribution, +} from "@/shell/action-registry"; +import { usePermissions } from "@/hooks/use-permissions"; + +/** + * The contributions in a slot that the current user may actually see. + * + * A slot owner calls this without knowing which plugins contributed, or + * whether any did. Contributions the user lacks the permission for are + * dropped entirely rather than disabled, which is how admin-gated UI already + * behaves elsewhere in the app. + */ +export function useActionSlot(slotId: string): SlotContribution[] { + const contributions = useSyncExternalStore( + subscribeToActionRegistry, + () => getSlotContributions(slotId), + () => getSlotContributions(slotId), + ); + + const { has, loaded } = usePermissions(); + + return useMemo(() => { + // Render nothing until the grants are known. A button that appears and + // then vanishes is worse than one that appears a moment late. + if (!loaded) return []; + return contributions.filter((contribution) => { + const permission = + getActionPermission(contribution.actionId) ?? undefined; + return !permission || has(permission); + }); + }, [contributions, has, loaded]); +} diff --git a/src/ui/hooks/use-permissions.ts b/src/ui/hooks/use-permissions.ts new file mode 100644 index 000000000..1b41c37ad --- /dev/null +++ b/src/ui/hooks/use-permissions.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useState } from "react"; +import { matchesPermission } from "@/lib/permissions"; + +export interface PermissionsState { + permissions: string[]; + isAdmin: boolean; + /** False until the call answers, so gated UI does not flash in or out. */ + loaded: boolean; + has: (permission: string) => boolean; +} + +/** Fired when a user's grants may have changed, so every surface re-reads them. */ +export const PERMISSIONS_CHANGED_EVENT = "permissionsChanged"; + +interface CachedPermissions { + permissions: string[]; + isAdmin: boolean; +} + +/** + * Shared across every hook instance. A terminal per split pane would otherwise + * fire one identical request each. + */ +let cache: CachedPermissions | null = null; +let inFlight: Promise | null = null; + +function load(): Promise { + if (cache) return Promise.resolve(cache); + if (inFlight) return inFlight; + + inFlight = import("@/api/rbac-api") + .then(({ getMyPermissions }) => getMyPermissions()) + .then((result) => { + cache = { + permissions: result.permissions ?? [], + isAdmin: !!result.isAdmin, + }; + return cache; + }) + .catch(() => { + // Deny by default. A failed lookup hides gated UI rather than showing + // something the server would refuse anyway. + cache = { permissions: [], isAdmin: false }; + return cache; + }) + .finally(() => { + inFlight = null; + }); + + return inFlight; +} + +export function notifyPermissionsChanged(): void { + cache = null; + window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT)); +} + +/** + * The current user's grants. + * + * Freshness is eventually consistent: the backend caches per user with a TTL + * and this caches until notifyPermissionsChanged fires, so a revoke can leave + * a stale button on screen briefly. That is acceptable because the button is + * not the boundary - clicking it hits a route that checks again and refuses. + */ +export function usePermissions(): PermissionsState { + const [state, setState] = useState( + () => + cache + ? { ...cache, loaded: true } + : { permissions: [], isAdmin: false, loaded: false }, + ); + + useEffect(() => { + let cancelled = false; + + const refresh = () => { + load().then((result) => { + if (!cancelled) setState({ ...result, loaded: true }); + }); + }; + + refresh(); + window.addEventListener(PERMISSIONS_CHANGED_EVENT, refresh); + return () => { + cancelled = true; + window.removeEventListener(PERMISSIONS_CHANGED_EVENT, refresh); + }; + }, []); + + const has = useCallback( + (permission: string) => + matchesPermission(state.permissions, state.isAdmin, permission), + [state.permissions, state.isAdmin], + ); + + return { ...state, has }; +} + +/** Test seam. */ +export function resetPermissionsCache(): void { + cache = null; + inFlight = null; +} diff --git a/src/ui/lib/permissions.ts b/src/ui/lib/permissions.ts new file mode 100644 index 000000000..6b5216b45 --- /dev/null +++ b/src/ui/lib/permissions.ts @@ -0,0 +1,31 @@ +/** + * Frontend copy of the permission matching rules. + * + * This mirrors PermissionManager.hasPermission in + * src/backend/utils/permission-manager.ts and has to stay in step with it. It + * is a second implementation of one rule, which is a real cost, accepted here + * only because this copy is cosmetic: it decides what UI to show, never what + * a request is allowed to do. The server checks again on every route. + */ + +/** + * Whether a granted permission set covers the one required. + * + * Order matches the backend: the "*" superuser grant, an exact match, then + * progressively shorter dotted wildcards, then admin last. + */ +export function matchesPermission( + granted: readonly string[], + isAdmin: boolean, + required: string, +): boolean { + if (granted.includes("*")) return true; + if (granted.includes(required)) return true; + + const parts = required.split("."); + for (let i = parts.length; i > 0; i--) { + if (granted.includes(parts.slice(0, i).join(".") + ".*")) return true; + } + + return isAdmin; +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index a9204eeb5..5df7bfa37 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -5048,7 +5048,8 @@ "terminalPanelPlaceholder": "Ask about this host...", "noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.", "runsInOpenTerminal": "Runs in your open terminal session.", - "runInTerminal": "Run" + "runInTerminal": "Run", + "terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n" }, "macros": { "title": "Macros", diff --git a/src/ui/shell/ActionSlot.tsx b/src/ui/shell/ActionSlot.tsx new file mode 100644 index 000000000..ab870aa58 --- /dev/null +++ b/src/ui/shell/ActionSlot.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useActionSlot } from "@/hooks/use-action-slot"; +import { invokeAction, type SlotContribution } from "@/shell/action-registry"; + +interface ActionSlotProps { + slotId: string; + /** + * Arguments for the action, read at click time rather than on render. A + * getter keeps expensive context (a serialized terminal buffer, say) off the + * render path. + */ + context?: () => unknown[]; + /** Applied to the default button so a slot owner can match its own controls. */ + className?: string; + /** Hides the whole slot. For a surface that is contextually unavailable. */ + enabled?: boolean; + /** Renders a contribution the owner's own way instead of as a button. */ + renderItem?: ( + contribution: SlotContribution, + invoke: () => void, + ) => React.ReactNode; + /** Icon-only, for toolbars that collapse their labels. */ + hideLabels?: boolean; +} + +/** + * Renders whatever plugins have contributed to a named slot. + * + * Renders null when there is nothing to show, which covers both "no plugin + * contributed" and "the user may not see what was contributed". Those two + * cases are deliberately identical in the DOM: no placeholder, no disabled + * button, nothing hinting at a feature the user cannot reach. + */ +export function ActionSlot({ + slotId, + context, + className, + enabled = true, + renderItem, + hideLabels, +}: ActionSlotProps) { + const { t } = useTranslation(); + const contributions = useActionSlot(slotId); + + if (!enabled || contributions.length === 0) return null; + + return ( + <> + {contributions.map((contribution) => { + const invoke = () => { + void invokeAction(contribution.actionId, ...(context?.() ?? [])); + }; + + if (renderItem) { + return ( + + {renderItem(contribution, invoke)} + + ); + } + + const Icon = contribution.icon; + const label = t(contribution.titleKey); + + return ( + + ); + })} + + ); +} diff --git a/src/ui/shell/action-registry.ts b/src/ui/shell/action-registry.ts new file mode 100644 index 000000000..4f95f7c83 --- /dev/null +++ b/src/ui/shell/action-registry.ts @@ -0,0 +1,203 @@ +/** + * Runtime registry for plugin-contributed UI actions and the slots they fill. + * + * This is the frontend sibling of src/backend/plugins/service-registry.ts, and + * the difference between them is the thing to be clear about: + * + * - The backend registry's proxy IS a boundary. It checks the acting user's + * RBAC permission on every call before the provider ever runs. + * - This one is NOT. It runs in the browser, where anyone can call + * invokeAction from the console. The permission attached to an action + * decides whether a button is DRAWN, nothing more. + * + * Real enforcement stays where it already is: the backend route the handler + * eventually calls. Treating this file as a security boundary is the easy + * mistake to make when reading the two side by side, so it is said plainly + * here rather than left to be inferred. + * + * Unlike the rail and tab registries (rail-items.ts, tabUtils.tsx), which are + * plain Maps read during render, this one is reactive. A slot renders live and + * has to react when a plugin is enabled or disabled at runtime, so it carries + * subscribe/getSnapshot plumbing for useSyncExternalStore. + */ + +import type { LucideIcon } from "lucide-react"; + +/** + * Args are unknown[] because the registry never inspects them: a slot owner + * passes whatever its surface offers (the terminal passes its buffer text) and + * the contributing plugin knows what to expect. Handlers are written with + * concrete parameter types, so registerAction accepts those too. + */ +export type ActionHandler = (...args: never[]) => unknown; + +export interface RegisteredAction { + id: string; + handler: ActionHandler; + /** Role permission a user needs before this action is offered. */ + permission?: string; + pluginId?: string; +} + +export interface ActionSlotDefinition { + id: string; + /** Contribution kinds this slot will render. */ + accepts: string[]; +} + +export interface SlotContribution { + /** Action invoked when this contribution is activated. */ + actionId: string; + titleKey: string; + icon?: LucideIcon; + /** Checked against the slot's accepts. Defaults to "button". */ + kind?: string; + pluginId?: string; + /** Lower sorts first. Ties break on actionId so ordering is stable. */ + order?: number; +} + +const actions = new Map(); +const slots = new Map(); +/** slotId -> actionId -> contribution. */ +const contributions = new Map>(); +const listeners = new Set<() => void>(); + +/** + * Cached per-slot arrays. + * + * Required, not an optimization: useSyncExternalStore compares snapshots by + * identity, so building a fresh array per call makes React throw "The result + * of getSnapshot should be cached" and re-render forever. + */ +const snapshots = new Map(); + +const EMPTY: SlotContribution[] = []; + +function emit(): void { + snapshots.clear(); + for (const listener of listeners) listener(); +} + +export function registerAction( + id: string, + handler: ActionHandler, + options: { permission?: string; pluginId?: string } = {}, +): void { + actions.set(id, { id, handler, ...options }); + emit(); +} + +export function unregisterAction(id: string): void { + if (actions.delete(id)) emit(); +} + +/** The permission gating an action, if it declared one. */ +export function getActionPermission(id: string): string | undefined { + return actions.get(id)?.permission; +} + +/** + * Runs an action. Resolves quietly when the id is unknown: a contribution can + * outlive the plugin that registered its handler, and a click should not throw + * into the React event handler because of it. + */ +export async function invokeAction( + id: string, + ...args: unknown[] +): Promise { + const action = actions.get(id); + if (!action) { + console.warn(`[actions] no handler registered for "${id}"`); + return undefined; + } + return (action.handler as (...a: unknown[]) => unknown)(...args); +} + +export function declareActionSlot(slot: ActionSlotDefinition): void { + slots.set(slot.id, slot); + emit(); +} + +export function undeclareActionSlot(slotId: string): void { + if (slots.delete(slotId)) emit(); +} + +/** + * Adds a contribution to a slot. + * + * A contribution for a slot nobody has declared yet is kept rather than + * dropped: plugin activation order is not guaranteed, and the slot owner may + * register after the contributor. It becomes visible once the slot appears. + */ +export function registerSlotContribution( + slotId: string, + contribution: SlotContribution, +): void { + const slot = slots.get(slotId); + const kind = contribution.kind ?? "button"; + + if (slot && !slot.accepts.includes(kind)) { + console.warn( + `[actions] slot "${slotId}" does not accept "${kind}" (accepts: ${slot.accepts.join(", ")})`, + ); + return; + } + + let forSlot = contributions.get(slotId); + if (!forSlot) { + forSlot = new Map(); + contributions.set(slotId, forSlot); + } + forSlot.set(contribution.actionId, { ...contribution, kind }); + emit(); +} + +export function unregisterSlotContribution( + slotId: string, + actionId: string, +): void { + const forSlot = contributions.get(slotId); + if (!forSlot?.delete(actionId)) return; + if (forSlot.size === 0) contributions.delete(slotId); + emit(); +} + +/** + * Contributions for a slot, sorted and referentially stable between changes. + * Unfiltered: callers that render go through useActionSlot, which drops the + * ones the current user may not see. + */ +export function getSlotContributions(slotId: string): SlotContribution[] { + const cached = snapshots.get(slotId); + if (cached) return cached; + + const forSlot = contributions.get(slotId); + if (!forSlot || forSlot.size === 0) { + snapshots.set(slotId, EMPTY); + return EMPTY; + } + + const sorted = [...forSlot.values()].sort( + (a, b) => + (a.order ?? 0) - (b.order ?? 0) || a.actionId.localeCompare(b.actionId), + ); + snapshots.set(slotId, sorted); + return sorted; +} + +export function subscribeToActionRegistry(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Test seam, mirroring resetPluginState(). */ +export function resetActionRegistry(): void { + actions.clear(); + slots.clear(); + contributions.clear(); + snapshots.clear(); + emit(); +} diff --git a/src/ui/shell/pluginLoader.ts b/src/ui/shell/pluginLoader.ts index 8afa9f58b..433be534e 100644 --- a/src/ui/shell/pluginLoader.ts +++ b/src/ui/shell/pluginLoader.ts @@ -15,9 +15,17 @@ * bigger regression risk to a working terminal than gating the entry points. */ +import { Bot } from "lucide-react"; import { unregisterRailItem } from "@/sidebar/rail-items"; import { unregisterTabComponent } from "@/shell/tabUtils"; import { getPlugins, type PluginSummary } from "@/api/plugins-api"; +import { + declareActionSlot, + registerAction, + registerSlotContribution, + unregisterAction, + unregisterSlotContribution, +} from "@/shell/action-registry"; /** * Built-in tab types owned by a plugin, keyed by plugin id. The terminal has @@ -38,6 +46,50 @@ const BUILT_IN_TABS_BY_PLUGIN: Record = { let pluginEnabled = new Map(); let disabledTabTypes = new Set(); +const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar"; +const AI_OPEN_WITH_CONTEXT = "ai.openWithContext"; + +/** + * Applies the UI action contributions the first-party plugins declare. + * + * These live in plugins/*_/frontend/index.mjs, but nothing imports those yet: + * there is no frontend plugin loader. Until there is, the registration is + * mirrored here so the contributed UI is actually reachable in the running + * app rather than only in tests. The two must be kept in step, and this block + * goes away when a real loader lands. + */ +function applyFirstPartyActions(enabled: Map): void { + if (enabled.get("ssh-terminal") !== false) { + declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] }); + } + + if (enabled.get("ai") === false) { + unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT); + unregisterAction(AI_OPEN_WITH_CONTEXT); + return; + } + + registerAction( + AI_OPEN_WITH_CONTEXT, + (context: unknown) => { + window.dispatchEvent( + new CustomEvent("termix:ai:openWithContext", { + detail: { context: typeof context === "string" ? context : "" }, + }), + ); + }, + { permission: "ai.services.use", pluginId: "ai" }, + ); + + registerSlotContribution(TERMINAL_TOOLBAR_SLOT, { + actionId: AI_OPEN_WITH_CONTEXT, + titleKey: "ai.assistant", + icon: Bot, + kind: "button", + pluginId: "ai", + }); +} + export function applyPluginState(plugins: PluginSummary[]): void { const nextEnabled = new Map(); const nextDisabledTabs = new Set(); @@ -65,6 +117,7 @@ export function applyPluginState(plugins: PluginSummary[]): void { pluginEnabled = nextEnabled; disabledTabTypes = nextDisabledTabs; + applyFirstPartyActions(nextEnabled); } export async function refreshPluginState(): Promise { @@ -92,4 +145,6 @@ export function isPluginEnabled(pluginId: string): boolean { export function resetPluginState(): void { pluginEnabled = new Map(); disabledTabTypes = new Set(); + unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT); + unregisterAction(AI_OPEN_WITH_CONTEXT); } diff --git a/src/ui/tests/features/terminal/TerminalToolbar.test.tsx b/src/ui/tests/features/terminal/TerminalToolbar.test.tsx index ed655d1cb..814ce9d71 100644 --- a/src/ui/tests/features/terminal/TerminalToolbar.test.tsx +++ b/src/ui/tests/features/terminal/TerminalToolbar.test.tsx @@ -29,6 +29,16 @@ vi.mock("@/sidebar/tree/HostItem/HostItem", () => hostActionsApi); vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => mobileApi.isMobile, })); +const perms = vi.hoisted(() => ({ granted: [] as string[] })); +vi.mock("@/hooks/use-permissions", () => ({ + usePermissions: () => ({ + permissions: perms.granted, + isAdmin: false, + loaded: true, + has: (permission: string) => perms.granted.includes(permission), + }), +})); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => @@ -54,11 +64,18 @@ vi.mock("react-i18next", () => ({ "terminalToolbar.imageActionFailed": "Image action failed", "terminalToolbar.retry": "Retry", "terminalToolbar.dismissError": "Dismiss image error", + "ai.assistant": "AI Assistant", })[key] ?? key, }), })); import { TerminalToolbar } from "@/features/terminal/TerminalToolbar"; +import { + declareActionSlot, + registerAction, + registerSlotContribution, + resetActionRegistry, +} from "@/shell/action-registry"; import { clampToolbarPosition, getResponsiveToolbarDensity, @@ -96,6 +113,8 @@ beforeEach(() => { value: 1024, }); localStorage.clear(); + perms.granted = []; + resetActionRegistry(); hostActionsApi.getSshActions.mockReturnValue([]); api.startMetricsPolling.mockResolvedValue({ viewerSessionId: "viewer" }); api.getServerMetricsById.mockResolvedValue(null); @@ -891,4 +910,67 @@ describe("TerminalToolbar Phase 1", () => { expect(api.stopMetricsPolling).toHaveBeenCalledWith(7, "late-viewer"), ); }); + + describe("contributed toolbar actions", () => { + const ACTION = "ai.openWithContext"; + + function contribute() { + const handler = vi.fn(); + declareActionSlot({ id: "terminal.toolbar", accepts: ["button"] }); + registerAction(ACTION, handler, { permission: "ai.services.use" }); + registerSlotContribution("terminal.toolbar", { + actionId: ACTION, + titleKey: "ai.assistant", + }); + return handler; + } + + it("shows no contributed button and no stray separator when nothing contributed", () => { + declareActionSlot({ id: "terminal.toolbar", accepts: ["button"] }); + const { container } = renderToolbar(); + + expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull(); + const separatorsWithout = container.querySelectorAll(".w-px").length; + + cleanup(); + contribute(); + perms.granted = ["ai.services.use"]; + const withButton = renderToolbar().container; + + expect(withButton.querySelectorAll(".w-px").length).toBe( + separatorsWithout + 1, + ); + }); + + it("hides the contributed button when the user lacks the permission", () => { + contribute(); + renderToolbar(); + + expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull(); + }); + + it("invokes the action with the terminal buffer text when permitted", async () => { + const handler = contribute(); + perms.granted = ["ai.services.use"]; + const getBufferText = vi.fn(() => "$ whoami\nroot"); + + renderToolbar({ getBufferText }); + + expect(getBufferText).not.toHaveBeenCalled(); + await userEvent.click( + screen.getByRole("button", { name: "AI Assistant" }), + ); + + expect(handler).toHaveBeenCalledWith("$ whoami\nroot"); + }); + + it("hides the slot when the host has contributed actions disabled", () => { + contribute(); + perms.granted = ["ai.services.use"]; + + renderToolbar({ actionsEnabled: false }); + + expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull(); + }); + }); }); diff --git a/src/ui/tests/features/terminal/terminal-buffer-text.test.ts b/src/ui/tests/features/terminal/terminal-buffer-text.test.ts new file mode 100644 index 000000000..ec5626a2e --- /dev/null +++ b/src/ui/tests/features/terminal/terminal-buffer-text.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import type { Terminal } from "@xterm/xterm"; +import { getTerminalBufferText } from "@/features/terminal/terminal-buffer-text"; + +interface FakeLine { + text: string; + wrapped?: boolean; +} + +/** Minimal stand-in for the parts of xterm this reads. */ +function fakeTerminal(lines: FakeLine[], rows = lines.length): Terminal { + return { + rows, + buffer: { + active: { + baseY: Math.max(0, lines.length - rows), + getLine: (i: number) => { + const line = lines[i]; + if (!line) return undefined; + return { + isWrapped: !!line.wrapped, + translateToString: () => line.text, + }; + }, + }, + }, + } as unknown as Terminal; +} + +describe("getTerminalBufferText", () => { + it("returns an empty string for a missing terminal", () => { + expect(getTerminalBufferText(null)).toBe(""); + expect(getTerminalBufferText(undefined)).toBe(""); + }); + + it("joins buffer rows with newlines", () => { + const terminal = fakeTerminal([{ text: "$ whoami" }, { text: "root" }]); + + expect(getTerminalBufferText(terminal)).toBe("$ whoami\nroot"); + }); + + it("joins a wrapped row onto the line it continues", () => { + const terminal = fakeTerminal([ + { text: "$ echo start" }, + { text: "a-very-long-command " }, + { text: "that-wrapped", wrapped: true }, + ]); + + expect(getTerminalBufferText(terminal)).toBe( + "$ echo start\na-very-long-command that-wrapped", + ); + }); + + it("drops trailing blank lines", () => { + const terminal = fakeTerminal([ + { text: "output" }, + { text: "" }, + { text: "" }, + ]); + + expect(getTerminalBufferText(terminal)).toBe("output"); + }); + + it("keeps blank lines in the middle", () => { + const terminal = fakeTerminal([ + { text: "one" }, + { text: "" }, + { text: "two" }, + ]); + + expect(getTerminalBufferText(terminal)).toBe("one\n\ntwo"); + }); + + it("bounds the result to maxLines, keeping the tail", () => { + const lines = Array.from({ length: 50 }, (_, i) => ({ text: `line ${i}` })); + const terminal = fakeTerminal(lines, 50); + + const result = getTerminalBufferText(terminal, 3); + + expect(result).toBe("line 47\nline 48\nline 49"); + }); + + it("returns an empty string for a non-positive maxLines", () => { + const terminal = fakeTerminal([{ text: "anything" }]); + + expect(getTerminalBufferText(terminal, 0)).toBe(""); + }); + + it("returns an empty string when the buffer throws", () => { + const disposed = { + rows: 1, + get buffer(): never { + throw new Error("terminal disposed"); + }, + } as unknown as Terminal; + + expect(getTerminalBufferText(disposed)).toBe(""); + }); +}); diff --git a/src/ui/tests/lib/permissions.test.ts b/src/ui/tests/lib/permissions.test.ts new file mode 100644 index 000000000..3d874accd --- /dev/null +++ b/src/ui/tests/lib/permissions.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { matchesPermission } from "@/lib/permissions"; + +/** + * These cases mirror PermissionManager.hasPermission on the backend. If that + * changes, this file is the tripwire. + */ +describe("matchesPermission", () => { + it("grants on an exact match", () => { + expect( + matchesPermission(["ai.services.use"], false, "ai.services.use"), + ).toBe(true); + }); + + it("grants on the superuser wildcard", () => { + expect(matchesPermission(["*"], false, "anything.at.all")).toBe(true); + }); + + it("grants on each dotted wildcard level", () => { + expect(matchesPermission(["ai.services.*"], false, "ai.services.use")).toBe( + true, + ); + expect(matchesPermission(["ai.*"], false, "ai.services.use")).toBe(true); + }); + + it("grants to an admin with no explicit permissions", () => { + expect(matchesPermission([], true, "ai.services.use")).toBe(true); + }); + + it("denies a non-admin without a matching grant", () => { + expect(matchesPermission([], false, "ai.services.use")).toBe(false); + expect(matchesPermission(["hosts.view"], false, "ai.services.use")).toBe( + false, + ); + }); + + it("does not treat a prefix as a wildcard without the star", () => { + expect(matchesPermission(["ai.services"], false, "ai.services.use")).toBe( + false, + ); + }); + + it("does not let a sibling wildcard grant an unrelated permission", () => { + expect(matchesPermission(["hosts.*"], false, "ai.services.use")).toBe( + false, + ); + }); + + it("handles a single-segment permission", () => { + expect(matchesPermission(["admin"], false, "admin")).toBe(true); + // "admin.*" covers bare "admin" too: the backend builds the same + // single-segment wildcard, so the frontend must agree. + expect(matchesPermission(["admin.*"], false, "admin")).toBe(true); + expect(matchesPermission(["other"], false, "admin")).toBe(false); + }); +}); diff --git a/src/ui/tests/shell/action-slot.test.tsx b/src/ui/tests/shell/action-slot.test.tsx new file mode 100644 index 000000000..76dbd7378 --- /dev/null +++ b/src/ui/tests/shell/action-slot.test.tsx @@ -0,0 +1,298 @@ +/** + * The three states a permission-gated slot contribution can be in, plus the + * reactivity that makes a slot different from the rail/tab registries. + * + * Follows the pattern of sidebar/plugin-extension-seam.test.tsx: register into + * the real registry, assert through a real render, then clean up. The point is + * that states (a) "nothing contributed" and (b) "contributed but not + * permitted" are indistinguishable in the DOM. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { Bot } from "lucide-react"; + +const perms = vi.hoisted(() => ({ + permissions: [] as string[], + isAdmin: false, + loaded: true, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/hooks/use-permissions", async () => { + const { matchesPermission } = await import("@/lib/permissions"); + return { + usePermissions: () => ({ + permissions: perms.permissions, + isAdmin: perms.isAdmin, + loaded: perms.loaded, + has: (permission: string) => + matchesPermission(perms.permissions, perms.isAdmin, permission), + }), + }; +}); + +import { ActionSlot } from "@/shell/ActionSlot"; +import { + declareActionSlot, + registerAction, + registerSlotContribution, + resetActionRegistry, + getSlotContributions, + unregisterSlotContribution, +} from "@/shell/action-registry"; + +const SLOT = "terminal.toolbar"; +const ACTION = "ai.openWithContext"; + +function contributeAi(handler = vi.fn()) { + registerAction(ACTION, handler, { permission: "ai.services.use" }); + registerSlotContribution(SLOT, { + actionId: ACTION, + titleKey: "ai.assistant", + icon: Bot, + }); + return handler; +} + +describe("ActionSlot", () => { + beforeEach(() => { + perms.permissions = []; + perms.isAdmin = false; + perms.loaded = true; + declareActionSlot({ id: SLOT, accepts: ["button"] }); + }); + + afterEach(() => { + resetActionRegistry(); + }); + + it("renders nothing when no plugin has contributed", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders nothing when the user lacks the permission", () => { + const handler = contributeAi(); + + const { container } = render(); + + // Identical to the no-contribution case: no placeholder, and nothing + // merely disabled or greyed out that hints at a feature out of reach. + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.queryByText("ai.assistant")).toBeNull(); + expect(container.querySelector("[disabled]")).toBeNull(); + expect(container.querySelector("[aria-disabled]")).toBeNull(); + expect(handler).not.toHaveBeenCalled(); + }); + + it("renders the button and invokes the action when permitted", async () => { + const handler = contributeAi(); + perms.permissions = ["ai.services.use"]; + + render( ["buffer text"]} />); + + const button = screen.getByRole("button", { name: "ai.assistant" }); + await act(async () => { + button.click(); + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith("buffer text"); + }); + + it("reads its context at click time, not on render", async () => { + const handler = contributeAi(); + perms.permissions = ["ai.services.use"]; + const context = vi.fn(() => ["later"]); + + render(); + expect(context).not.toHaveBeenCalled(); + + await act(async () => { + screen.getByRole("button", { name: "ai.assistant" }).click(); + }); + expect(context).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith("later"); + }); + + it("appears when a contribution is registered after mount", () => { + perms.permissions = ["ai.services.use"]; + + render(); + expect(screen.queryByRole("button")).toBeNull(); + + // No re-render from the test: useSyncExternalStore has to do this. A + // plain Map like the rail/tab registries would fail here. + act(() => { + contributeAi(); + }); + + expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy(); + }); + + it("disappears again when the contribution is removed", () => { + contributeAi(); + perms.permissions = ["ai.services.use"]; + + render(); + expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy(); + + act(() => { + unregisterSlotContribution(SLOT, ACTION); + }); + + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders a wildcard-granted contribution", () => { + contributeAi(); + perms.permissions = ["ai.*"]; + + render(); + + expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy(); + }); + + it("renders for an admin with no explicit grants", () => { + contributeAi(); + perms.isAdmin = true; + + render(); + + expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy(); + }); + + it("renders an ungated contribution for everyone", () => { + registerAction("demo.ping", vi.fn()); + registerSlotContribution(SLOT, { + actionId: "demo.ping", + titleKey: "demo.ping", + }); + + render(); + + expect(screen.getByRole("button", { name: "demo.ping" })).toBeTruthy(); + }); + + it("renders nothing while permissions are still loading", () => { + contributeAi(); + perms.permissions = ["ai.services.use"]; + perms.loaded = false; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when the owner disables the slot", () => { + contributeAi(); + perms.permissions = ["ai.services.use"]; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("lets the slot owner render contributions its own way", () => { + contributeAi(); + perms.permissions = ["ai.services.use"]; + + render( + ( + + {contribution.titleKey} + + )} + />, + ); + + expect(screen.getByTestId("custom")).toHaveTextContent("ai.assistant"); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); + +describe("action registry", () => { + afterEach(() => { + resetActionRegistry(); + }); + + it("refuses a contribution whose kind the slot does not accept", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + declareActionSlot({ id: SLOT, accepts: ["button"] }); + + registerSlotContribution(SLOT, { + actionId: "demo.menu", + titleKey: "demo.menu", + kind: "menu-item", + }); + + expect(getSlotContributions(SLOT)).toHaveLength(0); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("keeps a contribution registered before its slot is declared", () => { + registerSlotContribution(SLOT, { + actionId: ACTION, + titleKey: "ai.assistant", + }); + + expect(getSlotContributions(SLOT)).toHaveLength(1); + + declareActionSlot({ id: SLOT, accepts: ["button"] }); + expect(getSlotContributions(SLOT)).toHaveLength(1); + }); + + it("returns a stable snapshot reference between mutations", () => { + declareActionSlot({ id: SLOT, accepts: ["button"] }); + registerSlotContribution(SLOT, { + actionId: ACTION, + titleKey: "ai.assistant", + }); + + // The guard against useSyncExternalStore's infinite re-render loop. + expect(getSlotContributions(SLOT)).toBe(getSlotContributions(SLOT)); + + const before = getSlotContributions(SLOT); + registerSlotContribution(SLOT, { + actionId: "demo.other", + titleKey: "demo.other", + }); + expect(getSlotContributions(SLOT)).not.toBe(before); + }); + + it("sorts by order then action id", () => { + declareActionSlot({ id: SLOT, accepts: ["button"] }); + registerSlotContribution(SLOT, { actionId: "z.last", titleKey: "k" }); + registerSlotContribution(SLOT, { actionId: "a.first", titleKey: "k" }); + registerSlotContribution(SLOT, { + actionId: "m.leading", + titleKey: "k", + order: -1, + }); + + expect(getSlotContributions(SLOT).map((c) => c.actionId)).toEqual([ + "m.leading", + "a.first", + "z.last", + ]); + }); + + it("resolves quietly when a contribution points at a missing action", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { invokeAction } = await import("@/shell/action-registry"); + + await expect(invokeAction("nobody.home")).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +});