diff --git a/README.md b/README.md index c9101d10..12f8d992 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- +

@@ -23,8 +23,8 @@ - - + +

@@ -96,11 +96,11 @@ - [Known Issues](#known-issues) - [Features Overview](#features-overview) - [Releases \& Downloads](#releases--downloads) - - [Download Latest Release](#download-latest-release) - - [Building from Source](#building-from-source) + - [Download Latest Release](#download-latest-release) + - [Building from Source](#building-from-source) - [Development Configuration](#development-configuration) - - [Telemetry and Error Tracking](#telemetry-and-error-tracking) - - [Environment Variables](#environment-variables) + - [Telemetry and Error Tracking](#telemetry-and-error-tracking) + - [Environment Variables](#environment-variables) - [Discussions](#discussions) - [License](#license) - [Team](#team) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 187dfa98..3f978782 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,46 +1,37 @@ -# Power Platform ToolBox 1.2.2 +# Power Platform ToolBox 1.2.3 ## Highlights -- Open external URLs using the active connection's browser profile for consistent session isolation -- Keep the app accessible on macOS with a system tray icon when the window is closed -- Sync zoom level between the main window and all open tool BrowserViews -- Allow tools to open `mailto:` links with explicit user consent and URL validation -- Improve protocol handler behavior for stable builds (single-instance lock and safer registration rules) -- Make marketplace filtering clearer with an active-filter indicator and one-click clear -- Improve tool update availability visibility with a clearer text-based indicator +- Support beta and pre-release npm packages in tool discovery and installs +- Enhanced inter-tool communications; both one-way (send and forget) and two-way (send and receive) +- Let tools declare capabilities; and find tools by capabilities +- Tighten terminal command blocking during tool execution +- Sanitize version scopes before running npm install ## Fixes -- Zoom: correct zoom-in accelerator keys and re-fit BrowserView bounds after zooming -- Links: avoid lowercasing full URLs during `mailto:` scheme checks -- Links: improve parse-failure logging with scheme context while avoiding PII -- UI: fix notification layering/z-index issues in the renderer -- Tools sidebar: improve empty-state hint behavior for better guidance -- Marketplace: fix tool icon theming so icons adapt correctly in light/dark mode -- URLs: add support for URLs ending in `mcas.ms` where applicable -- macOS: ensure tray Quit fully exits the app (track `isQuitting` correctly) +- Terminal: improve blocking rules and related command handling +- Terminal: keep stdout processing stable while filtering sentinel output +- Install flow: sanitize npm version scopes before invoking installs +- Marketplace: keep prerelease package handling aligned with tool registry metadata +- Tools: preserve capability metadata for npm and locally loaded tools ## Developer & Build -- toolboxAPI: remove deprecated loading screen API and associated handlers -- `pptb-validate`: validate `pptb.config.json` invocation with semver enforcement -- Types: rename `PptbConfig` → `PPTBConfig` and `validatePptbConfig` → `validatePPTBConfig` for consistent casing -- CI/CD: separate stable vs insider release channels (including channel-specific icons) -- Windows packaging: sign app binaries before packaging into installers -- Workflows: tighten merge automation with explicit permissions and concurrency +- Tool registry and preload APIs now carry prerelease package support end to end +- Terminal manager refactor expands the blocking pipeline and stdout handling internals ## Install -- Windows: Power-Platform-ToolBox-1.2.2-Setup.exe -- macOS: Power-Platform-ToolBox-1.2.2.dmg (drag to Applications) -- Linux: Power-Platform-ToolBox-1.2.2.AppImage (chmod +x, then run) +- Windows: Power-Platform-ToolBox-1.2.3-Setup.exe +- macOS: Power-Platform-ToolBox-1.2.3.dmg (drag to Applications) +- Linux: Power-Platform-ToolBox-1.2.3.AppImage (chmod +x, then run) ## Notes -- No manual migration needed; existing settings and connections continue to work. -- Tool developers: `mailto:` opening may prompt for user consent; update config/type references to `PPTBConfig` if you used the older casing. +- No manual migration needed. +- Tool developers using prerelease packages should confirm their package metadata is published with the expected version scope. ## Full Changelog -https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.2.1...v1.2.2 +https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.2.2...v1.2.3 diff --git a/docs/INTER_TOOL_INVOCATION.md b/docs/INTER_TOOL_INVOCATION.md index f6dd64e1..3c9c25bc 100644 --- a/docs/INTER_TOOL_INVOCATION.md +++ b/docs/INTER_TOOL_INVOCATION.md @@ -9,21 +9,42 @@ This document covers the **Inter-Tool Invocation** feature of Power Platform Too ## Table of Contents -1. [Overview](#overview) -2. [Part 1 – Callee (the tool that accepts invocations)](#part-1--callee-the-tool-that-accepts-invocations) - - [1.1 Declaring the invocation contract (`pptb.config.json`)](#11-declaring-the-invocation-contract-pptbconfigjson) - - [1.2 Reading the launch context](#12-reading-the-launch-context) - - [1.3 Returning data to the caller](#13-returning-data-to-the-caller) - - [1.4 Handling standalone vs. invoked modes](#14-handling-standalone-vs-invoked-modes) - - [1.5 Complete callee example](#15-complete-callee-example) -3. [Part 2 – Caller (the tool that launches other tools)](#part-2--caller-the-tool-that-launches-other-tools) - - [2.1 Launching a tool with prefill data](#21-launching-a-tool-with-prefill-data) - - [2.2 Handling the return value](#22-handling-the-return-value) - - [2.3 Connection overrides](#23-connection-overrides) - - [2.4 Complete caller example](#24-complete-caller-example) -4. [Lifecycle and Behaviour](#lifecycle-and-behaviour) -5. [Validation and Tooling](#validation-and-tooling) -6. [Troubleshooting](#troubleshooting) +- [Inter-Tool Invocation](#inter-tool-invocation) + - [Table of Contents](#table-of-contents) + - [Overview](#overview) + - [Part 1 – Callee (the tool that accepts invocations)](#part-1--callee-the-tool-that-accepts-invocations) + - [1.1 Declaring the invocation contract (`pptb.config.json`)](#11-declaring-the-invocation-contract-pptbconfigjson) + - [1.2 Reading the launch context](#12-reading-the-launch-context) + - [1.3 Returning data to the caller](#13-returning-data-to-the-caller) + - [1.4 Handling standalone vs. invoked modes](#14-handling-standalone-vs-invoked-modes) + - [1.5 Complete callee example](#15-complete-callee-example) + - [Part 2 – Caller (the tool that launches other tools)](#part-2--caller-the-tool-that-launches-other-tools) + - [2.1 Launching a tool with prefill data](#21-launching-a-tool-with-prefill-data) + - [2.2 Handling the return value](#22-handling-the-return-value) + - [2.3 Connection auto-inheritance and overrides](#23-connection-auto-inheritance-and-overrides) + - [2.4 Tag-based capability discovery](#24-tag-based-capability-discovery) + - [Well-known capability tags](#well-known-capability-tags) + - [2.5 Complete caller example](#25-complete-caller-example) + - [End-to-End Scenario: FXS "Send To" Flyout](#end-to-end-scenario-fxs-send-to-flyout) + - [Scenario summary](#scenario-summary) + - [Step 1 – Callee tools declare the `"fetchxml"` capability](#step-1--callee-tools-declare-the-fetchxml-capability) + - [Step 2 – FXS builds its "Send To" flyout from discovered tools](#step-2--fxs-builds-its-send-to-flyout-from-discovered-tools) + - [Step 3 – User selects DMS; FXS launches it with `noReturn: true`](#step-3--user-selects-dms-fxs-launches-it-with-noreturn-true) + - [Step 4 – DMS reads the prefill data and uses it](#step-4--dms-reads-the-prefill-data-and-uses-it) + - [Step 5 – DMS closes; FXS Promise resolves](#step-5--dms-closes-fxs-promise-resolves) + - [Full sequence diagram](#full-sequence-diagram) + - [Validation and Tooling](#validation-and-tooling) + - [`pptb-validate`](#pptb-validate) + - [TypeScript types](#typescript-types) + - [Troubleshooting](#troubleshooting) + - [`launchTool` throws "Tool not found"](#launchtool-throws-tool-not-found) + - [`launchTool` throws "A callee invocation is already in progress"](#launchtool-throws-a-callee-invocation-is-already-in-progress) + - [`getLaunchContext()` returns `null` when expecting prefill data](#getlaunchcontext-returns-null-when-expecting-prefill-data) + - [Caller `Promise` resolves with `null` unexpectedly](#caller-promise-resolves-with-null-unexpectedly) + - [Changes to `pptb.config.json` are not picked up](#changes-to-pptbconfigjson-are-not-picked-up) + - [`returnData` appears to do nothing](#returndata-appears-to-do-nothing) + - [`findToolsByCapability` returns an empty array](#findtoolsbycapability-returns-an-empty-array) + - [References](#references) --- @@ -43,13 +64,19 @@ invocation.launchTool( invocation.getLaunchContext() result { selectedId: "a1b2c3", = { selectedId: "a1b2c3", selectedName: "Contoso" } selectedName: "Contoso" } ) + ← PPTB auto-closes callee window ``` Key properties of the feature: - **Promise-based**: `invocation.launchTool()` returns a `Promise` that resolves when the callee calls `returnData()`, or resolves to `null` if the callee closes without returning data. - **Isolated windows**: the callee opens in its own BrowserView, just like a normally launched tool. +- **Auto-close callee**: after the callee calls `returnData()`, PPTB **automatically closes the callee window** — the callee does not need to close itself. +- **Connection auto-inheritance**: the callee automatically inherits the caller's active FXS connection (can be overridden via `options`). +- **One-at-a-time**: only one active callee per caller is supported in this phase. A second `launchTool` call while a callee is active rejects with `"A callee invocation is already in progress"`. - **Optional contract**: the callee declares the shape of its prefill data and return value in `pptb.config.json`; this is validated by `pptb-validate` but is not enforced at runtime. +- **Tag-based capability discovery**: callee tools declare capability tags; caller tools can discover matching installed tools by tag. +- **Shell-level "Return to Caller" banner**: PPTB injects a dismissable banner in the callee window so the user can return to the caller at any time — even before `returnData` is called. - **Graceful degradation**: both the prefill data and the return value are plain JSON objects (`Record`), so missing fields degrade gracefully. --- @@ -60,13 +87,14 @@ Key properties of the feature: Create a file named `pptb.config.json` in the **root of your tool package** (next to `package.json`). This file declares: -| Field | Required | Description | -|-------|----------|-------------| -| `invocation.version` | **Yes** (when `invocation` is present) | Semantic version of your invocation contract (e.g. `"1.0.0"`). Bump this when the shape of `prefill` or `returnTopic` changes. | -| `invocation.prefill` | No | JSON-schema-style object describing the data a caller can pass in. | -| `invocation.prefill.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. | -| `invocation.returnTopic` | No | JSON-schema-style object describing the data your tool returns to its caller. | -| `invocation.returnTopic.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. | +| Field | Required | Description | +| ----------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `invocation.version` | **Yes** (when `invocation` is present) | Semantic version of your invocation contract (e.g. `"1.0.0"`). Bump this when the shape of `prefill` or `returnTopic` changes. | +| `invocation.capabilities` | No | Array of capability tag strings (e.g. `["entity-picker"]`). Used by callers to discover this tool. | +| `invocation.prefill` | No | JSON-schema-style object describing the data a caller can pass in. | +| `invocation.prefill.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. | +| `invocation.returnTopic` | No | JSON-schema-style object describing the data your tool returns to its caller. | +| `invocation.returnTopic.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. | **Example `pptb.config.json`:** @@ -74,6 +102,7 @@ Create a file named `pptb.config.json` in the **root of your tool package** (nex { "invocation": { "version": "1.0.0", + "capabilities": ["entity-picker"], "prefill": { "properties": { "entityName": { "type": "string" }, @@ -135,6 +164,7 @@ await toolboxAPI.invocation.returnData({ selectedId: "a1b2c3d4-...", selectedName: "Contoso Ltd.", }); +// PPTB automatically closes this window after delivering the result. ``` **Signature:** @@ -144,8 +174,8 @@ returnData(returnData: Record): Promise ``` - Resolves the `Promise` that the caller is awaiting in `invocation.launchTool()`. +- **PPTB automatically closes the callee window** after the result has been delivered to the caller — the callee does **not** need to close itself. - If the tool was **not** launched by another tool, this call is a **no-op** – it is safe to call unconditionally. -- After calling `returnData`, it is your tool's responsibility to close its own window or update its UI as appropriate. --- @@ -153,10 +183,10 @@ returnData(returnData: Record): Promise A well-behaved callee works in both modes: -| Mode | `getLaunchContext()` returns | Expected behaviour | -|------|-----------------------------|--------------------| -| Standalone (normal launch) | `null` | Show full UI, no pre-populated state | -| Invoked by another tool | `Record` | Pre-populate UI from the context, show a "confirm / return" action | +| Mode | `getLaunchContext()` returns | Expected behaviour | +| -------------------------- | ---------------------------- | ------------------------------------------------------------------ | +| Standalone (normal launch) | `null` | Show full UI, no pre-populated state | +| Invoked by another tool | `Record` | Pre-populate UI from the context, show a "confirm / return" action | ```typescript async function initTool() { @@ -169,6 +199,7 @@ async function initTool() { allowMultiSelect: (ctx.allowMultiSelect as boolean) ?? false, onConfirm: async (selection) => { await toolboxAPI.invocation.returnData(selection); + // PPTB will auto-close this window after returnData completes }, }); } else { @@ -190,6 +221,7 @@ The following is a minimal but complete callee implementation for an entity-pick { "invocation": { "version": "1.0.0", + "capabilities": ["entity-picker"], "prefill": { "properties": { "entityName": { "type": "string" }, @@ -218,7 +250,7 @@ async function main() { const records = await loadRecords(entityName); renderPicker(records, async (selected) => { - // Send the selection back and let the caller handle closing / next steps + // Send the selection back; PPTB auto-closes this window await toolboxAPI.invocation.returnData({ selectedId: selected.id, selectedName: selected.name, @@ -243,8 +275,8 @@ Use `toolboxAPI.invocation.launchTool()` to open another installed tool and pass ```typescript const result = await toolboxAPI.invocation.launchTool( - "@my-org/entity-picker", // npm package name of the target tool - { entityName: "account" }, // prefill data (must match callee's prefill schema) + "@my-org/entity-picker", // npm package name of the target tool + { entityName: "account" }, // prefill data (must match callee's prefill schema) ); ``` @@ -257,64 +289,123 @@ launchTool( options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null; + noReturn?: boolean; }, ): Promise ``` -| Parameter | Type | Description | -|-----------|------|-------------| -| `targetToolId` | `string` | The npm package name of the tool to launch (e.g. `"@my-org/entity-picker"`). Must be installed. | -| `prefillData` | `Record` | Optional data to pre-populate the callee's state. Shape should match the callee's `invocation.prefill` schema. | -| `options.primaryConnectionId` | `string | null` | Override the primary Dataverse connection for the callee. | -| `options.secondaryConnectionId` | `string | null` | Override the secondary Dataverse connection for the callee. | +| Parameter | Type | Description | +| ------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `targetToolId` | `string` | The npm package name of the tool to launch (e.g. `"@my-org/entity-picker"`). Must be installed. | +| `prefillData` | `Record` | Optional data to pre-populate the callee's state. Shape should match the callee's `invocation.prefill` schema. | +| `options.primaryConnectionId` | `string \| null` | Override the primary Dataverse connection for the callee. Omit to auto-inherit the caller's active FXS connection. | +| `options.secondaryConnectionId` | `string \| null` | Override the secondary Dataverse connection for the callee. Omit to let PPTB prompt for it when the callee is a multi-connection tool. | +| `options.noReturn` | `boolean` | When `true`, signals that the caller does not expect the callee to return data. The "Return to [Caller]" banner is suppressed entirely for the callee. The invocation lifecycle is otherwise identical — the Promise still resolves with `null` when the callee closes. | -**Return value:** A `Promise` that resolves with the `Record` passed to `returnData()` by the callee, or `null` if the callee closes without returning data. +**Return value:** A `Promise` that resolves with the `Record` passed to `returnData()` by the callee, or `null` if: + +- the callee closes without calling `returnData`, or +- the user clicks the "Return to [this tool]" banner before the callee calls `returnData`. + +> **Important:** Only one callee per caller is active at a time. Calling `launchTool` a second time while a callee is still active throws `"A callee invocation is already in progress"`. > **Important:** The target tool must be **installed** in PPTB. If the tool is not found, `launchTool` throws an error. +> **Multi-connection auto-prompt:** If the callee tool declares `features.multiConnection: "required"` or `"optional"` in its manifest and no `options.secondaryConnectionId` is provided, PPTB automatically opens the multi-connection selector before launching the callee. If the user cancels the selector, `launchTool` throws `"Connection selection cancelled"`. + --- ### 2.2 Handling the return value ```typescript -const result = await toolboxAPI.invocation.launchTool( - "@my-org/entity-picker", - { entityName: "contact" }, -); +const result = await toolboxAPI.invocation.launchTool("@my-org/entity-picker", { entityName: "contact" }); if (result !== null) { const { selectedId, selectedName } = result as { selectedId: string; selectedName: string }; // Use the selection returned by the callee populateField("regardingobjectid", selectedId, selectedName); } else { - // User closed the picker without making a selection – no change needed + // User dismissed the picker without selecting – no change needed } ``` Always check for `null` before using the result. The `Promise` resolves to `null` in two scenarios: 1. The user closes the callee tool window without calling `returnData`. -2. The callee explicitly calls `returnData({})` with an empty object (treat as cancelled if you expect specific fields). +2. The user clicks the **"Return to [CallerTool]"** banner before the callee calls `returnData`. --- -### 2.3 Connection overrides +### 2.3 Connection auto-inheritance and overrides -By default, the callee inherits no connection from the caller. Pass `options.primaryConnectionId` or `options.secondaryConnectionId` to forward a specific Dataverse connection: +By default, the callee **automatically inherits the caller's active FXS connection**. No additional configuration is needed: ```typescript -const connections = await toolboxAPI.connections.getActiveConnection(); +// Callee receives the same primary connection as this tool automatically +const result = await toolboxAPI.invocation.launchTool("@my-org/entity-picker", { entityName: "account" }); +``` -const result = await toolboxAPI.invocation.launchTool( - "@my-org/solution-importer", - { solutionName: "MySolution" }, - { primaryConnectionId: connections?.id ?? null }, -); +To override with a specific connection, pass `options.primaryConnectionId`: + +```typescript +const result = await toolboxAPI.invocation.launchTool("@my-org/solution-importer", { solutionName: "MySolution" }, { primaryConnectionId: specificConnectionId }); +``` + +Pass `null` to launch the callee with no connection: + +```typescript +const result = await toolboxAPI.invocation.launchTool("@my-org/entity-picker", {}, { primaryConnectionId: null }); +``` + +--- + +### 2.4 Tag-based capability discovery + +Discover installed tools that support a given capability by querying their declared `capabilities` tags: + +```typescript +const pickers = await toolboxAPI.invocation.findToolsByCapability("entity-picker"); +// pickers: Tool[] — all installed tools with "entity-picker" in their capabilities + +if (pickers.length > 0) { + const picker = pickers[0] as { id: string }; + const result = await toolboxAPI.invocation.launchTool(picker.id, { entityName: "account" }); +} +``` + +**Signature:** + +```typescript +findToolsByCapability(tag: CapabilityTag): Promise +getKnownCapabilityTags(): Promise> ``` +`findToolsByCapability` returns an array of matching installed `Tool` objects (empty array if none found). + +`getKnownCapabilityTags` returns the full capability registry (fetched from Supabase at runtime, cached for 5 minutes; falls back to a built-in list when offline). + +#### Well-known capability tags + +The following tags are registered in the official capability registry. Use them to ensure your tool is discoverable by callers without typos: + +| Tag | Description | +| ------------------- | -------------------------------------------- | +| `fetchxml` | Accept or process FetchXML queries | +| `entity-picker` | Browse and select a Dataverse entity (table) | +| `record-selector` | Browse and select a Dataverse record | +| `solution-selector` | Pick a Power Platform solution | + +> **Note:** The registry is configurable without an app update — new tags are added to the Supabase `capability_tags` table and become immediately discoverable at runtime via `getKnownCapabilityTags()`. The TypeScript `KnownCapabilityTag` union type and `pptb-validate` are updated in the next `@pptb/types` release. + +> **IDE auto-complete:** Import `CapabilityTag` from `@pptb/types/pptbConfig` to get IntelliSense for known tags: +> +> ```typescript +> import type { CapabilityTag } from "@pptb/types/pptbConfig"; +> ``` + --- -### 2.4 Complete caller example +### 2.5 Complete caller example ```typescript async function openEntityPicker(entityName: string) { @@ -324,9 +415,10 @@ async function openEntityPicker(entityName: string) { result = await toolboxAPI.invocation.launchTool( "@my-org/entity-picker", { entityName, allowMultiSelect: false }, + // primaryConnectionId omitted → callee inherits this tool's FXS connection ); } catch (err) { - // Tool not installed or launch failed + // Tool not installed, already has an active callee, or launch failed await toolboxAPI.utils.showNotification({ title: "Cannot open picker", body: err instanceof Error ? err.message : String(err), @@ -336,7 +428,7 @@ async function openEntityPicker(entityName: string) { } if (result === null) { - // User dismissed the picker + // User dismissed the picker (closed window or clicked "Return to Caller" banner) return; } @@ -347,7 +439,208 @@ async function openEntityPicker(entityName: string) { --- -## Lifecycle and Behaviour +## End-to-End Scenario: FXS "Send To" Flyout + +This section illustrates a concrete real-world scenario where **FetchXML Studio (FXS)** exposes a "Send To ▾" flyout button that lets users push the current FetchXML query directly into another installed tool — such as **DRB** (Dataverse Rest Builder) or **DMS** (Data Migration Studio) — without expecting a return value. + +### Scenario summary + +| Step | What happens | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | User composes a FetchXML query in FXS. | +| 2 | User clicks the **"Send To ▾"** flyout button in FXS. | +| 3 | PPTB queries all installed tools that declare the `"fetchxml"` capability — both DRB and DMS qualify. The flyout lists them as options. | +| 4 | User selects **DMS**. | +| 5 | PPTB opens DMS, inheriting FXS's active Dataverse connection as the primary connection. | +| 6 | If DMS requires a **secondary connection** (e.g. it is a multi-connection tool for cross-environment migration), PPTB automatically shows the **multi-connection selector** before launching DMS — the user picks the second connection. | +| 7 | DMS opens pre-populated with the FetchXML from step 1. | +| 8 | Because `noReturn: true` was set, **no banner is shown** in the DMS window — FXS does not expect data back. | +| 9 | The user continues in DMS independently. Closing DMS resolves the Promise on the FXS side with `null`. | + +--- + +### Step 1 – Callee tools declare the `"fetchxml"` capability + +Both DRB and DMS include the following in their `pptb.config.json`: + +**DRB `pptb.config.json`** + +```json +{ + "invocation": { + "version": "1.0.0", + "capabilities": ["fetchxml"], + "prefill": { + "properties": { + "fetchXml": { "type": "string" } + } + } + } +} +``` + +**DMS `pptb.config.json`** — DMS is a multi-connection tool so it has no `returnTopic`; it uses the FetchXML purely as input. + +```json +{ + "invocation": { + "version": "1.0.0", + "capabilities": ["fetchxml"], + "prefill": { + "properties": { + "fetchXml": { "type": "string" } + } + } + } +} +``` + +> DMS's multi-connection requirement is declared in the standard tool manifest (`pptb.package.json`): +> +> ```json +> { "features": { "multiConnection": "required" } } +> ``` +> +> PPTB detects this at launch time and automatically prompts the user to select a secondary connection when none is available from the caller. + +--- + +### Step 2 – FXS builds its "Send To" flyout from discovered tools + +FXS calls `findToolsByCapability` on startup to discover all installed `fetchxml`-capable tools, then renders a flyout button for each one: + +```typescript +// fxs/index.ts — called during tool initialisation +async function setupSendToFlyout() { + // Discover all installed tools that accept fetchxml + const fetchXmlTools = await toolboxAPI.invocation.findToolsByCapability("fetchxml"); + + // Render one "Send to [ToolName]" item in the flyout for each discovered tool + renderSendToFlyout(fetchXmlTools as Array<{ id: string; name: string }>); +} +``` + +--- + +### Step 3 – User selects DMS; FXS launches it with `noReturn: true` + +When the user picks "Send to DMS" from the flyout, FXS calls `launchTool` with `noReturn: true` to signal that it does not expect DMS to return data: + +```typescript +// fxs/index.ts +async function sendCurrentQueryToTool(targetToolId: string) { + const currentFetchXml = getEditorContent(); // e.g. "" + + try { + // noReturn: true → DMS will NOT call returnData back to FXS. + // The Promise resolves with null when DMS closes or the user clicks "Return to FXS". + await toolboxAPI.invocation.launchTool( + targetToolId, + { fetchXml: currentFetchXml }, + { + // primaryConnectionId omitted → FXS's active connection is inherited by DMS + // secondaryConnectionId omitted → PPTB shows multi-connection selector if DMS requires it + noReturn: true, + }, + ); + // Execution reaches here once DMS is closed (or user clicks "Return to FXS"). + // No result data to process. + } catch (err) { + // Tool not installed, connection selection cancelled, or already has an active callee. + toolboxAPI.utils.showNotification({ + title: "Send To failed", + body: err instanceof Error ? err.message : String(err), + type: "error", + }); + } +} +``` + +**What PPTB does behind the scenes:** + +1. Looks up the DMS tool manifest. +2. Detects that DMS has `features.multiConnection: "required"` and no secondary connection was provided → opens the **multi-connection selector modal** in the PPTB shell. The user selects the target environment connection. +3. Launches DMS with FXS's primary connection and the user-selected secondary connection. +4. Pre-populates DMS with `{ fetchXml: "…" }`. +5. Because `noReturn: true` was set, **no "Return to FXS" banner is shown** in the DMS window. + +--- + +### Step 4 – DMS reads the prefill data and uses it + +DMS starts normally and reads the FetchXML from its launch context: + +```typescript +// dms/index.ts +async function main() { + const ctx = await toolboxAPI.invocation.getLaunchContext(); + + if (ctx) { + // Launched by FXS (or another tool) with a fetchXml payload + const fetchXml = ctx.fetchXml as string | undefined; + if (fetchXml) { + loadQueryIntoEditor(fetchXml); + } + // DMS does NOT call returnData — FXS launched it with noReturn: true. + // The user works in DMS independently; closing DMS resolves FXS's Promise with null. + } else { + // Standalone launch — show empty editor + renderEmptyEditor(); + } +} + +main(); +``` + +> **Note:** DMS does not need to detect `noReturn` explicitly. The behaviour is the same as any other invocation: if `returnData` is never called and the tool is closed, the caller's Promise resolves with `null`. The `noReturn` flag only suppresses the banner — it does not change the invocation lifecycle. + +--- + +### Step 5 – DMS closes; FXS Promise resolves + +Because `noReturn: true` was set, no banner is shown in the DMS window. The user works in DMS and closes it normally (or closes the tab). PPTB resolves FXS's Promise with `null`. + +| Action | Result | +| ---------------------- | ------------------------------------------------ | +| Close DMS tab normally | DMS is closed; FXS Promise resolves with `null`. | + +--- + +### Full sequence diagram + +``` +FXS (Caller) PPTB Shell DMS (Callee) +──────────── ────────── ──────────── +findToolsByCapability("fetchxml") + → [DRB, DMS] + +User clicks "Send to DMS" +launchTool("dms", { fetchXml }, + { noReturn: true }) + │ + ▼ + DMS needs secondary connection + → show multi-connection selector + ← user picks target env + │ + ▼ + Launch DMS (primary = FXS conn, + secondary = user pick) + No banner shown (noReturn: true) + │ + ▼ + getLaunchContext() + → { fetchXml: "…" } + loadQueryIntoEditor(fetchXml) + // user works in DMS … + +User closes DMS tab normally + ◄────────────────────────────────────────────── +Promise resolves (null) DMS closed +// No result to process +``` + +--- Understanding the full lifecycle helps when reasoning about edge cases: @@ -355,38 +648,51 @@ Understanding the full lifecycle helps when reasoning about edge cases: Caller tool calls invocation.launchTool(...) │ ▼ +One-at-a-time check: rejects if caller already has an active callee + │ + ▼ PPTB main process creates a new BrowserView for the callee +FXS connection auto-inherited from caller (unless overridden) │ ▼ Callee loads, receives toolContext with: • toolId, instanceId • callerInstanceId ← present only for invocations • prefillData ← the object passed by the caller + • connectionId ← auto-inherited from caller's FXS connection + │ + ▼ +PPTB injects "Return to [CallerToolName]" banner in the callee window +(skipped if launchTool was called with noReturn: true) │ ▼ Callee calls getLaunchContext() → returns prefillData │ ▼ (user interacts with callee UI) │ - ┌───┴────────────────────────┐ - │ │ - ▼ ▼ -Callee calls returnData(...) Callee window is closed - │ │ - ▼ ▼ -PPTB sends result to caller PPTB sends null to caller - │ │ - └───────────────┬────────────┘ - │ - ▼ - Caller's Promise resolves (returnData value OR null) + ┌───┴──────────────────────────────────────┐──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +Callee calls returnData(...) User closes callee window User clicks "Return to Caller" + │ │ banner button + ▼ ▼ │ +PPTB sends result to caller PPTB sends null to caller ▼ +PPTB auto-closes callee window │ PPTB sends null to caller + │ │ PPTB auto-closes callee window + └──────────────────┬──────────────────────┘──────────────────────────┘ + │ + ▼ + Caller's Promise resolves (returnData value OR null) ``` **Key points:** - The callee opens in its **own window** (BrowserView) and is visible as a separate tab in the PPTB tool panel. -- The caller's `launchTool()` Promise **never rejects** under normal operation – it always resolves (possibly with `null`). Rejections only occur if the target tool is not installed or if the launch itself fails due to a system error. -- It is the callee's responsibility to **close its own window** after calling `returnData`, if appropriate for the UX. PPTB does not close the callee automatically. +- The caller's `launchTool()` Promise **never rejects** under normal operation – it always resolves (possibly with `null`). Rejections only occur if the target tool is not installed, if the caller already has an active callee, or if the launch itself fails. +- **Auto-close**: after `returnData` is called, PPTB automatically closes the callee window. The callee does **not** need to close itself. +- **Banner early-return**: if the user clicks the "Return to [CallerToolName]" banner before the callee calls `returnData`, the caller's Promise resolves with `null` and the callee window is closed. This is treated the same as closing the window. +- **Banner dismiss (✕)**: clicking the dismiss button hides the banner for the session but does **not** end the invocation. The callee stays open and can still call `returnData` normally. +- **One-at-a-time**: only one active callee per caller is allowed. A second `launchTool` call from the same caller while a callee is active rejects with `"A callee invocation is already in progress"`. - A callee that never calls `returnData` will keep the caller's Promise pending until the callee window is closed by the user. --- @@ -406,24 +712,33 @@ npx pptb-validate The validator checks: - `invocation.version` is present and a valid semver string. +- `invocation.capabilities` (when present) is an array of non-empty strings where each value is a recognised capability tag (warning issued for unrecognised tags — see well-known tags above). - `invocation.prefill.properties` values are valid JSON-schema property descriptors. - `invocation.returnTopic.properties` values are valid JSON-schema property descriptors. +The validator prints the list of known capability tags in its output summary so you can quickly check which tags are available. + ### TypeScript types The `@pptb/types` package ships type definitions for the entire invocation API: ```typescript -// The three methods live on toolboxAPI.invocation -toolboxAPI.invocation.getLaunchContext() // Promise | null> -toolboxAPI.invocation.returnData(data) // Promise -toolboxAPI.invocation.launchTool(...) // Promise +// All methods live on toolboxAPI.invocation +toolboxAPI.invocation.getLaunchContext() // Promise | null> +toolboxAPI.invocation.returnData(data) // Promise (auto-closes callee after call) +toolboxAPI.invocation.launchTool(...) // Promise +toolboxAPI.invocation.findToolsByCapability(tag) // Promise — tag is CapabilityTag +toolboxAPI.invocation.getKnownCapabilityTags() // Promise> ``` -For the shape of `pptb.config.json`, import from the bundled declaration file: +For auto-complete on capability tags, import `CapabilityTag` / `KnownCapabilityTag` from the bundled declaration file: ```typescript -import type { PPTBConfig, InvocationConfig } from "@pptb/types/pptbConfig"; +import type { PPTBConfig, InvocationConfig, CapabilityTag, KnownCapabilityTag } from "@pptb/types/pptbConfig"; + +// IDE will suggest known tags when typing: +const tag: CapabilityTag = "fetchxml"; +const tools = await toolboxAPI.invocation.findToolsByCapability(tag); ``` --- @@ -434,28 +749,41 @@ import type { PPTBConfig, InvocationConfig } from "@pptb/types/pptbConfig"; The target tool is not installed. Ask the user to install it from the PPTB Marketplace, or check that the `targetToolId` matches the exact npm package name (`name` field in the tool's `package.json`). +### `launchTool` throws "A callee invocation is already in progress" + +Your tool already has an active callee open. Wait for the current invocation to resolve (or reject) before calling `launchTool` again. Only one callee per caller is supported in this phase. + ### `getLaunchContext()` returns `null` when expecting prefill data The tool was opened by the user directly rather than via `launchTool`. Ensure the caller is using `toolboxAPI.invocation.launchTool()` and not the standard tool launch mechanism. ### Caller `Promise` resolves with `null` unexpectedly -The callee window was closed by the user (or programmatically) before `returnData()` was called. This is by design – always handle the `null` case in the caller. +One of the following occurred: + +1. The callee window was closed by the user before `returnData()` was called. +2. The user clicked the "Return to [CallerTool]" banner button before the callee called `returnData()`. + +Both scenarios are by design — always handle the `null` case in the caller. ### Changes to `pptb.config.json` are not picked up -Restart the tool or reload it in PPTB. The config file is read at install/load time; changes during development require a reload. +Capabilities and the invocation contract are read when a tool is **installed**. If you change `pptb.config.json` in a locally-loaded development tool, reload or reinstall the tool in PPTB. ### `returnData` appears to do nothing Confirm that `getLaunchContext()` returned a non-null value first. If `getLaunchContext()` returns `null`, `returnData` is a no-op because the tool was not launched by another tool. +### `findToolsByCapability` returns an empty array + +No installed tools declare the queried capability tag in their `pptb.config.json`. Check that the target tool's `pptb.config.json` has the correct tag in `invocation.capabilities` and was reinstalled after the change. + --- ## References -- [`packages/toolboxAPI.d.ts`](../packages/toolboxAPI.d.ts) – Full TypeScript type definitions for the invocation API (`InvocationAPI` interface) +- [`packages/toolboxAPI.d.ts`](../packages/toolboxAPI.d.ts) – Full TypeScript type definitions for the invocation API (`InvocationAPI`) - [`packages/pptbConfig.d.ts`](../packages/pptbConfig.d.ts) – Type definitions for `pptb.config.json` (`PPTBConfig`, `InvocationConfig`) - [`packages/README.md`](../packages/README.md) – Developer guide for the `@pptb/types` package, including the API reference -- [`src/main/managers/toolWindowManager.ts`](../src/main/managers/toolWindowManager.ts) – Host-side implementation (`launchToolWithContext`, `resolveInvocation`) +- [`src/main/managers/toolWindowManager.ts`](../src/main/managers/toolWindowManager.ts) – Host-side implementation (`launchToolWithContext`, `resolveInvocation`, `activeCallees`) - [`src/main/toolPreloadBridge.ts`](../src/main/toolPreloadBridge.ts) – Preload-side implementation of the `invocation` namespace diff --git a/package.json b/package.json index 77109911..ea60b0c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "powerplatform-toolbox", - "version": "1.2.2", + "version": "1.2.3", "description": "A universal desktop app that contains multiple tools to ease the customization and configuration of Power Platform", "main": "dist/main/index.js", "scripts": { diff --git a/packages/README.md b/packages/README.md index 5ad037ba..4c4a7d5d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -119,6 +119,7 @@ In addition to `package.json`, the validator automatically checks a `pptb.config | Field | Required | Rules | | --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | | `invocation.version` | ✅\*\* | Must be a valid **semantic version** string (e.g. `"1.0.0"`). Tool developers own this version and bump it when the invocation contract changes. | +| `invocation.capabilities` | ❌ | Array of non-empty string tags (e.g. `["entity-picker"]`). Used by callers to discover this tool via `findToolsByCapability`. | | `invocation.prefill` | ❌ | JSON-schema-style object describing data callers can pre-populate | | `invocation.prefill.properties` | ❌ | Map of property names to `{ type?, enum?, items? }` descriptors | | `invocation.returnTopic` | ❌ | JSON-schema-style object describing the data this tool returns to its caller | @@ -132,6 +133,7 @@ In addition to `package.json`, the validator automatically checks a `pptb.config { "invocation": { "version": "1.0.0", + "capabilities": ["entity-picker"], "prefill": { "properties": { "entityName": { "type": "string" }, @@ -255,8 +257,8 @@ const terminal = await toolboxAPI.terminal.create({ cwd: "/path/to/directory", }); -// Execute a command -const result = await toolboxAPI.terminal.execute(terminal.id, "npm install"); +// Execute a command (most commands are allowed; shells and privilege-escalation tools are blocked) +const result = await toolboxAPI.terminal.execute(terminal.id, "pac auth list"); console.log("Exit code:", result.exitCode); console.log("Output:", result.output); @@ -296,13 +298,29 @@ Tools can launch one another and pass data between them using the `invocation` n ```typescript // Tool A – launches the entity-picker tool and waits for a selection +// The callee automatically inherits this tool's FXS connection const result = await toolboxAPI.invocation.launchTool( "@my-org/entity-picker", { entityName: "account", allowMultiSelect: false }, ); -if (result) { +if (result !== null) { console.log("Selected record id:", (result as { selectedId: string }).selectedId); +} else { + // User dismissed the picker (closed window or clicked "Return to Caller" banner) +} +``` + +> **One-at-a-time**: only one active callee per caller is supported. A second `launchTool` call while a callee is open throws `"A callee invocation is already in progress"`. + +#### Caller: tag-based capability discovery + +```typescript +// Find all installed tools that declare the "entity-picker" capability +const pickers = await toolboxAPI.invocation.findToolsByCapability("entity-picker"); +if (pickers.length > 0) { + const picker = pickers[0] as { id: string }; + const result = await toolboxAPI.invocation.launchTool(picker.id, { entityName: "account" }); } ``` @@ -317,12 +335,17 @@ if (ctx) { // When the user makes their selection: await toolboxAPI.invocation.returnData({ selectedId: "a1b2c3...", selectedName: "Contoso" }); + // PPTB automatically closes this window after delivering the result } ``` > **Tip:** A tool that was *not* launched by another tool receives `null` from `getLaunchContext()`. > Use this to show a standalone UI or redirect accordingly. +> **Auto-close**: after calling `returnData`, PPTB automatically closes the callee window. The callee does **not** need to close itself. + +> **Banner early-return**: PPTB injects a "Return to [CallerToolName]" banner in the callee window. If the user clicks it before `returnData` is called, the caller's Promise resolves with `null` and the callee window is closed. + #### Declaring your invocation contract Add a `pptb.config.json` alongside your `package.json` to tell callers what data you expect and return: @@ -331,6 +354,7 @@ Add a `pptb.config.json` alongside your `package.json` to tell callers what data { "invocation": { "version": "1.0.0", + "capabilities": ["entity-picker"], "prefill": { "properties": { "entityName": { "type": "string" }, @@ -557,9 +581,13 @@ Core platform features organized into namespaces: - **create(options: TerminalOptions)**: Promise - Creates a new terminal attached to the tool (tool ID is auto-determined) + - Uses the preferred shell specified via `TerminalOptions.shell` if it is installed on the machine, otherwise falls back to the system default shell - **execute(terminalId: string, command: string)**: Promise - Executes a command in the specified terminal and returns its result + - Only commands that are **not** on the blocked list are executed; blocked commands are shell interpreters (`bash`, `sh`, `powershell`, `cmd`, etc.) and privilege-escalation tools (`sudo`, `su`, `runas`, etc.) + - `npx --shell/-c` flags are blocked to prevent shell pivot via npx; unquoted command substitution (`$(…)` and backticks) is also rejected + - Compound commands using `&&`, `||`, `;`, or `|` are supported — each segment is individually validated against the blocklist - **close(terminalId: string)**: Promise - Closes the specified terminal @@ -602,12 +630,17 @@ Core platform features organized into namespaces: - Returns the prefill data passed by the tool that launched this tool, or `null` when not launched via inter-tool invocation - **returnData(returnData: Record\)**: Promise\ - - Sends data back to the caller tool and signals completion; no-op if not launched by another tool + - Sends data back to the caller tool and signals completion; PPTB **automatically closes the callee window** after delivery; no-op if not launched by another tool - **launchTool(targetToolId, prefillData?, options?)**: Promise\ - Launches the specified tool, optionally with prefill data - - Returns a Promise that resolves with the data returned by the callee (or `null` if it closes without returning) - - `options.primaryConnectionId` / `options.secondaryConnectionId` – override connection for the callee + - Returns a Promise that resolves with the data returned by the callee, or `null` if it closes without returning or the user clicks the "Return to Caller" banner + - The callee automatically inherits the caller's FXS connection; pass `options.primaryConnectionId` to override + - Only one active callee per caller is allowed; throws `"A callee invocation is already in progress"` if a callee is already open + - Pass `options.noReturn: true` for one-way "Send To" flows; the banner is suppressed entirely for the callee + +- **findToolsByCapability(tag: string)**: Promise\ + - Returns all installed tools that declare the given capability tag in their `pptb.config.json` ### Dataverse API (`window.dataverseAPI`) diff --git a/packages/bin/pptb-validate.js b/packages/bin/pptb-validate.js index 110f447d..71ba8b9b 100644 --- a/packages/bin/pptb-validate.js +++ b/packages/bin/pptb-validate.js @@ -16,7 +16,7 @@ const fs = require("fs"); const path = require("path"); -const { validatePackageJson, validatePPTBConfig } = require("../lib/validate"); +const { validatePackageJson, validatePPTBConfig, KNOWN_CAPABILITY_TAGS } = require("../lib/validate"); // ANSI colour helpers – gracefully degrade when colours are unsupported const NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR; @@ -219,9 +219,15 @@ async function main() { console.log(` Features : multiConnection=${info.features.multiConnection}${info.features.minAPI ? `, minAPI=${info.features.minAPI}` : ""}`); } if (configResult !== null && configResult.packageInfo && configResult.packageInfo.invocation) { - console.log(` Invocation : version=${configResult.packageInfo.invocation.version}`); + const inv = configResult.packageInfo.invocation; + console.log(` Invocation : version=${inv.version}`); + if (Array.isArray(inv.capabilities) && inv.capabilities.length > 0) { + console.log(` Capabilities: ${inv.capabilities.join(", ")}`); + } } console.log(); + console.log(c.dim(`Known capability tags: ${KNOWN_CAPABILITY_TAGS.join(", ")}`)); + console.log(); } else { console.log(c.red(c.bold("✖ Validation failed"))); console.log(); diff --git a/packages/lib/validate.js b/packages/lib/validate.js index 79387e6d..6cc60de7 100644 --- a/packages/lib/validate.js +++ b/packages/lib/validate.js @@ -55,6 +55,30 @@ const VALID_MULTI_CONNECTION_VALUES = ["required", "optional", "none"]; // Semver regex for minAPI validation const SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/; +/** + * Built-in list of well-known capability tags. + * Mirrors the entries in the Supabase `capability_tags` table. + * New tags are added to Supabase first (configurable without a deploy) and are + * then reflected here in the next package release so offline validation stays current. + * + * When a tool declares a tag that is not in this list the validator emits a warning + * (not an error) so existing tools are not broken by registry updates. + * + * **Keep in sync with `BUILT_IN_CAPABILITY_TAGS` in + * `src/main/managers/toolRegistryManager.ts`.** Because this file is a standalone + * Node.js module that cannot import from the Electron/TypeScript source tree, both + * lists must be updated together whenever a new tag is added. + */ +const KNOWN_CAPABILITY_TAGS = [ + "fetchxml", + "entity-picker", + "record-selector", + "solution-selector", + "webresource-editor", + "plugin-inspector", + "pcf-control-builder", +]; + /** * Checks if a string is a valid URL. * @param {string} url @@ -334,7 +358,7 @@ async function validatePackageJson(packageJson, options = {}) { }; } -module.exports = { validatePackageJson, validatePPTBConfig, isValidUrl, APPROVED_LICENSES }; +module.exports = { validatePackageJson, validatePPTBConfig, isValidUrl, APPROVED_LICENSES, KNOWN_CAPABILITY_TAGS }; /** * Validates a tool's pptb.config.json against the official review criteria. @@ -390,6 +414,25 @@ function validatePPTBConfig(config) { validateJsonSchemaProperties("invocation.returnTopic", inv.returnTopic.properties, errors); } } + + // invocation.capabilities – optional array of non-empty strings + if (inv.capabilities !== undefined) { + if (!Array.isArray(inv.capabilities)) { + errors.push("invocation.capabilities must be an array"); + } else { + inv.capabilities.forEach((cap, idx) => { + if (typeof cap !== "string" || cap.trim().length === 0) { + errors.push(`invocation.capabilities[${idx}] must be a non-empty string`); + } else if (!KNOWN_CAPABILITY_TAGS.includes(cap.trim())) { + warnings.push( + `invocation.capabilities[${idx}] "${cap}" is not a recognised capability tag. ` + + `Known tags: ${KNOWN_CAPABILITY_TAGS.join(", ")}. ` + + "If this is a new tag, ensure it has been added to the capability registry.", + ); + } + }); + } + } } } diff --git a/packages/package.json b/packages/package.json index 446d86fd..616b2624 100644 --- a/packages/package.json +++ b/packages/package.json @@ -1,6 +1,6 @@ { "name": "@pptb/types", - "version": "1.2.2", + "version": "1.2.3", "description": "Type definitions for Power Platform ToolBox APIs and validity checks for tool packages", "main": "index.d.ts", "types": "index.d.ts", diff --git a/packages/pptbConfig.d.ts b/packages/pptbConfig.d.ts index ef8c85f9..334a54cd 100644 --- a/packages/pptbConfig.d.ts +++ b/packages/pptbConfig.d.ts @@ -11,6 +11,7 @@ * { * "invocation": { * "version": "1.0.0", + * "capabilities": ["fetchxml"], * "prefill": { * "properties": { * "entityName": { "type": "string" }, @@ -29,6 +30,50 @@ * ``` */ +/** + * Well-known capability tags defined in the Power Platform ToolBox capability registry. + * + * Using one of these values provides IDE auto-complete and ensures compatibility with the + * official capability discovery system (`toolboxAPI.invocation.findToolsByCapability`). + * The authoritative list is maintained in the Supabase `capability_tags` table so new + * tags can be added without an app update. Fetch the current list at runtime via + * `toolboxAPI.invocation.getKnownCapabilityTags()`. + * + * | Tag | Description | + * | -------------------- | ----------------------------------------------------- | + * | `fetchxml` | Accept or process FetchXML queries | + * | `entity-picker` | Browse and select a Dataverse entity (table) | + * | `record-selector` | Browse and select a Dataverse record | + * | `solution-selector` | Pick a Power Platform solution | + * | `webresource-editor` | Edit or manage web resources | + * | `plugin-inspector` | Inspect or manage plugins and assemblies | + * | `pcf-control-builder`| Build or scaffold PCF controls | + */ +export type KnownCapabilityTag = + | "fetchxml" + | "entity-picker" + | "record-selector" + | "solution-selector" + | "webresource-editor" + | "plugin-inspector" + | "pcf-control-builder"; + +/** + * A capability tag string accepted by `invocation.capabilities` and + * `toolboxAPI.invocation.findToolsByCapability()`. + * + * `KnownCapabilityTag` values offer IDE auto-complete and are validated by + * `pptb-validate`. Custom strings are permitted for organisation-internal tags, + * but will produce a validation warning unless the tag appears in the official + * capability registry. + * + * @example + * ```json + * { "invocation": { "version": "1.0.0", "capabilities": ["fetchxml", "entity-picker"] } } + * ``` + */ +export type CapabilityTag = KnownCapabilityTag | (string & {}); + /** A JSON-schema-style property descriptor used inside invocation definitions. */ export interface JsonSchemaProperty { /** The JSON type of the value (e.g. "string", "number", "boolean", "object", "array"). */ @@ -67,6 +112,24 @@ export interface InvocationConfig { prefill?: JsonSchemaObject; /** Schema of the data this tool returns to its caller on completion. */ returnTopic?: JsonSchemaObject; + /** + * Capability tags declared by this tool. + * + * Callers use `toolboxAPI.invocation.findToolsByCapability(tag)` to discover tools + * that advertise a given capability. Prefer `KnownCapabilityTag` values for IDE + * auto-complete; custom strings are accepted but will produce a `pptb-validate` + * warning unless the tag is present in the official capability registry. + * + * Use `toolboxAPI.invocation.getKnownCapabilityTags()` at runtime to retrieve the + * full list from the registry (backed by a configurable Supabase table so new tags + * can be added without an app update). + * + * @example + * ```json + * { "invocation": { "version": "1.0.0", "capabilities": ["entity-picker", "fetchxml"] } } + * ``` + */ + capabilities?: CapabilityTag[]; } /** diff --git a/packages/toolboxAPI.d.ts b/packages/toolboxAPI.d.ts index 693568a8..1036b4da 100644 --- a/packages/toolboxAPI.d.ts +++ b/packages/toolboxAPI.d.ts @@ -82,7 +82,10 @@ declare namespace ToolBoxAPI { name: string; url: string; environment: "Dev" | "Test" | "UAT" | "Production"; - createdAt: string; + category?: string; + environmentColor?: string; + categoryColor?: string; + createdAt?: string; lastUsedAt?: string; /** * @deprecated isActive is a legacy field that is no longer persisted. @@ -109,9 +112,9 @@ declare namespace ToolBoxAPI { */ export interface TerminalOptions { name: string; - shell?: string; + shell?: string; // Preferred shell executable (e.g. "pwsh", "/bin/zsh"). Falls back to the system default if the requested shell is not found on the machine. cwd?: string; - env?: Record; + env?: Record; // PATH-like and shell bootstrap variables are filtered for tool security visible?: boolean; // Whether terminal should be visible initially (default: true) } @@ -455,9 +458,9 @@ declare namespace ToolBoxAPI { * Returns data back to the caller tool that launched this tool. * * The value resolves the `Promise` returned by the caller's - * `invocation.launchTool()` call. After calling `returnData`, the PPTB host - * will notify the caller; it is the callee's responsibility to close itself (or - * update its UI) after the return. + * `invocation.launchTool()` call. **After calling `returnData`, PPTB + * automatically closes the callee window** — the callee does not need to + * close itself. * * If this tool was not launched by another tool, the call is a no-op. * @@ -469,17 +472,62 @@ declare namespace ToolBoxAPI { * Launch another tool from within this tool and (optionally) pass prefill data. * * Returns a Promise that resolves with the data the target tool sends via - * `invocation.returnData()`, or `null` if the target tool closes without - * returning any data. + * `invocation.returnData()`, or `null` if: + * - the target tool closes without calling `returnData`, or + * - the user clicks the "Return to [this tool]" banner before the callee finalises. + * + * **One-at-a-time**: only one active callee per caller is supported. A second + * call while a callee is active throws `"A callee invocation is already in progress"`. + * + * **Connection auto-inheritance**: when `options.primaryConnectionId` is omitted, + * the callee automatically inherits the caller's active FXS connection. + * + * **Multi-connection auto-prompt**: when the callee declares + * `features.multiConnection: "required"` or `"optional"` and + * `options.secondaryConnectionId` is not provided, PPTB automatically shows + * the multi-connection selector before launching the callee. The Promise rejects + * if the user cancels the selector. * - * The target tool must be installed and its `pptb.config.json` must declare an - * `invocation.prefill` schema that matches the shape of `prefillData`. + * **`noReturn`**: pass `true` when the caller does not expect the callee to + * return data (e.g. a "Send To" pattern where data is only sent one-way). + * When set, the "Return to [Caller]" banner is suppressed entirely for the callee. + * The invocation lifecycle is otherwise identical — the Promise still resolves + * with `null` when the callee closes. * * @param targetToolId The npm package name (toolId) of the tool to launch * @param prefillData Data to pre-populate the target tool's state - * @param options Optional connection overrides for the target tool + * @param options Optional connection overrides and launch flags + */ + launchTool: (targetToolId: string, prefillData?: Record, options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null; noReturn?: boolean }) => Promise; + + /** + * Find installed tools that declare a given capability tag in their + * `pptb.config.json` (`invocation.capabilities` array). + * + * Use a `KnownCapabilityTag` literal from `@pptb/types` for IDE auto-complete: + * ```ts + * import type { KnownCapabilityTag } from "@pptb/types/pptbConfig"; + * const tools = await toolboxAPI.invocation.findToolsByCapability("fetchxml"); + * ``` + * + * @param tag The capability tag to search for (e.g. `"entity-picker"`) + * @returns Array of matching installed `ToolManifest` objects + */ + findToolsByCapability: (tag: import("./pptbConfig").CapabilityTag) => Promise; + + /** + * Returns the list of known (registered) capability tags from the capability registry. + * + * The registry is stored in a Supabase `capability_tags` table and fetched at + * startup (cached for 5 minutes). When Supabase is unavailable a built-in + * fallback list is returned, so the result is never empty. + * + * Use this at runtime to populate a "capabilities" picker or to validate a tag + * before calling `findToolsByCapability`. + * + * @returns Array of `{ tag: string; description: string }` entries ordered by tag name. */ - launchTool: (targetToolId: string, prefillData?: Record, options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null }) => Promise; + getKnownCapabilityTags: () => Promise>; } /** diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 2f83f788..0233eedd 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -77,6 +77,12 @@ export const TOOL_CHANNELS = { CHECK_TOOL_UPDATES: "check-tool-updates", UPDATE_TOOL: "update-tool", IS_TOOL_UPDATING: "is-tool-updating", + /** Fetch the list of registered capability tags from Supabase (with built-in fallback). */ + GET_KNOWN_CAPABILITY_TAGS: "get-known-capability-tags", + /** Check whether a beta (pre-release) npm package version exists for a given npm package name. */ + CHECK_BETA_PACKAGE: "check-beta-package", + /** Install the beta (pre-release) npm package for a registry tool. */ + INSTALL_PRERELEASE_TOOL: "install-prerelease-tool", } as const; // Tool Window-related IPC channels @@ -91,6 +97,33 @@ export const TOOL_WINDOW_CHANNELS = { HIDE_ALL: "tool-window:hide-all", RENDERER_INITIALIZED: "tool-window:renderer-initialized", RETURN_INVOCATION_DATA: "tool-window:return-invocation-data", + FIND_TOOLS_BY_CAPABILITY: "tool-window:find-tools-by-capability", + /** Pushed from main process to the PPTB renderer to show/hide the invocation banner. */ + INVOCATION_BANNER_STATE: "tool-window:invocation-banner-state", + /** + * Pushed from main process to the PPTB renderer when an invoked callee tool requires + * connections that were not inherited from the caller (e.g. secondary connection needed + * by a multi-connection tool). The renderer shows the multi-connection selector and + * replies via PROVIDE_INVOCATION_CONNECTIONS. + */ + INVOCATION_PROMPT_CONNECTIONS: "tool-window:invocation-prompt-connections", + /** + * Sent from the PPTB renderer back to the main process to provide the connection IDs + * selected by the user in response to an INVOCATION_PROMPT_CONNECTIONS request. + */ + PROVIDE_INVOCATION_CONNECTIONS: "tool-window:provide-invocation-connections", + /** + * Pushed from the main process to the PPTB renderer when a callee tool has been + * successfully launched via an inter-tool invocation. The renderer creates a new tab + * for the callee so it can be independently managed (switched to, closed, etc.). + */ + CALLEE_TOOL_OPENED: "tool-window:callee-tool-opened", + /** + * Pushed from the main process to the PPTB renderer when a callee tool has been + * auto-closed after returning data (resolveInvocation path). The renderer removes + * the callee's tab and switches back to the caller tool. + */ + CALLEE_TOOL_CLOSED: "tool-window:callee-tool-closed", } as const; // Terminal-related IPC channels diff --git a/src/common/types/api.ts b/src/common/types/api.ts index c0d0deb8..36faed94 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -9,7 +9,7 @@ import { DataverseConnection } from "./connection"; import { DataverseExecuteRequest } from "./dataverse"; import { CspConsentRecord, LastUsedToolEntry, LastUsedToolUpdate, UserSettings } from "./settings"; import { Terminal, TerminalOptions } from "./terminal"; -import { Tool, ToolContext, ToolSettings } from "./tool"; +import { CapabilityTagEntry, Tool, ToolContext, ToolSettings } from "./tool"; /** * Connections API namespace @@ -154,6 +154,7 @@ export interface ToolboxAPI { primaryConnectionId: string | null, secondaryConnectionId: string | null, prefillData: Record, + noReturn?: boolean, ) => Promise; switchToolWindow: (toolId: string) => Promise; closeToolWindow: (toolId: string) => Promise; @@ -161,6 +162,28 @@ export interface ToolboxAPI { getActiveToolWindow: () => Promise; getOpenToolWindows: () => Promise; updateToolConnection: (instanceId: string, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => Promise; + /** Find installed tools that declare a given capability tag in their pptb.config.json. */ + findToolsByCapability: (tag: string) => Promise; + /** Returns the list of known capability tags from the registry (Supabase-backed, with built-in fallback). */ + getKnownCapabilityTags: () => Promise; + /** Trigger banner "Return to Caller" — resolves the currently active callee's invocation with null and auto-closes it. */ + returnToCallerBanner: () => Promise; + /** Subscribe to invocation banner state changes (main → renderer push). */ + onInvocationBannerState: (callback: (state: { visible: boolean; callerToolName?: string }) => void) => void; + /** Subscribe to multi-connection prompts triggered when an invoked callee requires a secondary connection. */ + onInvocationConnectionsPrompt: (callback: (prompt: { requestId: string; toolName: string; isSecondaryRequired: boolean; inheritedPrimaryConnectionId: string | null }) => void) => void; + /** Provide the selected connection IDs in response to an INVOCATION_PROMPT_CONNECTIONS request (or null to cancel). */ + provideInvocationConnections: (requestId: string, result: { primaryConnectionId: string | null; secondaryConnectionId: string | null } | null) => Promise; + /** + * Subscribe to callee-tool-opened events. Fired once the callee BrowserView is ready + * so the renderer can create a dedicated tab for the callee instance. + */ + onCalleeToolOpened: (callback: (data: { calleeInstanceId: string; callerInstanceId: string; tool: Tool; primaryConnectionId: string | null; secondaryConnectionId: string | null }) => void) => void; + /** + * Subscribe to callee-tool-closed events. Fired after the callee is auto-closed by + * the main process so the renderer can remove the callee tab and return focus to the caller. + */ + onCalleeToolClosed: (callback: (data: { calleeInstanceId: string; callerInstanceId: string }) => void) => void; // Favorite tools addFavoriteTool: (toolId: string) => Promise; @@ -197,6 +220,10 @@ export interface ToolboxAPI { installToolFromRegistry: (toolId: string) => Promise<{ manifest: unknown; tool: Tool }>; checkToolUpdates: (toolId: string) => Promise<{ hasUpdate: boolean; latestVersion?: string }>; isToolUpdating: (toolId: string) => Promise; + /** Check whether a beta (pre-release) npm package version exists for the given npm package name. */ + checkBetaPackage: (npmPackageName: string) => Promise<{ hasBeta: boolean; betaVersion?: string }>; + /** Install the beta (pre-release) npm package for a registry tool and return the loaded Tool. */ + installPrereleaseToolFromNpm: (npmPackageName: string) => Promise; // Utils namespace utils: UtilsAPI; diff --git a/src/common/types/tool.ts b/src/common/types/tool.ts index 196a062b..e1b5a2fc 100644 --- a/src/common/types/tool.ts +++ b/src/common/types/tool.ts @@ -4,6 +4,20 @@ import { CspExceptions } from "./common"; +/** + * A single entry from the capability tag registry. + * + * The registry is stored in the Supabase `capability_tags` table and fetched at + * startup (with a TTL-based cache). A built-in fallback list is used when Supabase + * is unavailable so the application always has a baseline set of known tags. + */ +export interface CapabilityTagEntry { + /** The capability tag string (e.g. `"fetchxml"`, `"entity-picker"`). */ + tag: string; + /** Human-readable description of what the capability represents. */ + description: string; +} + /** * Tool features configuration */ @@ -52,6 +66,8 @@ export interface Tool { minAPI?: string; // Minimum ToolBox API version required maxAPI?: string; // Maximum ToolBox API version tested isSupported?: boolean; // Whether this tool is compatible with current ToolBox version + /** Invocation capability tags declared in pptb.config.json (e.g. ["entity-picker"]). */ + capabilities?: string[]; } /** @@ -82,6 +98,7 @@ export interface ToolRegistryEntry { website?: string; minAPI?: string; // Minimum ToolBox API version required (from features.minAPI) maxAPI?: string; // Maximum ToolBox API version tested (from npm-shrinkwrap @pptb/types version) + npmPackageName?: string; // npm package name used for pre-release version detection } /** @@ -113,6 +130,8 @@ export interface ToolManifest { createdAt?: string; minAPI?: string; // Minimum ToolBox API version required (from features.minAPI) maxAPI?: string; // Maximum ToolBox API version tested (from npm-shrinkwrap @pptb/types version) + /** Invocation capability tags declared in pptb.config.json (e.g. ["entity-picker"]). */ + capabilities?: string[]; } /** diff --git a/src/main/constants.ts b/src/main/constants.ts index bec93f64..b07890f7 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -45,3 +45,143 @@ export const MIN_SUPPORTED_API_VERSION = "1.0.17"; * packages/-/icon-dark.png – dark theme icon for the tool/version */ export const AZURE_BLOB_BASE_URL = process.env.AZURE_BLOB_BASE_URL || ""; + +/** + * Blocked terminal commands that tools are not allowed to execute through the TerminalManager. + * This is a security measure to prevent tools from executing potentially dangerous commands that could harm the user's system or compromise their security. + * Note that this is not an exhaustive list of all potentially dangerous commands, but it covers the most common ones. + * The check is done by comparing the command being executed against this list, ignoring case and allowing for additional arguments (e.g. "powershell -NoProfile" would still be blocked). + */ +export const BLOCKED_TERMINAL_COMMANDS = new Set([ + // Unix/macOS shells + "bash", + "sh", + "zsh", + "fish", + "csh", + "ksh", + "dash", + "tcsh", + // Windows shells and their .exe variants + "cmd", + "cmd.exe", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", + // Privilege escalation + "sudo", + "su", + "runas", + "doas", + "pkexec", + // Scripting interpreters with shell-escape or arbitrary-eval capability + "python", + "python3", + "python2", + "perl", + "ruby", + "irb", + "php", + "php.exe", + "lua", + "tclsh", + "wish", + // Pagers/editors with built-in shell escapes + "less", + "more", + "man", + "vi", + "vim", + "nvim", + "emacs", + "nano", + // Utilities with side-channel command execution + "find", + "awk", + "gawk", + "nawk", + "xargs", + "make", + // Remote access / tunneling / exfiltration + "ssh", + "scp", + "sftp", + "rsync", + "telnet", + "ftp", + "nc", + "ncat", + "netcat", + "socat", + // WSL (escapes the sandbox into a full Linux shell) + "wsl", + "wsl.exe", + // Windows LOLBins + "mshta.exe", + "wscript.exe", + "cscript.exe", + "regsvr32.exe", + "rundll32.exe", + "certutil.exe", + "bitsadmin.exe", + "forfiles.exe", + "wmic.exe", + "installutil.exe", + "msiexec.exe", + "reg.exe", + "schtasks.exe", + "vssadmin.exe", + "wevtutil.exe", + // macOS-specific + "osascript", + "open", + "launchctl", + "defaults", + "dscl", + // Linux-specific + "crontab", + "at", + "systemd-run", + "chroot", + "useradd", + "usermod", + "passwd", +]); + +/** + * Environment variables and npx flags that must be stripped from terminal sessions to prevent security issues or breakage. + * These are not necessarily blocked from being executed, but they will be removed from the environment or command line when a terminal session is created. + * For example, allowing PATH through could let a tool trick the user into executing a malicious version of a common command. + * Allowing "npx -s" or "npx --shell" could let a tool execute arbitrary shell commands, bypassing the blocked command list entirely. + * Note that this is not an exhaustive list of all potentially dangerous environment variables or npx flags, but it covers the most common ones. + */ +export const BLOCKED_TERMINAL_ENV_KEYS = new Set([ + "PATH", + "PATHEXT", + "COMSPEC", + "SHELL", + "NODE_OPTIONS", + "BASH_ENV", + "ENV", + "PROMPT_COMMAND", + "ZDOTDIR", + // Dynamic-linker / runtime injection + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + // Tool-specific command/option injection + "GIT_SSH_COMMAND", + "PYTHONSTARTUP", + "PERL5OPT", + "RUBYOPT", +]); + +/** + * Blocked npx flags that could be used to execute arbitrary shell commands, bypassing the blocked command list. + * This is a security measure to prevent tools from using npx as a way to execute commands that would otherwise be blocked. + * The check is done by looking for these flags anywhere in the command line, ignoring case and allowing for additional arguments (e.g. "npx -s" or "npx --shell" would both be blocked). + * For more information, see the npx documentation: https://www.npmjs.com/package/npx#options + */ +export const BLOCKED_NPX_FLAGS = new Set(["-c", "--call", "-s", "--shell"]); diff --git a/src/main/index.ts b/src/main/index.ts index be3157b0..389470a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -289,6 +289,7 @@ class ToolBoxApp { ipcMain.removeHandler(TOOL_CHANNELS.INSTALL_TOOL_FROM_REGISTRY); ipcMain.removeHandler(TOOL_CHANNELS.FETCH_REGISTRY_TOOLS); ipcMain.removeHandler(TOOL_CHANNELS.FETCH_COMMUNITY_LINKS); + ipcMain.removeHandler(TOOL_CHANNELS.GET_KNOWN_CAPABILITY_TAGS); ipcMain.removeHandler(TOOL_CHANNELS.CHECK_TOOL_UPDATES); ipcMain.removeHandler(TOOL_CHANNELS.UPDATE_TOOL); ipcMain.removeHandler(TOOL_CHANNELS.IS_TOOL_UPDATING); @@ -818,6 +819,11 @@ class ToolBoxApp { return await this.toolManager.fetchCommunityLinks(); }); + // Fetch known capability tags from Supabase (with built-in fallback) + ipcMain.handle(TOOL_CHANNELS.GET_KNOWN_CAPABILITY_TAGS, async () => { + return await this.toolManager.getKnownCapabilityTags(); + }); + // Check for tool updates ipcMain.handle(TOOL_CHANNELS.CHECK_TOOL_UPDATES, async (_, toolId) => { // DEV MOCK: randomly flag ~50% of tools as having an update available @@ -841,6 +847,18 @@ class ToolBoxApp { return this.toolManager.isToolUpdating(toolId); }); + // Check whether a beta (pre-release) npm package version is available + ipcMain.handle(TOOL_CHANNELS.CHECK_BETA_PACKAGE, async (_, npmPackageName: string) => { + return await this.toolManager.checkBetaPackage(npmPackageName); + }); + + // Install the beta (pre-release) npm package for a registry tool + ipcMain.handle(TOOL_CHANNELS.INSTALL_PRERELEASE_TOOL, async (_, npmPackageName: string) => { + const tool = await this.toolManager.installPrereleaseToolFromNpm(npmPackageName); + this.settingsManager.addInstalledTool(tool.id); + return tool; + }); + // Debug mode only - npm-based installation for tool developers ipcMain.handle(TOOL_CHANNELS.INSTALL_TOOL, async (_, packageName) => { await this.toolManager.installToolForDebug(packageName); diff --git a/src/main/managers/browserviewProtocolManager.ts b/src/main/managers/browserviewProtocolManager.ts index fc9f6ebc..378b90aa 100644 --- a/src/main/managers/browserviewProtocolManager.ts +++ b/src/main/managers/browserviewProtocolManager.ts @@ -1,10 +1,10 @@ import { app, protocol } from "electron"; import * as fs from "fs"; import * as path from "path"; +import { logError, logInfo } from "../../common/logger"; import { normalizeCspExceptionSource } from "../../common/types"; import { SettingsManager } from "./settingsManager"; import { ToolManager } from "./toolsManager"; -import { logInfo, logError } from "../../common/logger"; /** * BrowserviewProtocolManager @@ -196,8 +196,21 @@ export class BrowserviewProtocolManager { // Local development tool return tool.localPath; } else if (tool.npmPackageName) { + /** + * Cleanup any version suffix from the package name to get the correct directory + * Handle scoped packages (e.g., @scope/package) and version suffixes (e.g., package-1.0.0) + * Scope "@" is preserved, but version suffixes are removed + * Some packages may not have version suffixes, so we should check for both possibilities + * Example transformations: + * - "my-tool@1.0.0" => "my-tool" + * - "@scope/my-tool@1.0.0" => "@scope/my-tool" + * - "my-tool" => "my-tool" + * - "@scope/my-tool" => "@scope/my-tool" + */ + const packageDirName = tool.npmPackageName.replace(/@?([^@]+)(?:@[\d.]+)?$/, ""); + // Npm-installed tool (debug mode) - return path.join(this.toolsDir, "node_modules", tool.npmPackageName); + return path.join(this.toolsDir, "node_modules", packageDirName); } else if (tool.id) { // Registry-installed tool return path.join(this.toolsDir, tool.id); diff --git a/src/main/managers/terminalManager.ts b/src/main/managers/terminalManager.ts index dc5a75af..0b4bb32b 100644 --- a/src/main/managers/terminalManager.ts +++ b/src/main/managers/terminalManager.ts @@ -1,8 +1,353 @@ -import { ChildProcessWithoutNullStreams, spawn } from "child_process"; +import { ChildProcessWithoutNullStreams, execFileSync, spawn } from "child_process"; import { randomUUID } from "crypto"; import { EventEmitter } from "events"; -import { Terminal, TerminalCommandResult, TerminalOptions } from "../../common/types"; +import { existsSync } from "fs"; +import { basename, isAbsolute } from "path"; import { logInfo, logWarn } from "../../common/logger"; +import { Terminal, TerminalCommandResult, TerminalOptions } from "../../common/types"; +import { BLOCKED_NPX_FLAGS, BLOCKED_TERMINAL_COMMANDS, BLOCKED_TERMINAL_ENV_KEYS } from "../constants"; + +type ShellType = "posix" | "pwsh" | "cmd"; + +interface ParsedTerminalCommand { + executable: string; + args: string[]; + /** The validated command string as entered by the user (leading/trailing whitespace trimmed), written verbatim to the shell's stdin. */ + rawCommand: string; +} + +interface QueuedCommand { + parsedCommand: ParsedTerminalCommand; + commandId: string; + resolve: (result: TerminalCommandResult) => void; +} + +interface PendingCommand { + commandId: string; + resolve: (result: TerminalCommandResult) => void; + output: string; + stderrBuffer: string; +} + +interface SanitizedTerminalEnv { + env?: Record; + strippedKeys: string[]; +} + +function sanitizeTerminalEnv(env?: Record): SanitizedTerminalEnv { + if (!env) { + return { strippedKeys: [] }; + } + + const strippedKeys: string[] = []; + const sanitizedEnv = Object.entries(env).reduce>((result, [key, value]) => { + const normalizedKey = key.toUpperCase(); + if (BLOCKED_TERMINAL_ENV_KEYS.has(normalizedKey)) { + strippedKeys.push(key); + return result; + } + + result[key] = value; + return result; + }, {}); + + return { + env: Object.keys(sanitizedEnv).length > 0 ? sanitizedEnv : undefined, + strippedKeys, + }; +} + +function tokenizeTerminalCommand(command: string): string[] { + const tokens: string[] = []; + let currentToken = ""; + let activeQuote: '"' | "'" | null = null; + let isEscaped = false; + + for (let index = 0; index < command.length; index += 1) { + const char = command[index]; + + if (isEscaped) { + currentToken += char; + isEscaped = false; + continue; + } + + if (char === "\\" && activeQuote !== "'") { + const nextChar = command[index + 1]; + // Commands are executed with shell:false, so tokenization only needs to preserve quoted + // arguments and escaped whitespace/quotes without emulating full shell parsing. + if (nextChar === '"' || nextChar === "'" || nextChar === "\\" || /\s/.test(nextChar || "")) { + isEscaped = true; + continue; + } + } + + if (activeQuote) { + if (char === activeQuote) { + activeQuote = null; + } else { + currentToken += char; + } + continue; + } + + if (char === '"' || char === "'") { + activeQuote = char; + continue; + } + + if (/\s/.test(char)) { + if (currentToken) { + tokens.push(currentToken); + currentToken = ""; + } + continue; + } + + currentToken += char; + } + + if (activeQuote) { + throw new Error("Terminal command contains an unterminated quote."); + } + + if (isEscaped) { + throw new Error("Terminal command ends with an incomplete escape sequence."); + } + + if (currentToken) { + tokens.push(currentToken); + } + + return tokens; +} + +/** + * Checks for unquoted command substitution patterns ($(…) and backticks) that could + * be used to execute blocked commands indirectly when the raw command string is passed + * directly to the shell's stdin. + */ +function checkNoUnquotedSubstitution(command: string): void { + let activeQuote: '"' | "'" | null = null; + let isEscaped = false; + + for (let i = 0; i < command.length; i++) { + const char = command[i]; + const nextChar = command[i + 1]; + + if (isEscaped) { + isEscaped = false; + continue; + } + + if (char === "\\" && activeQuote !== "'") { + isEscaped = true; + continue; + } + + if (activeQuote) { + if (char === activeQuote) activeQuote = null; + continue; + } + + if (char === '"' || char === "'") { + activeQuote = char; + continue; + } + + if (char === "`") { + throw new Error("Terminal command contains unquoted command substitution."); + } + if (char === "$" && nextChar === "(") { + throw new Error("Terminal command contains unquoted command substitution."); + } + } +} + +/** + * Splits a command string on unquoted shell sequence/pipe operators: &&, ||, ;, | + * Respects single and double quoted strings. Returns trimmed, non-empty segments. + */ +function splitCompoundCommand(command: string): string[] { + const segments: string[] = []; + let current = ""; + let activeQuote: '"' | "'" | null = null; + let isEscaped = false; + + for (let i = 0; i < command.length; i++) { + const char = command[i]; + const nextChar = command[i + 1]; + + if (isEscaped) { + current += char; + isEscaped = false; + continue; + } + + if (char === "\\" && activeQuote !== "'") { + isEscaped = true; + current += char; + continue; + } + + if (activeQuote) { + if (char === activeQuote) activeQuote = null; + current += char; + continue; + } + + if (char === '"' || char === "'") { + activeQuote = char; + current += char; + continue; + } + + // && or || (two-character operators) + if ((char === "&" && nextChar === "&") || (char === "|" && nextChar === "|")) { + if (current.trim()) segments.push(current.trim()); + current = ""; + i++; // skip the second operator character + continue; + } + + // Single | (pipe) or ; (sequence) + if (char === "|" || char === ";") { + if (current.trim()) segments.push(current.trim()); + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) segments.push(current.trim()); + return segments.length > 0 ? segments : [command.trim()]; +} + +/** + * Validates a single command segment (executable + args) against the blocklist. + * A "segment" is one command from a compound expression, e.g. `git status` from + * `cd /tmp && git status`. + */ +function validateCommandSegment(segment: string): void { + const trimmed = segment.trim(); + if (!trimmed) return; + + const tokens = tokenizeTerminalCommand(trimmed); + if (tokens.length === 0) return; + + const rawExecutable = tokens[0]; + const executable = rawExecutable.toLowerCase(); + + if (/[;&|<>]/.test(rawExecutable)) { + throw new Error(`Terminal command executable "${rawExecutable}" contains shell metacharacters.`); + } + + if (BLOCKED_TERMINAL_COMMANDS.has(executable)) { + throw new Error(`Blocked unsafe terminal command "${rawExecutable}". Shell interpreters and privilege-escalation tools are not allowed.`); + } + + if (executable === "npx" && tokens.slice(1).some((arg) => BLOCKED_NPX_FLAGS.has(arg.toLowerCase()))) { + throw new Error("Blocked unsafe npx invocation. Shell execution flags are not allowed."); + } +} + +function parseTerminalCommand(command: string): ParsedTerminalCommand { + const trimmedCommand = command.trim(); + if (!trimmedCommand) { + throw new Error("Terminal command cannot be empty."); + } + + if (/[\r\n\u2028\u2029]/.test(trimmedCommand) || trimmedCommand.includes("\u000b") || trimmedCommand.includes("\u000c")) { + throw new Error("Multi-line terminal commands are not allowed."); + } + + // Reject unquoted command substitution so blocked commands cannot be reached via $() or backticks. + checkNoUnquotedSubstitution(trimmedCommand); + + // Split on compound operators and validate every constituent command. + const segments = splitCompoundCommand(trimmedCommand); + for (const segment of segments) { + validateCommandSegment(segment); + } + + // Extract the first segment's executable for logging; the raw command string is passed + // verbatim to the shell so that operators like && and || are handled natively. + const firstTokens = tokenizeTerminalCommand(segments[0]); + return { + executable: firstTokens[0].toLowerCase(), + args: firstTokens.slice(1), + rawCommand: trimmedCommand, + }; +} + +function getShellType(shell: string): ShellType { + const name = basename(shell) + .toLowerCase() + .replace(/\.exe$/, ""); + if (name === "pwsh" || name === "powershell") return "pwsh"; + if (name === "cmd") return "cmd"; + return "posix"; +} + +function getShellInteractiveArgs(shellType: ShellType): string[] { + if (shellType === "pwsh") return ["-NoLogo"]; + // cmd.exe with no args starts an interactive session that reads from stdin + if (shellType === "cmd") return []; + return ["-i"]; +} + +/** + * Builds the text written to the shell's stdin to execute a validated command. + * The raw command string is passed verbatim so that shell operators (&&, ||, ;, |) + * are handled natively by the persistent shell process. + * A unique sentinel marker is written to stderr after the command so the caller + * can detect completion and extract the exit code without polluting visible output. + */ +function buildShellCommandInput(parsedCommand: ParsedTerminalCommand, commandId: string, shellType: ShellType): string { + const cmd = parsedCommand.rawCommand; + const sentinel = `PPTB_CMD_END_${commandId}`; + + if (shellType === "pwsh") { + // Capture $? and $LASTEXITCODE immediately before any conditional to prevent them being overwritten + return `${cmd}\n$__pptb_ok__ = $?; $__pptb_lec__ = $LASTEXITCODE; $__pptb__ = if ($null -ne $__pptb_lec__) { $__pptb_lec__ } elseif ($__pptb_ok__) { 0 } else { 1 }; [Console]::Error.WriteLine("${sentinel}_$__pptb__")\n`; + } + if (shellType === "cmd") { + // Use >&2 (without the explicit 1) for stdout-to-stderr redirection in cmd.exe + return `${cmd}\r\necho ${sentinel}_%ERRORLEVEL% >&2\r\n`; + } + // POSIX: capture $? immediately after the command; write sentinel to stderr via printf + return `${cmd}\n__pptb__=$?; printf '%s\\n' "${sentinel}_$__pptb__" >&2\n`; +} + +/** + * Resolves the preferred shell requested by a tool, falling back to the system + * default if the requested shell cannot be found on the current machine. + */ +function resolveShell(requestedShell: string, defaultShell: string, terminalId: string): string { + // Reject paths containing null bytes or obvious shell metacharacters to prevent injection + if (/[\0;&|<>]/.test(requestedShell)) { + logWarn(`Requested shell "${requestedShell}" contains invalid characters for terminal ${terminalId}; falling back to ${defaultShell}`); + return defaultShell; + } + + if (isAbsolute(requestedShell)) { + if (existsSync(requestedShell)) { + return requestedShell; + } + logWarn(`Requested shell "${requestedShell}" not found for terminal ${terminalId}; falling back to ${defaultShell}`); + return defaultShell; + } + + try { + const finder = process.platform === "win32" ? "where" : "which"; + execFileSync(finder, [requestedShell], { encoding: "utf8", timeout: 3000 }); + return requestedShell; + } catch { + logWarn(`Requested shell "${requestedShell}" not found in PATH for terminal ${terminalId}; falling back to ${defaultShell}`); + return defaultShell; + } +} /** * Manages terminal instances for tools @@ -28,30 +373,12 @@ export class TerminalManager extends EventEmitter { } } - /** - * Check if a shell exists and is executable - */ - private async shellExists(shellPath: string): Promise { - return new Promise((resolve) => { - const testProcess = spawn(shellPath, ["--version"], { stdio: "ignore" }); - testProcess.on("error", () => resolve(false)); - testProcess.on("close", (code) => resolve(code !== null)); - }); - } - /** * Create a new terminal for a tool */ async createTerminal(toolId: string, toolInstanceId: string | null, options: TerminalOptions): Promise { const terminalId = randomUUID(); - let shell = options.shell || this.defaultShell; - - // Verify shell exists, fallback to default if not - if (options.shell && !(await this.shellExists(options.shell))) { - logWarn(`Shell ${options.shell} not found, using default shell ${this.defaultShell}`); - shell = this.defaultShell; - } - + const shell = options.shell ? resolveShell(options.shell, this.defaultShell, terminalId) : this.defaultShell; const cwd = options.cwd || process.cwd(); // Default to visible unless explicitly set to false @@ -68,7 +395,12 @@ export class TerminalManager extends EventEmitter { createdAt: new Date().toISOString(), }; - const instance = new TerminalInstance(terminal, options.env); + const { env: sanitizedEnv, strippedKeys: strippedEnvKeys } = sanitizeTerminalEnv(options.env); + if (strippedEnvKeys.length > 0) { + logWarn(`Ignoring restricted terminal environment variables for terminal ${terminalId}: ${strippedEnvKeys.join(", ")}`); + } + + const instance = new TerminalInstance(terminal, sanitizedEnv); this.terminals.set(terminalId, instance); // Forward terminal events with toolId for proper filtering @@ -99,7 +431,23 @@ export class TerminalManager extends EventEmitter { throw new Error(`Terminal ${terminalId} not found`); } - return instance.executeCommand(command); + let parsedCommand: ParsedTerminalCommand; + try { + parsedCommand = parseTerminalCommand(command); + } catch (error) { + const blockedResult: TerminalCommandResult = { + terminalId, + commandId: randomUUID(), + exitCode: 1, + error: error instanceof Error ? error.message : "Blocked unsafe terminal command.", + }; + + this.emit("terminal:error", { terminalId, toolId: instance.terminal.toolId, error: blockedResult.error }); + this.emit("terminal:command:completed", { ...blockedResult, toolId: instance.terminal.toolId }); + return blockedResult; + } + + return instance.executeCommand(parsedCommand); } /** @@ -194,164 +542,282 @@ export class TerminalManager extends EventEmitter { } /** - * Internal terminal instance that manages a shell process + * Internal terminal instance that owns a persistent shell process. + * Commands are written to the shell's stdin and completion is detected + * via a unique sentinel marker written to stderr. */ class TerminalInstance extends EventEmitter { public terminal: Terminal; - private process: ChildProcessWithoutNullStreams | null = null; - private commandQueue: Array<{ command: string; commandId: string; resolve: (result: TerminalCommandResult) => void; reject: (error: Error) => void }> = []; - private currentCommand: { commandId: string; output: string; resolve: (result: TerminalCommandResult) => void; reject: (error: Error) => void } | null = null; + private shellProcess: ChildProcessWithoutNullStreams | null = null; + private env?: Record; + private commandQueue: QueuedCommand[] = []; private isProcessing = false; + private pendingCommand: PendingCommand | null = null; + private shellType: ShellType; + /** Holds partial stdout data while a command is executing so sentinel echo lines can be filtered out before display. */ + private stdoutLineBuffer: string = ""; constructor(terminal: Terminal, env?: Record) { super(); this.terminal = terminal; - this.startShellProcess(env); + this.env = env; + this.shellType = getShellType(terminal.shell); + this.startShellProcess(); } /** - * Start the shell process + * Start the persistent shell process so the terminal is not blank when a tool loads. + * Wires up stdout/stderr for display output and sentinel-based command completion. */ - private startShellProcess(env?: Record): void { - const shellArgs = this.getShellArgs(); - - // Ensure critical environment variables are set for proper shell initialization + startShellProcess(): void { const processEnv = { ...process.env, - ...env, - // Ensure TERM is set for proper terminal emulation (needed for Oh-My-Posh and colors) + ...this.env, TERM: process.env.TERM || "xterm-256color", - // Ensure COLORTERM is set to indicate true color support COLORTERM: process.env.COLORTERM || "truecolor", }; - // Log shell startup for debugging (can be removed in production) - logInfo(`[Terminal ${this.terminal.id}] Starting shell: ${this.terminal.shell} with args: ${shellArgs.join(" ")}`); - logInfo(`[Terminal ${this.terminal.id}] Working directory: ${this.terminal.cwd}`); - logInfo(`[Terminal ${this.terminal.id}] TERM: ${processEnv.TERM}, COLORTERM: ${processEnv.COLORTERM}`); + logInfo(`[Terminal ${this.terminal.id}] Starting shell: ${this.terminal.shell}`); - this.process = spawn(this.terminal.shell, shellArgs, { + this.shellProcess = spawn(this.terminal.shell, getShellInteractiveArgs(this.shellType), { cwd: this.terminal.cwd, env: processEnv, shell: false, }); - this.process.stdout.on("data", (data: Buffer) => { - const output = data.toString(); - this.emit("output", output); - - if (this.currentCommand) { - this.currentCommand.output += output; - } + this.shellProcess.stdout.on("data", (data: Buffer) => { + this.handleStdoutChunk(data.toString()); }); - this.process.stderr.on("data", (data: Buffer) => { - const output = data.toString(); - this.emit("output", output); - - if (this.currentCommand) { - this.currentCommand.output += output; - } + this.shellProcess.stderr.on("data", (data: Buffer) => { + this.handleStderrChunk(data.toString()); }); - this.process.on("error", (error: Error) => { + this.shellProcess.on("error", (error: Error) => { + logWarn(`[Terminal ${this.terminal.id}] Shell process error: ${error.message}`); this.emit("error", error.message); - if (this.currentCommand) { - this.currentCommand.reject(error); - this.currentCommand = null; + if (this.pendingCommand) { + this.failPendingCommand(error.message); } }); - this.process.on("close", (code: number | null) => { - if (this.currentCommand) { - const result: TerminalCommandResult = { - terminalId: this.terminal.id, - commandId: this.currentCommand.commandId, - output: this.currentCommand.output, - exitCode: code ?? undefined, - }; - this.currentCommand.resolve(result); - this.emit("command:completed", result); - this.currentCommand = null; + this.shellProcess.on("close", () => { + logInfo(`[Terminal ${this.terminal.id}] Shell process exited`); + this.shellProcess = null; + if (this.pendingCommand) { + this.failPendingCommand("Shell process exited unexpectedly."); } }); } /** - * Get appropriate shell arguments for interactive mode + * Handle a chunk of stdout output from the shell. + * When a command is executing, stdout is buffered by line so that any echoed sentinel + * command text (written to stdin for exit-code tracking) can be stripped before display. + * When no command is in flight the data is forwarded immediately. */ - private getShellArgs(): string[] { - if (process.platform === "win32") { - // Windows cmd.exe or PowerShell - if (this.terminal.shell.toLowerCase().includes("powershell")) { - return ["-NoLogo", "-NoExit"]; + private handleStdoutChunk(text: string): void { + if (!this.pendingCommand) { + // No command in flight — pass through immediately. + this.emit("output", text); + return; + } + + // Buffer and filter sentinel echoes while a command is executing. + this.stdoutLineBuffer += text; + + const lastNewline = this.stdoutLineBuffer.lastIndexOf("\n"); + if (lastNewline === -1) { + // No complete line yet; hold in buffer until the next chunk. + return; + } + + const completeLines = this.stdoutLineBuffer.substring(0, lastNewline + 1); + this.stdoutLineBuffer = this.stdoutLineBuffer.substring(lastNewline + 1); + + // Split on newlines, drop sentinel echo lines, then rejoin. + const filtered = completeLines + .split("\n") + .filter((line) => !this.isSentinelEchoLine(line)) + .join("\n"); + + if (filtered) { + this.emit("output", filtered); + this.pendingCommand.output += filtered; + } + } + + /** + * Returns true when a stdout line is the shell's echo of the internal sentinel command + * that was written to stdin for exit-code detection. Such lines must not be shown to + * the user because they are an implementation detail of the terminal manager. + */ + private isSentinelEchoLine(line: string): boolean { + // Strip a trailing \r so both \n and \r\n line endings are handled. + const trimmed = line.replace(/\r$/, ""); + if (this.shellType === "pwsh") { + return trimmed.includes("$__pptb_ok__ = $?;"); + } + if (this.shellType === "cmd") { + return trimmed.includes("echo PPTB_CMD_END_"); + } + // posix + return trimmed.includes("__pptb__=$?;"); + } + + /** + * Handle a chunk of stderr output from the shell. + * Normal stderr lines are forwarded to the terminal display. + * Sentinel lines (written after each command) are used to detect completion + * and extract the exit code without polluting the visible output. + */ + private handleStderrChunk(text: string): void { + if (!this.pendingCommand) { + this.emit("output", text); + return; + } + + this.pendingCommand.stderrBuffer += text; + const sentinel = `PPTB_CMD_END_${this.pendingCommand.commandId}_`; + const sentinelIdx = this.pendingCommand.stderrBuffer.indexOf(sentinel); + + if (sentinelIdx !== -1) { + // Emit any stderr output that preceded the sentinel line + const beforeSentinel = this.pendingCommand.stderrBuffer.substring(0, sentinelIdx); + if (beforeSentinel) { + this.emit("output", beforeSentinel); + this.pendingCommand.output += beforeSentinel; } - return ["/Q"]; // Quiet mode for cmd + const afterSentinel = this.pendingCommand.stderrBuffer.substring(sentinelIdx + sentinel.length); + const exitCodeMatch = afterSentinel.match(/^(\d+)/); + // Default to 1 (failure) if the exit code portion is malformed or missing + if (!exitCodeMatch) { + logWarn(`Malformed command sentinel for command ${this.pendingCommand.commandId} in terminal ${this.terminal.id}; defaulting exit code to 1`); + } + const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1; + this.resolvePendingCommand(exitCode); } else { - // Unix-like shells - use both login and interactive modes - // -l loads the login profile (e.g., .bash_profile, .zprofile) - // -i makes it interactive (loads .bashrc, .zshrc) - return ["-l", "-i"]; // Login + Interactive mode to load all profiles + // Sentinel not yet received; emit complete stderr lines in real-time + const lastNewline = this.pendingCommand.stderrBuffer.lastIndexOf("\n"); + if (lastNewline !== -1) { + const safeToEmit = this.pendingCommand.stderrBuffer.substring(0, lastNewline + 1); + this.emit("output", safeToEmit); + this.pendingCommand.output += safeToEmit; + this.pendingCommand.stderrBuffer = this.pendingCommand.stderrBuffer.substring(lastNewline + 1); + } } } /** - * Execute a command in the terminal + * Flushes any remaining buffered stdout, filtering sentinel echo lines. + * Called before a pending command is resolved or failed so no output is lost. */ - async executeCommand(command: string): Promise { - const commandId = randomUUID(); + private flushStdoutBuffer(): void { + if (!this.stdoutLineBuffer || !this.pendingCommand) return; + const remaining = this.stdoutLineBuffer; + this.stdoutLineBuffer = ""; + if (!this.isSentinelEchoLine(remaining)) { + this.emit("output", remaining); + this.pendingCommand.output += remaining; + } + } + + private resolvePendingCommand(exitCode: number): void { + if (!this.pendingCommand) return; - return new Promise((resolve, reject) => { - this.commandQueue.push({ command, commandId, resolve, reject }); + // Flush any stdout that arrived after the last newline boundary. + this.flushStdoutBuffer(); + + const pending = this.pendingCommand; + this.pendingCommand = null; + this.isProcessing = false; + + const result: TerminalCommandResult = { + terminalId: this.terminal.id, + commandId: pending.commandId, + output: pending.output, + exitCode, + }; + pending.resolve(result); + this.emit("command:completed", result); + this.processQueue(); + } + + private failPendingCommand(errorMsg: string): void { + if (!this.pendingCommand) return; + + // Flush any stdout that arrived after the last newline boundary. + this.flushStdoutBuffer(); + + const pending = this.pendingCommand; + this.pendingCommand = null; + this.isProcessing = false; + + const result: TerminalCommandResult = { + terminalId: this.terminal.id, + commandId: pending.commandId, + output: pending.output, + exitCode: 1, + error: errorMsg, + }; + pending.resolve(result); + this.emit("command:completed", result); + this.processQueue(); + } + + /** + * Execute a validated command by writing it to the shell's stdin. + */ + async executeCommand(parsedCommand: ParsedTerminalCommand): Promise { + const commandId = randomUUID(); + return new Promise((resolve) => { + this.commandQueue.push({ parsedCommand, commandId, resolve }); this.processQueue(); }); } /** - * Process the command queue + * Dequeue the next command and write it to the shell's stdin. */ private processQueue(): void { - if (this.isProcessing || this.commandQueue.length === 0 || !this.process) { + if (this.isProcessing || this.commandQueue.length === 0) { return; } - this.isProcessing = true; const queueItem = this.commandQueue.shift()!; - this.currentCommand = { + + if (!this.shellProcess || this.shellProcess.stdin.destroyed) { + const result: TerminalCommandResult = { + terminalId: this.terminal.id, + commandId: queueItem.commandId, + exitCode: 1, + error: "Shell process is not running.", + }; + queueItem.resolve(result); + this.emit("command:completed", result); + this.processQueue(); + return; + } + + this.isProcessing = true; + this.pendingCommand = { commandId: queueItem.commandId, - output: "", resolve: queueItem.resolve, - reject: queueItem.reject, + output: "", + stderrBuffer: "", }; - // Write command to stdin - const commandWithNewline = queueItem.command + (process.platform === "win32" ? "\r\n" : "\n"); - this.process.stdin.write(commandWithNewline); - - // Set a timeout to resolve the command even if process doesn't close - setTimeout(() => { - if (this.currentCommand && this.currentCommand.commandId === queueItem.commandId) { - const result: TerminalCommandResult = { - terminalId: this.terminal.id, - commandId: this.currentCommand.commandId, - output: this.currentCommand.output, - }; - this.currentCommand.resolve(result); - this.emit("command:completed", result); - this.currentCommand = null; - this.isProcessing = false; - this.processQueue(); - } - }, 5000); // 5 second timeout for command execution + logInfo(`[Terminal ${this.terminal.id}] Executing: ${queueItem.parsedCommand.executable}`); + this.shellProcess.stdin.write(buildShellCommandInput(queueItem.parsedCommand, queueItem.commandId, this.shellType)); } /** - * Close the terminal + * Kill the shell process and clean up. */ close(): void { - if (this.process) { - this.process.kill(); - this.process = null; + if (this.shellProcess) { + this.shellProcess.kill(); + this.shellProcess = null; } } } diff --git a/src/main/managers/toolRegistryManager.ts b/src/main/managers/toolRegistryManager.ts index cdae6234..6d19d87a 100644 --- a/src/main/managers/toolRegistryManager.ts +++ b/src/main/managers/toolRegistryManager.ts @@ -6,7 +6,7 @@ import * as http from "http"; import * as https from "https"; import * as path from "path"; import { pipeline } from "stream/promises"; -import { CspExceptions, ToolManifest, ToolRegistryEntry, CommunityLinksCollection, CommunityLinksGroup, CommunityLinksItem } from "../../common/types"; +import { CapabilityTagEntry, CspExceptions, ToolManifest, ToolRegistryEntry, CommunityLinksCollection, CommunityLinksGroup, CommunityLinksItem } from "../../common/types"; import { AZURE_BLOB_BASE_URL, SUPABASE_ANON_KEY, SUPABASE_URL } from "../constants"; import { InstallIdManager } from "./installIdManager"; import { logInfo, logWarn, logError } from "../../common/logger"; @@ -93,6 +93,35 @@ interface SupabaseCommunityLink { is_active: boolean; } +/** + * Supabase capability_tags table row + */ +interface SupabaseCapabilityTagRow { + tag: string; + description: string; +} + +/** + * Built-in fallback capability tags used when Supabase is unreachable. + * The authoritative list is stored in the Supabase `capability_tags` table and + * fetched at startup; this list ensures the app always has a baseline set of + * known tags so tools can be validated even in an offline scenario. + * + * **Keep in sync with `KNOWN_CAPABILITY_TAGS` in `packages/lib/validate.js`.** + * Since the validator is a standalone Node.js CLI that cannot import from the + * Electron/TypeScript source tree, both lists must be updated together whenever + * new tags are added to the Supabase `capability_tags` table. + */ +const BUILT_IN_CAPABILITY_TAGS: CapabilityTagEntry[] = [ + { tag: "fetchxml", description: "Accept or process FetchXML queries" }, + { tag: "entity-picker", description: "Browse and select a Dataverse entity (table)" }, + { tag: "record-selector", description: "Browse and select a Dataverse record" }, + { tag: "solution-selector", description: "Pick a Power Platform solution" }, + { tag: "webresource-editor", description: "Edit or manage web resources" }, + { tag: "plugin-inspector", description: "Inspect or manage plugins and assemblies" }, + { tag: "pcf-control-builder", description: "Build or scaffold PCF controls" }, +]; + /** * Local registry JSON file structure */ @@ -148,10 +177,17 @@ export class ToolRegistryManager extends EventEmitter { source: "supabase" | "azureBlob" | "local"; } | null = null; + // Capability tags cache (fetched from Supabase `capability_tags` table) + private capabilityTagsCache: CapabilityTagEntry[] | null = null; + private capabilityTagsFetchedAtMs = 0; + // Multiple renderer modules request the registry during startup (homepage stats, marketplace, etc.). // Keep this short so the marketplace stays fresh, but long enough to prevent thrash. private static readonly REGISTRY_CACHE_TTL_MS = 30_000; + // Capability tags change rarely; use a longer TTL so the fetch happens at most once per session. + private static readonly CAPABILITY_TAGS_CACHE_TTL_MS = 300_000; // 5 minutes + constructor(toolsDirectory: string, supabaseUrl?: string, supabaseKey?: string, installIdManager?: InstallIdManager, azureBlobBaseUrl?: string) { super(); this.toolsDirectory = toolsDirectory; @@ -316,6 +352,7 @@ export class ToolRegistryManager extends EventEmitter { status: (tool.status as "active" | "deprecated" | "archived" | undefined) || "active", minAPI: tool.min_api, // Include min API version from database maxAPI: tool.max_api, // Include max API version from database + npmPackageName: tool.packagename || undefined, // npm package name for pre-release detection } as ToolRegistryEntry; }); @@ -626,6 +663,31 @@ export class ToolRegistryManager extends EventEmitter { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + // Read optional pptb.config.json for invocation capabilities + let capabilities: string[] | undefined; + const pptbConfigPath = path.join(toolPath, "pptb.config.json"); + if (fs.existsSync(pptbConfigPath)) { + try { + const pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf-8")); + const caps = pptbConfig?.invocation?.capabilities; + if (Array.isArray(caps) && caps.length > 0) { + capabilities = (caps as unknown[]).filter((c): c is string => typeof c === "string" && c.trim().length > 0); + } + } catch (err) { + logWarn(`[ToolRegistry] Could not read pptb.config.json for ${toolId}`, err); + } + } + + // Validate declared capabilities against the known registry (warn on unknown tags) + if (capabilities && capabilities.length > 0) { + const knownTags = await this.getKnownCapabilityTags(); + const knownTagSet = new Set(knownTags.map((t) => t.tag)); + const unknownCaps = capabilities.filter((c) => !knownTagSet.has(c)); + if (unknownCaps.length > 0) { + logWarn(`[ToolRegistry] Tool ${toolId} declares unrecognised capability tags: ${unknownCaps.join(", ")}. Ensure these tags exist in the capability registry or check for typos.`); + } + } + // Extract version information from registry (Supabase) // These are pre-processed during tool intake and stored in the database const minAPI: string | undefined = tool.minAPI; // From Supabase tools table (min_api column) @@ -671,6 +733,7 @@ export class ToolRegistryManager extends EventEmitter { publishedAt: tool.publishedAt, minAPI, // Minimum API version required maxAPI, // Maximum API version tested (from @pptb/types) + capabilities, // Invocation capability tags from pptb.config.json }; // Save to manifest file @@ -1085,4 +1148,134 @@ export class ToolRegistryManager extends EventEmitter { return null; } } + + /** + * Returns the list of known capability tags. + * + * On first call (or after the TTL expires) the list is fetched from the Supabase + * `capability_tags` table and cached. When Supabase is unavailable the built-in + * fallback list (`BUILT_IN_CAPABILITY_TAGS`) is returned so validation and + * auto-complete still work in offline scenarios. + */ + async getKnownCapabilityTags(): Promise { + const now = Date.now(); + if (this.capabilityTagsCache && now - this.capabilityTagsFetchedAtMs < ToolRegistryManager.CAPABILITY_TAGS_CACHE_TTL_MS) { + return this.capabilityTagsCache; + } + + try { + const tags = await this.fetchKnownCapabilityTagsFromSupabase(); + if (tags !== null) { + this.capabilityTagsCache = tags; + this.capabilityTagsFetchedAtMs = now; + return tags; + } + } catch (error) { + logWarn("[ToolRegistry] Could not fetch capability tags from Supabase, using built-in fallback", { error: error instanceof Error ? error.message : String(error) }); + } + + // Supabase unavailable: update the timestamp so we do not retry on every subsequent call + // while Supabase is down (rate-limit retries to once per TTL interval). + // If a previous successful fetch populated the cache, keep returning it; otherwise fall back + // to the built-in list so callers always receive a non-empty result. + this.capabilityTagsFetchedAtMs = now; + return this.capabilityTagsCache ?? BUILT_IN_CAPABILITY_TAGS; + } + + /** + * Fetches capability tags from the Supabase `capability_tags` table. + * Returns `null` when Supabase is not configured or the query fails. + */ + private async fetchKnownCapabilityTagsFromSupabase(): Promise { + if (!this.supabase || this.useLocalFallback) { + return null; + } + + try { + logInfo("[ToolRegistry] Fetching capability tags from Supabase"); + + const { data, error } = await this.supabase + .from("capability_tags") + .select("tag, description") + .eq("is_active", true) + .order("tag", { ascending: true }); + + if (error) { + throw new Error(`Supabase capability_tags query failed: ${error.message}`); + } + + if (!data || data.length === 0) { + logInfo("[ToolRegistry] No capability tags found in Supabase, using built-in fallback"); + return null; + } + + const rows = data as SupabaseCapabilityTagRow[]; + const tags: CapabilityTagEntry[] = rows + .filter((r) => typeof r.tag === "string" && r.tag.trim().length > 0) + .map((r) => ({ tag: r.tag.trim(), description: typeof r.description === "string" ? r.description : "" })); + + logInfo(`[ToolRegistry] Fetched ${tags.length} capability tags from Supabase`); + return tags; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logWarn("[ToolRegistry] Failed to fetch capability tags from Supabase", { error: errorMessage }); + return null; + } + } + + /** + * Check whether an npm package has a beta (pre-release) dist-tag on the npm registry. + * Uses the public npm registry REST API — no npm CLI required. + * @param npmPackageName - the npm package name (e.g. "@pptoolbox/my-tool") + * @returns an object with `hasBeta` flag and the `betaVersion` string when available + */ + async checkBetaPackage(npmPackageName: string): Promise<{ hasBeta: boolean; betaVersion?: string }> { + if (!npmPackageName || typeof npmPackageName !== "string") { + return { hasBeta: false }; + } + + try { + logInfo(`[ToolRegistry] Checking for beta package: ${npmPackageName}`); + + // encodeURIComponent handles both scoped (@org/name → %40org%2Fname) and plain names. + const encodedName = encodeURIComponent(npmPackageName); + + const url = `https://registry.npmjs.org/-/package/${encodedName}/dist-tags`; + + const rawJson = await new Promise((resolve, reject) => { + https + .get(url, { timeout: 10000 }, (res) => { + if (res.statusCode === 404) { + // Package not found on npm — no beta available + resolve("{}"); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`npm registry request failed: HTTP ${res.statusCode}`)); + return; + } + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + res.on("error", reject); + }) + .on("error", reject) + .on("timeout", () => reject(new Error("npm registry request timed out"))); + }); + + const distTags = JSON.parse(rawJson) as Record; + const betaVersion = distTags["beta"]; + + if (betaVersion && typeof betaVersion === "string") { + logInfo(`[ToolRegistry] Beta version found for ${npmPackageName}: ${betaVersion}`); + return { hasBeta: true, betaVersion }; + } + + logInfo(`[ToolRegistry] No beta version found for ${npmPackageName}`); + return { hasBeta: false }; + } catch (error) { + logWarn(`[ToolRegistry] Failed to check beta package for ${npmPackageName}`, { error: error instanceof Error ? error.message : String(error) }); + return { hasBeta: false }; + } + } } diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index f7c56d00..ea1b8c4c 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -47,12 +47,15 @@ export class ToolWindowManager { */ private toolViews: Map = new Map(); private toolConnectionInfo: Map = new Map(); // Maps instanceId -> connection info + /** Maps instanceId → tool display name (used for the "Return to [CallerToolName]" banner). */ + private toolInstanceNames: Map = new Map(); /** * Pending invocation contexts – created when one tool launches another with prefill data. * The entry is keyed by the *callee* instanceId and holds: * - the prefill data passed by the caller * - the caller's instanceId so we can forward the return value * - resolve/reject callbacks for the Promise returned to the caller tool + * - resolved flag to prevent double-resolution (e.g. when auto-close calls closeTool after resolveInvocation) */ private pendingInvocations: Map< string, // calleeInstanceId @@ -61,8 +64,29 @@ export class ToolWindowManager { prefillData: Record; resolve: (data: unknown) => void; reject: (reason: unknown) => void; + resolved: boolean; + /** When true the caller does not expect return data; banner shows a "nothing returned" warning. */ + noReturn?: boolean; } > = new Map(); + /** + * Pending requests for connection selection shown to the user via the main renderer. + * Keyed by requestId; resolved by PROVIDE_INVOCATION_CONNECTIONS from the renderer. + */ + private pendingConnectionPrompts: Map< + string, // requestId + { + resolve: (result: { primaryConnectionId: string | null; secondaryConnectionId: string | null }) => void; + reject: (reason: Error) => void; + } + > = new Map(); + /** + * Tracks the one active callee per caller (one-at-a-time enforcement). + * Maps callerInstanceId → calleeInstanceId. + * The reverse lookup (calleeInstanceId → callerInstanceId) is obtained directly + * from pendingInvocations, which already stores callerInstanceId per callee entry. + */ + private activeCallees: Map = new Map(); // NOTE: Despite the name, this stores the active tool *instanceId* (not the toolId). // The property name is retained for backward compatibility; prefer `instanceId` terminology elsewhere. private activeToolId: string | null = null; @@ -70,6 +94,7 @@ export class ToolWindowManager { private frameScheduled = false; private boundsResponseListener: (event: Electron.IpcMainEvent, bounds: { x: number; y: number; width: number; height: number }) => void; private terminalVisibilityListener: () => void; + private bannerVisibilityListener: () => void; private sidebarLayoutListener: () => void; private refreshBoundsListener: () => void; private focusListener: () => void; @@ -124,6 +149,9 @@ export class ToolWindowManager { this.terminalVisibilityListener = () => { this.scheduleBoundsUpdate(); }; + this.bannerVisibilityListener = () => { + this.scheduleBoundsUpdate(); + }; this.sidebarLayoutListener = () => { this.scheduleBoundsUpdate(); setTimeout(() => this.scheduleBoundsUpdate(), 140); @@ -150,6 +178,7 @@ export class ToolWindowManager { ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.HIDE_ALL); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY); } /** @@ -179,14 +208,51 @@ export class ToolWindowManager { primaryConnectionId: string | null, secondaryConnectionId: string | null, prefillData: Record, + noReturn?: boolean, ) => { - return this.launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData); + return this.launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData, noReturn); }, ); - // Handle data returned by a callee tool back to its caller - ipcMain.handle(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, async (event, calleeInstanceId: string, returnData: unknown) => { - return this.resolveInvocation(calleeInstanceId, returnData); + // Receive the connection IDs selected by the user via the multi-connection modal + // (in response to an INVOCATION_PROMPT_CONNECTIONS push to the main renderer). + ipcMain.handle( + TOOL_WINDOW_CHANNELS.PROVIDE_INVOCATION_CONNECTIONS, + async ( + _event, + requestId: string, + result: { primaryConnectionId: string | null; secondaryConnectionId: string | null } | null, + ) => { + const prompt = this.pendingConnectionPrompts.get(requestId); + if (!prompt) return; + this.pendingConnectionPrompts.delete(requestId); + if (result) { + prompt.resolve(result); + } else { + prompt.reject(new Error("Connection selection cancelled")); + } + }, + ); + + // Handle data returned by a callee tool back to its caller. + // calleeInstanceId is provided by callee tools calling returnData() directly. + // When the banner's "Return to Caller" button is clicked, no calleeInstanceId is + // passed — the main process falls back to the currently active tool (activeToolId). + // The banner is only visible while its callee is the active tool (switchToTool hides + // the banner whenever the active tool changes to a non-callee), so activeToolId is + // always the correct callee when the Return button is clicked. A guard on + // pendingInvocations defends against any residual race conditions. + ipcMain.handle(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, async (event, calleeInstanceId: string | null, returnData: unknown) => { + const effectiveCalleeId = calleeInstanceId ?? this.activeToolId; + if (!effectiveCalleeId) { + logWarn("[ToolWindowManager] RETURN_INVOCATION_DATA: no callee instance ID and no active tool"); + return; + } + if (!this.pendingInvocations.has(effectiveCalleeId)) { + logWarn(`[ToolWindowManager] RETURN_INVOCATION_DATA: ${effectiveCalleeId} has no pending invocation — ignoring`); + return; + } + return this.resolveInvocation(effectiveCalleeId, returnData); }); // Switch to a different tool @@ -219,9 +285,16 @@ export class ToolWindowManager { this.mainWindow.setBrowserView(null); this.activeToolId = null; this.invokeActiveToolChangedCallback(); + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false }); return true; }); + // Find installed tools that declare a given capability tag + ipcMain.handle(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY, async (_event, tag: string) => { + const allTools = this.toolManager.getAllTools(); + return allTools.filter((t) => Array.isArray(t.capabilities) && t.capabilities.includes(tag)); + }); + // Restore renderer-provided bounds flow ipcMain.on("get-tool-panel-bounds-response", this.boundsResponseListener); @@ -245,6 +318,7 @@ export class ToolWindowManager { // Handle terminal panel visibility changes // When terminal is shown/hidden, we need to adjust BrowserView bounds ipcMain.on("terminal-visibility-changed", this.terminalVisibilityListener); + ipcMain.on("invocation-banner-visibility-changed", this.bannerVisibilityListener); ipcMain.on("sidebar-layout-changed", this.sidebarLayoutListener); // Periodic frame scheduling helper @@ -332,6 +406,8 @@ export class ToolWindowManager { // Store the view with instanceId as key this.toolViews.set(instanceId, toolView); + // Store the tool display name for the "Return to [CallerToolName]" banner + this.toolInstanceNames.set(instanceId, tool.name); // Get connection information for this tool instance // Connections are passed from frontend (per-instance), not retrieved from settings @@ -433,12 +509,19 @@ export class ToolWindowManager { * Called when Tool A wants to launch Tool B with prefill data and (optionally) receive * a return value when Tool B calls returnInvocationData(). * + * One-at-a-time enforcement: rejects if the caller already has an active callee. + * FXS connection auto-inheritance: when primaryConnectionId is null, the caller's + * active FXS connection is inherited automatically. + * Multi-connection: when the callee tool requires a secondary connection that was not + * provided, the user is prompted via the PPTB renderer before the tool is launched. + * * @param callerInstanceId The instanceId of the tool initiating the launch * @param calleeInstanceId The instanceId to use for the new tool window * @param tool The tool manifest to launch - * @param primaryConnectionId Primary connection for the callee + * @param primaryConnectionId Primary connection for the callee (null = auto-inherit from caller) * @param secondaryConnectionId Secondary connection for the callee (optional) * @param prefillData Arbitrary data to pre-populate the callee's state + * @param noReturn When true, the caller does not expect return data; banner is suppressed for the callee * @returns A Promise that resolves with the data returned by the callee, or null if the callee closes without returning data */ async launchToolWithContext( @@ -448,37 +531,109 @@ export class ToolWindowManager { primaryConnectionId: string | null, secondaryConnectionId: string | null, prefillData: Record, + noReturn?: boolean, ): Promise { + // One-at-a-time enforcement + if (this.activeCallees.has(callerInstanceId)) { + throw new Error("A callee invocation is already in progress"); + } + + // FXS connection auto-inheritance: use caller's primary connection when none is specified + const effectivePrimaryConnectionId = primaryConnectionId ?? this.toolConnectionInfo.get(callerInstanceId)?.primaryConnectionId ?? null; + + // Multi-connection: if the callee requires a secondary connection but none was provided, + // ask the main renderer to show the multi-connection selector before launching the tool. + const multiConnectionMode = tool.features?.multiConnection ?? "none"; + const needsSecondary = multiConnectionMode === "required" || multiConnectionMode === "optional"; + let effectiveSecondaryConnectionId = secondaryConnectionId; + + if (needsSecondary && !effectiveSecondaryConnectionId) { + const isSecondaryRequired = multiConnectionMode === "required"; + const requestId = `invocation-conn-${callerInstanceId}-${Date.now()}`; + try { + const connectionResult = await this.promptForInvocationConnections( + requestId, + tool.name, + isSecondaryRequired, + effectivePrimaryConnectionId, + ); + effectiveSecondaryConnectionId = connectionResult.secondaryConnectionId; + } catch (err) { + throw new Error(`Connection selection cancelled: ${err instanceof Error ? err.message : String(err)}`); + } + } + return new Promise((resolve, reject) => { this.pendingInvocations.set(calleeInstanceId, { callerInstanceId, prefillData, resolve, reject, + resolved: false, + noReturn: noReturn ?? false, }); + this.activeCallees.set(callerInstanceId, calleeInstanceId); - this.launchTool(calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData) + this.launchTool(calleeInstanceId, tool, effectivePrimaryConnectionId, effectiveSecondaryConnectionId, prefillData) .then((launched) => { if (!launched) { this.pendingInvocations.delete(calleeInstanceId); + this.activeCallees.delete(callerInstanceId); reject(new Error(`Failed to launch tool instance ${calleeInstanceId}`)); + return; } + // Notify the renderer to create a tab for the callee so it appears as a + // separate instance (its own tab) rather than replacing the caller's view. + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_OPENED, { + calleeInstanceId, + callerInstanceId, + tool, + primaryConnectionId: effectivePrimaryConnectionId, + secondaryConnectionId: effectiveSecondaryConnectionId, + }); }) .catch((error) => { this.pendingInvocations.delete(calleeInstanceId); + this.activeCallees.delete(callerInstanceId); reject(error as Error); }); }); } + /** + * Ask the main renderer to show the multi-connection selector for an invoked callee tool. + * + * Returns a Promise that resolves with the selected connection IDs once the user confirms, + * or rejects if the user cancels the dialog. + */ + private promptForInvocationConnections( + requestId: string, + toolName: string, + isSecondaryRequired: boolean, + inheritedPrimaryConnectionId: string | null, + ): Promise<{ primaryConnectionId: string | null; secondaryConnectionId: string | null }> { + return new Promise((resolve, reject) => { + this.pendingConnectionPrompts.set(requestId, { resolve, reject }); + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_PROMPT_CONNECTIONS, { + requestId, + toolName, + isSecondaryRequired, + inheritedPrimaryConnectionId, + }); + }); + } + /** * Called by the callee tool's preload bridge when it is ready to return data to its caller. * * Resolves the pending Promise created in launchToolWithContext and notifies the - * caller tool via IPC so it can continue its workflow. + * caller tool via IPC so it can continue its workflow. After delivering the result, + * the callee window is automatically closed. + * + * Accepts null as a valid payload (banner early-return path). * * @param calleeInstanceId The instanceId of the tool returning data - * @param returnData The data to hand back to the caller + * @param returnData The data to hand back to the caller (null for early-return via banner) */ resolveInvocation(calleeInstanceId: string, returnData: unknown): void { const pending = this.pendingInvocations.get(calleeInstanceId); @@ -487,7 +642,14 @@ export class ToolWindowManager { return; } + if (pending.resolved) { + logWarn(`[ToolWindowManager] resolveInvocation: already resolved for ${calleeInstanceId}`); + return; + } + + pending.resolved = true; this.pendingInvocations.delete(calleeInstanceId); + this.activeCallees.delete(pending.callerInstanceId); // Notify the caller tool (if it is still open) via an IPC push const callerView = this.toolViews.get(pending.callerInstanceId); @@ -500,6 +662,21 @@ export class ToolWindowManager { // Resolve the JS Promise held by launchToolWithContext pending.resolve(returnData); + + // Auto-close the callee window now that the result has been delivered. + // After the BrowserView is destroyed, notify the renderer to remove the callee + // tab and switch back to the caller. + const callerInstanceId = pending.callerInstanceId; + this.closeTool(calleeInstanceId) + .then(() => { + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_CLOSED, { + calleeInstanceId, + callerInstanceId, + }); + }) + .catch((err) => { + logWarn(`[ToolWindowManager] Auto-close of callee ${calleeInstanceId} failed`, err); + }); } async switchToTool(instanceId: string): Promise { @@ -529,6 +706,24 @@ export class ToolWindowManager { this.activeToolId = instanceId; this.invokeActiveToolChangedCallback(); + // Push banner state to the renderer: show the "Return to [CallerToolName]" banner + // if this tool was launched by another tool, otherwise hide it. + const invocationEntry = this.pendingInvocations.get(instanceId); + if (invocationEntry) { + const callerToolName = this.toolInstanceNames.get(invocationEntry.callerInstanceId) ?? "Caller"; + // noReturn invocations do not show a banner — the caller does not expect data back + if (invocationEntry.noReturn) { + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false }); + } else { + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { + visible: true, + callerToolName, + }); + } + } else { + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false }); + } + logInfo(`[ToolWindowManager] Switched to tool instance: ${instanceId}, requesting bounds...`); // Request bounds update from renderer @@ -568,22 +763,32 @@ export class ToolWindowManager { // Remove from maps - also clean up connection info this.toolViews.delete(instanceId); this.toolConnectionInfo.delete(instanceId); + this.toolInstanceNames.delete(instanceId); // If the tool was launched by another tool (inter-tool invocation) and it closes // without calling returnData, resolve the caller's Promise with null so the caller // doesn't hang indefinitely. + // Guard: skip resolve if resolveInvocation already handled it (auto-close path). const pending = this.pendingInvocations.get(instanceId); if (pending) { this.pendingInvocations.delete(instanceId); - // Notify the caller view (if still alive) - const callerView = this.toolViews.get(pending.callerInstanceId); - if (callerView && !callerView.webContents.isDestroyed()) { - callerView.webContents.send("toolbox:invocation-result", { - calleeInstanceId: instanceId, - returnData: null, - }); + this.activeCallees.delete(pending.callerInstanceId); + if (!pending.resolved) { + // Notify the caller view (if still alive) + const callerView = this.toolViews.get(pending.callerInstanceId); + if (callerView && !callerView.webContents.isDestroyed()) { + callerView.webContents.send("toolbox:invocation-result", { + calleeInstanceId: instanceId, + returnData: null, + }); + } + pending.resolve(null); } - pending.resolve(null); + } + + // If this was the active tool, hide the banner in the renderer + if (this.activeToolId === null) { + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false }); } // Dispose any terminals created by this tool instance @@ -952,9 +1157,11 @@ export class ToolWindowManager { ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY); if (this.boundsResponseListener) ipcMain.removeListener("get-tool-panel-bounds-response", this.boundsResponseListener); if (this.terminalVisibilityListener) ipcMain.removeListener("terminal-visibility-changed", this.terminalVisibilityListener); + if (this.bannerVisibilityListener) ipcMain.removeListener("invocation-banner-visibility-changed", this.bannerVisibilityListener); if (this.sidebarLayoutListener) ipcMain.removeListener("sidebar-layout-changed", this.sidebarLayoutListener); if (this.rendererInitializedListener) ipcMain.removeListener(TOOL_WINDOW_CHANNELS.RENDERER_INITIALIZED, this.rendererInitializedListener); diff --git a/src/main/managers/toolsManager.ts b/src/main/managers/toolsManager.ts index 9ed94038..fd1e4f73 100644 --- a/src/main/managers/toolsManager.ts +++ b/src/main/managers/toolsManager.ts @@ -3,11 +3,11 @@ import { EventEmitter } from "events"; import * as fs from "fs"; import * as path from "path"; import { pathToFileURL } from "url"; -import { CspExceptions, Tool, ToolFeatures, ToolManifest, CommunityLinksCollection } from "../../common/types"; +import { logError, logInfo, logWarn } from "../../common/logger"; +import { CapabilityTagEntry, CommunityLinksCollection, CspExceptions, Tool, ToolFeatures, ToolManifest } from "../../common/types"; import { InstallIdManager } from "./installIdManager"; import { ToolRegistryManager } from "./toolRegistryManager"; import { VersionManager } from "./versionManager"; -import { logInfo, logError } from "../../common/logger"; /** * Package.json structure for tool validation @@ -79,6 +79,7 @@ export class ToolManager extends EventEmitter { minAPI: manifest.minAPI, maxAPI: manifest.maxAPI, isSupported: VersionManager.isToolSupported(manifest.minAPI, manifest.maxAPI), + capabilities: manifest.capabilities, }; const cached = this.analyticsCache.get(tool.id); @@ -149,6 +150,7 @@ export class ToolManager extends EventEmitter { minAPI: manifest.minAPI, maxAPI: manifest.maxAPI, isSupported: VersionManager.isToolSupported(manifest.minAPI, manifest.maxAPI), + capabilities: manifest.capabilities, }; const cached = this.analyticsCache.get(tool.id); @@ -190,7 +192,8 @@ export class ToolManager extends EventEmitter { unloadTool(toolId: string): void { const tool = this.tools.get(toolId); if (tool) { - this.tools.delete(toolId); + // Too soon to delete it + //this.tools.delete(toolId); this.emit("tool:unloaded", tool); } } @@ -272,7 +275,7 @@ export class ToolManager extends EventEmitter { */ async fetchAvailableTools(): Promise { const registryTools = await this.registryManager.fetchRegistry(); - + // Convert ToolRegistryEntry[] to Tool[] and add isSupported field return registryTools.map((registryTool) => { const tool: Tool = { @@ -291,6 +294,13 @@ export class ToolManager extends EventEmitter { return this.registryManager.fetchCommunityLinks(); } + /** + * Returns the list of known capability tags from the registry (backed by Supabase with fallback). + */ + async getKnownCapabilityTags(): Promise { + return this.registryManager.getKnownCapabilityTags(); + } + /** * Check for tool updates */ @@ -344,10 +354,29 @@ export class ToolManager extends EventEmitter { } /** - * Uninstall a tool from registry + * Uninstall a tool (handles registry, npm, and local tools) */ async uninstallTool(toolId: string): Promise { - await this.registryManager.uninstallTool(toolId); + const tool = this.tools.get(toolId); + + if (tool) { + this.tools.delete(toolId); + this.emit("tool:unloaded", tool); + } + + if (tool?.npmPackageName) { + const packageDir = this.resolvePackageDirectoryName(tool.npmPackageName); + const toolPath = path.join(this.toolsDirectory, "node_modules", packageDir); + if (fs.existsSync(toolPath)) { + fs.rmSync(toolPath, { recursive: true, force: true }); + } + } else if (tool?.localPath) { + if (fs.existsSync(tool.localPath)) { + fs.rmSync(tool.localPath, { recursive: true, force: true }); + } + } else { + await this.registryManager.uninstallTool(toolId); + } } /** @@ -372,13 +401,34 @@ export class ToolManager extends EventEmitter { /** * Check if a package manager is available globally (debug mode only) */ + private buildEnv(): Record { + const paths = [...(process.env.PATH || "").split(path.delimiter).filter(Boolean)]; + + if (process.platform === "darwin") { + paths.push("/usr/local/bin", "/opt/homebrew/bin"); + } else if (process.platform === "linux") { + paths.push("/usr/local/bin", path.join(process.env.HOME || "", ".local", "bin")); + } + + const home = process.env.HOME || ""; + if (home) { + paths.push(path.join(home, ".npm-global", "bin")); + paths.push(path.join(home, ".nvm", "versions", "node", process.version, "bin")); + } + + return { + ...process.env, + PATH: [...new Set(paths)].join(path.delimiter), + }; + } + private async checkPackageManager(command: string): Promise { return new Promise((resolve) => { const isWindows = process.platform === "win32"; const cmd = isWindows ? `${command}.cmd` : command; + const env = this.buildEnv(); - // Don't use shell to avoid issues with spaces in paths - const check = spawn(cmd, ["--version"]); + const check = spawn(cmd, ["--version"], { env }); check.on("close", (code: number) => { resolve(code === 0); @@ -394,19 +444,19 @@ export class ToolManager extends EventEmitter { * Get the available package manager (debug mode only) * Returns null if neither is available */ - private async getAvailablePackageManager(): Promise<{ command: string; name: string } | null> { + private async getAvailablePackageManager(): Promise<{ command: string; name: string; env: Record } | null> { // Check for pnpm first (preferred) const hasPnpm = await this.checkPackageManager("pnpm"); if (hasPnpm) { logInfo(`[ToolManager] Found pnpm globally installed`); - return { command: process.platform === "win32" ? "pnpm.cmd" : "pnpm", name: "pnpm" }; + return { command: process.platform === "win32" ? "pnpm.cmd" : "pnpm", name: "pnpm", env: this.buildEnv() }; } // Fallback to npm const hasNpm = await this.checkPackageManager("npm"); if (hasNpm) { logInfo(`[ToolManager] Found npm globally installed`); - return { command: process.platform === "win32" ? "npm.cmd" : "npm", name: "npm" }; + return { command: process.platform === "win32" ? "npm.cmd" : "npm", name: "npm", env: this.buildEnv() }; } logError(`[ToolManager] Neither pnpm nor npm found globally installed`); @@ -437,7 +487,7 @@ export class ToolManager extends EventEmitter { // Don't use shell: true to avoid issues with spaces in paths // The command array is already in the correct format for spawn - const install = spawn(pkgManager.command, args); + const install = spawn(pkgManager.command, args, { env: pkgManager.env }); let stderr = ""; @@ -473,6 +523,68 @@ export class ToolManager extends EventEmitter { }); } + /** + * Resolve the actual directory name for an npm package inside node_modules. + * Strips any trailing version/tag specifier so the path is valid on disk. + * Examples: + * "@org/name@1.0.0" → "@org/name" + * "@org/name@beta" → "@org/name" + * "my-tool@beta" → "my-tool" + * "@org/name" → "@org/name" (unchanged) + * "my-tool" → "my-tool" (unchanged) + */ + private resolvePackageDirectoryName(packageName: string): string { + if (packageName.startsWith("@")) { + // Scoped package: @scope/name[@version] + // Find the '@' that separates the name from the version specifier. + const withoutLeadingAt = packageName.slice(1); // "scope/name@version" + const versionAtIndex = withoutLeadingAt.indexOf("@"); + if (versionAtIndex !== -1) { + return "@" + withoutLeadingAt.slice(0, versionAtIndex); + } + return packageName; + } else { + // Regular package: name[@version] + const atIndex = packageName.indexOf("@"); + if (atIndex !== -1) { + return packageName.slice(0, atIndex); + } + return packageName; + } + } + + /** + * Check whether a beta (pre-release) npm package version is available. + * @param npmPackageName - the npm package name (e.g. "@pptoolbox/my-tool") + */ + async checkBetaPackage(npmPackageName: string): Promise<{ hasBeta: boolean; betaVersion?: string }> { + return this.registryManager.checkBetaPackage(npmPackageName); + } + + /** + * Install the beta (pre-release) version of a registry tool via npm. + * Installs the `@beta` dist-tag of the given npm package and loads it. + * @param npmPackageName - the npm package name (e.g. "@pptoolbox/my-tool") + */ + async installPrereleaseToolFromNpm(npmPackageName: string): Promise { + logInfo(`[ToolManager] Installing pre-release (beta) tool: ${npmPackageName}`); + const betaPackageSpec = `${npmPackageName}@beta`; + try { + await this.installToolForDebug(betaPackageSpec); + } catch (installError) { + const msg = installError instanceof Error ? installError.message : String(installError); + throw new Error(`Failed to install pre-release package '${betaPackageSpec}': ${msg}`); + } + // Use the base package name (no version specifier) to locate the installed directory. + try { + const tool = await this.loadNpmTool(npmPackageName); + return tool; + } catch (loadError) { + const msg = loadError instanceof Error ? loadError.message : String(loadError); + throw new Error(`Pre-release package '${betaPackageSpec}' was installed but could not be loaded: ${msg}`); + } + } + /** * Get installation instructions for package managers (debug mode only) */ @@ -502,13 +614,18 @@ export class ToolManager extends EventEmitter { /** * Load an npm-installed tool from node_modules (DEBUG MODE ONLY) * This is called after installToolForDebug to register the tool in the tools map - * @param packageName - npm package name + * @param packageName - npm package name (may include a version/tag specifier like "@beta" or "@1.0.0") */ async loadNpmTool(packageName: string): Promise { logInfo(`[ToolManager] [DEBUG] Loading npm tool: ${packageName}`); + // Resolve the actual directory name in node_modules — strip any version/tag specifier. + // For scoped packages: @org/name@version → @org/name + // For regular packages: name@version → name + const packageDirName = this.resolvePackageDirectoryName(packageName); + // Construct path to the installed package - const toolPath = path.join(this.toolsDirectory, "node_modules", packageName); + const toolPath = path.join(this.toolsDirectory, "node_modules", packageDirName); // Verify the path exists if (!fs.existsSync(toolPath)) { @@ -548,6 +665,32 @@ export class ToolManager extends EventEmitter { // Create a tool object with npm path metadata const toolId = `npm-${sanitizedToolId}`; + + // Read optional pptb.config.json for invocation capabilities + let capabilities: string[] | undefined; + const pptbConfigPath = path.join(toolPath, "pptb.config.json"); + if (fs.existsSync(pptbConfigPath)) { + try { + const pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf-8")); + const caps = pptbConfig?.invocation?.capabilities; + if (Array.isArray(caps) && caps.length > 0) { + capabilities = (caps as unknown[]).filter((c): c is string => typeof c === "string" && c.trim().length > 0); + } + } catch (err) { + logWarn(`[ToolRegistry] Could not read pptb.config.json for ${toolId}`, err); + } + } + + // Validate declared capabilities against the known registry (warn on unknown tags) + if (capabilities && capabilities.length > 0) { + const knownTags = await this.getKnownCapabilityTags(); + const knownTagSet = new Set(knownTags.map((t) => t.tag)); + const unknownCaps = capabilities.filter((c) => !knownTagSet.has(c)); + if (unknownCaps.length > 0) { + logWarn(`[ToolRegistry] Tool ${toolId} declares unrecognised capability tags: ${unknownCaps.join(", ")}. Ensure these tags exist in the capability registry or check for typos.`); + } + } + const tool: Tool = { id: toolId, name: packageJson.displayName || packageJson.name, @@ -561,6 +704,7 @@ export class ToolManager extends EventEmitter { repository: typeof packageJson.repository === "string" ? packageJson.repository : packageJson.repository?.url, website: packageJson.homepage, readmeUrl: packageJson.readme, + capabilities, // Invocation capability tags from pptb.config.json }; this.tools.set(toolId, tool); @@ -736,6 +880,32 @@ export class ToolManager extends EventEmitter { // Create a tool object with local path metadata const toolId = `local-${sanitizedToolId}`; + + // Read optional pptb.config.json for invocation capabilities + let capabilities: string[] | undefined; + const pptbConfigPath = path.join(localPath, "pptb.config.json"); + if (fs.existsSync(pptbConfigPath)) { + try { + const pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf-8")); + const caps = pptbConfig?.invocation?.capabilities; + if (Array.isArray(caps) && caps.length > 0) { + capabilities = (caps as unknown[]).filter((c): c is string => typeof c === "string" && c.trim().length > 0); + } + } catch (err) { + logWarn(`[ToolRegistry] Could not read pptb.config.json for ${toolId}`, err); + } + } + + // Validate declared capabilities against the known registry (warn on unknown tags) + if (capabilities && capabilities.length > 0) { + const knownTags = await this.getKnownCapabilityTags(); + const knownTagSet = new Set(knownTags.map((t) => t.tag)); + const unknownCaps = capabilities.filter((c) => !knownTagSet.has(c)); + if (unknownCaps.length > 0) { + logWarn(`[ToolRegistry] Tool ${toolId} declares unrecognised capability tags: ${unknownCaps.join(", ")}. Ensure these tags exist in the capability registry or check for typos.`); + } + } + const tool: Tool = { id: toolId, name: packageJson.displayName || packageJson.name, @@ -749,6 +919,7 @@ export class ToolManager extends EventEmitter { repository: typeof packageJson.repository === "string" ? packageJson.repository : packageJson.repository?.url, website: packageJson.homepage, readmeUrl: packageJson.readme, + capabilities, // Invocation capability tags from pptb.config.json }; this.tools.set(toolId, tool); diff --git a/src/main/preload.ts b/src/main/preload.ts index 0d383fa7..7ebb6323 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -61,7 +61,8 @@ contextBridge.exposeInMainWorld("toolboxAPI", { primaryConnectionId: string | null, secondaryConnectionId: string | null, prefillData: Record, - ) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData), + noReturn?: boolean, + ) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData, noReturn), switchToolWindow: (instanceId: string) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.SWITCH, instanceId), closeToolWindow: (instanceId: string) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.CLOSE, instanceId), hideToolWindows: () => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.HIDE_ALL), @@ -69,6 +70,35 @@ contextBridge.exposeInMainWorld("toolboxAPI", { getOpenToolWindows: () => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS), updateToolConnection: (instanceId: string, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION, instanceId, primaryConnectionId, secondaryConnectionId), + findToolsByCapability: (tag: string) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY, tag), + /** Trigger banner "Return to Caller" — resolves the currently active callee's invocation with null and auto-closes it. */ + returnToCallerBanner: () => + ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, null, null), + onInvocationBannerState: (callback: (state: { visible: boolean; callerToolName?: string }) => void) => { + ipcRenderer.on(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, (_event, state) => callback(state)); + }, + /** Listen for multi-connection prompts triggered by an invocation that requires a secondary connection. */ + onInvocationConnectionsPrompt: (callback: (prompt: { requestId: string; toolName: string; isSecondaryRequired: boolean; inheritedPrimaryConnectionId: string | null }) => void) => { + ipcRenderer.on(TOOL_WINDOW_CHANNELS.INVOCATION_PROMPT_CONNECTIONS, (_event, prompt) => callback(prompt)); + }, + /** Reply to a multi-connection prompt with the selected connection IDs (or null to cancel). */ + provideInvocationConnections: (requestId: string, result: { primaryConnectionId: string | null; secondaryConnectionId: string | null } | null) => + ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.PROVIDE_INVOCATION_CONNECTIONS, requestId, result), + /** + * Listen for inter-tool callee launch notifications. Fired once the callee BrowserView + * is ready so the renderer can create a dedicated tab for the callee instance. + */ + onCalleeToolOpened: (callback: (data: { calleeInstanceId: string; callerInstanceId: string; tool: unknown; primaryConnectionId: string | null; secondaryConnectionId: string | null }) => void) => { + ipcRenderer.on(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_OPENED, (_event, data) => callback(data)); + }, + /** + * Listen for inter-tool callee auto-close notifications. Fired after the callee is + * auto-closed by the main process so the renderer can remove the tab and return focus + * to the caller. + */ + onCalleeToolClosed: (callback: (data: { calleeInstanceId: string; callerInstanceId: string }) => void) => { + ipcRenderer.on(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_CLOSED, (_event, data) => callback(data)); + }, // Favorite tools - Only for PPTB UI addFavoriteTool: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.ADD_FAVORITE_TOOL, toolId), @@ -85,10 +115,13 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Registry-based tools (new primary method) fetchRegistryTools: () => ipcRenderer.invoke(TOOL_CHANNELS.FETCH_REGISTRY_TOOLS), fetchCommunityLinks: () => ipcRenderer.invoke(TOOL_CHANNELS.FETCH_COMMUNITY_LINKS), + getKnownCapabilityTags: () => ipcRenderer.invoke(TOOL_CHANNELS.GET_KNOWN_CAPABILITY_TAGS), installToolFromRegistry: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.INSTALL_TOOL_FROM_REGISTRY, toolId), checkToolUpdates: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.CHECK_TOOL_UPDATES, toolId), updateTool: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.UPDATE_TOOL, toolId), isToolUpdating: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.IS_TOOL_UPDATING, toolId), + checkBetaPackage: (npmPackageName: string) => ipcRenderer.invoke(TOOL_CHANNELS.CHECK_BETA_PACKAGE, npmPackageName), + installPrereleaseToolFromNpm: (npmPackageName: string) => ipcRenderer.invoke(TOOL_CHANNELS.INSTALL_PRERELEASE_TOOL, npmPackageName), // Tool Settings - Only for PPTB UI getToolSettings: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.GET_TOOL_SETTINGS, toolId), diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index ca665665..8914a9d5 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -385,7 +385,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { launchTool: async ( targetToolId: string, prefillData: Record = {}, - options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null }, + options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null; noReturn?: boolean }, ): Promise => { const { instanceId: callerInstanceId } = await getToolIdentifiers(); if (!callerInstanceId) { @@ -401,10 +401,33 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Generate a unique instanceId for the callee (mirrors the pattern used in the renderer) const calleeInstanceId = `${targetToolId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const primaryConnectionId = options?.primaryConnectionId !== undefined ? options.primaryConnectionId : null; + const primaryConnectionId = options?.primaryConnectionId !== undefined + ? options.primaryConnectionId + : (toolContext?.connectionId ?? null); // FXS auto-inherit: use caller's active connection const secondaryConnectionId = options?.secondaryConnectionId !== undefined ? options.secondaryConnectionId : null; + const noReturn = options?.noReturn ?? false; - return ipcInvoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData); + return ipcInvoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData, noReturn); + }, + + /** + * Find installed tools that declare a given capability tag in their pptb.config.json. + * + * @param tag The capability tag to search for (e.g. "entity-picker") + * @returns A list of matching installed tools + */ + findToolsByCapability: async (tag: string): Promise => { + return ipcInvoke(TOOL_WINDOW_CHANNELS.FIND_TOOLS_BY_CAPABILITY, tag) as Promise; + }, + + /** + * Returns the list of known (registered) capability tags from the capability registry. + * Backed by Supabase with a built-in fallback for offline use. + * + * @returns Array of { tag, description } entries + */ + getKnownCapabilityTags: async (): Promise> => { + return ipcInvoke(TOOL_CHANNELS.GET_KNOWN_CAPABILITY_TAGS) as Promise>; }, }, }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 4978a1f0..b3cf5380 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -365,6 +365,14 @@ + +