diff --git a/docs/PLUGIN-ARCHITECTURE.md b/docs/PLUGIN-ARCHITECTURE.md index 00184914c..91003ce21 100644 --- a/docs/PLUGIN-ARCHITECTURE.md +++ b/docs/PLUGIN-ARCHITECTURE.md @@ -163,6 +163,7 @@ host pushes the aggregated registry state into the server internals: | `setPluginTools(...)` | Plugin tools become callable agent tools (Settings → Tools) | | `setPluginCommands(...)` | Slash commands join the command registry with `pluginId` provenance | | `setPluginModelMetadataProviders(...)` | Metadata providers merge into `/api/providers` responses | +| `setPluginMessageTransforms(...)` | Message transforms are wired into the pre-LLM agent loop pipeline | | `refreshSkillSources()` | Calls each source's `load()`, skills join discovery with `source: 'plugin'` | UI contributions, badges, panels, RPC methods, hooks, transitions and assets @@ -331,20 +332,21 @@ unchanged. ## 7. Extension points summary -| Capability | Registry method(s) | Surfaces in | -| --------------- | -------------------------------------------------------- | ---------------------------------------- | -| `providers` | `registerAuth`, `registerTransport`, `registerPreset` | Provider setup wizard, agent loop | -| `models` | `registerModelMetadataProvider` | Model picker, `/api/providers` | -| `tools` | `registerTool` | Agent tool list, Settings → Tools | -| `commands` | `registerCommand` | Slash commands, Commands settings | -| `skills` | `registerSkillSource` | Skill discovery (`source: 'plugin'`) | -| `settings` | `registerSettings` | Plugins tab auto-form | -| `ui` | `registerUiAction`, `registerUiBadge`, `registerUiPanel` | Header, session, message, composer slots | -| `hooks` | `registerHook` | Background (observational) | -| `workflows` | `registerTransitionHandler` | Workflow `when: custom` transitions | -| `rpc` | `registerRpc` | `POST /api/plugins//rpc/` | -| `assets` | `registerAsset` | `/api/plugins//assets/` | -| `notifications` | `context.notify` | Toasts, bell, notification center | +| Capability | Registry method(s) | Surfaces in | +| --------------- | -------------------------------------------------------- | -------------------------------------------- | +| `providers` | `registerAuth`, `registerTransport`, `registerPreset` | Provider setup wizard, agent loop | +| `models` | `registerModelMetadataProvider` | Model picker, `/api/providers` | +| `tools` | `registerTool` | Agent tool list, Settings → Tools | +| `commands` | `registerCommand` | Slash commands, Commands settings | +| `skills` | `registerSkillSource` | Skill discovery (`source: 'plugin'`) | +| `settings` | `registerSettings` | Plugins tab auto-form | +| `ui` | `registerUiAction`, `registerUiBadge`, `registerUiPanel` | Header, session, message, composer slots | +| `hooks` | `registerHook` | Background (observational) | +| `workflows` | `registerTransitionHandler` | Workflow `when: custom` transitions | +| `rpc` | `registerRpc` | `POST /api/plugins//rpc/` | +| `assets` | `registerAsset` | `/api/plugins//assets/` | +| `transforms` | `registerMessageTransform` | Pre-LLM stream message pipeline (agent loop) | +| `notifications` | `context.notify` | Toasts, bell, notification center | ## 8. Where to look next diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 7f2e0a82a..e514a0d71 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -105,14 +105,14 @@ header. ### Manifest reference -| Field | Required | Description | -| ---------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `openfox.apiVersion` | yes | `1` (providers only, legacy) or `2` (full plugin API) | -| `openfox.entry` | yes for v2 | Path to the ESM entry point, relative to the package root. `openfox.plugin` is accepted for v1 packages | -| `openfox.displayName` | no | Shown in the Plugins tab. Defaults to the package name | -| `openfox.description` | no | Shown in the Plugins tab | -| `openfox.capabilities` | no | `providers`, `models`, `settings`, `tools`, `commands`, `skills`, `ui`, `hooks`, `notifications`, `workflows`, `rpc`, `assets` | -| `openfox.timeoutMs` | no | Per-plugin RPC timeout in ms (default 30 000) | +| Field | Required | Description | +| ---------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `openfox.apiVersion` | yes | `1` (providers only, legacy) or `2` (full plugin API) | +| `openfox.entry` | yes for v2 | Path to the ESM entry point, relative to the package root. `openfox.plugin` is accepted for v1 packages | +| `openfox.displayName` | no | Shown in the Plugins tab. Defaults to the package name | +| `openfox.description` | no | Shown in the Plugins tab | +| `openfox.capabilities` | no | `providers`, `models`, `settings`, `tools`, `commands`, `skills`, `ui`, `hooks`, `notifications`, `workflows`, `rpc`, `assets`, `transforms` | +| `openfox.timeoutMs` | no | Per-plugin RPC timeout in ms (default 30 000) | ### Discovery and lifecycle @@ -229,15 +229,96 @@ registry.registerSettings({ }) ``` -- Types: `text`, `password`, `number`, `boolean`, `select`, `textarea`, `path`. +- Types: `text`, `password`, `number`, `boolean`, `select`, `textarea`, `path`, `list`. - A form is auto-rendered in the Plugins tab from the schema — you never write UI code for it. - Values are stored per plugin in the database. `scope: 'project'` on a field stores it per project; otherwise the request scope applies (`global` by default). - Secret fields (`secret: true` or `type: 'password'`) are **never returned in cleartext**: reads return them in a `secretsSet` list, and an empty submitted value keeps the stored secret. +- `storageKey` backs a field with the plugin's own storage (`context.storage`) + instead of the settings store — the way to surface a secret an earlier version + of the plugin kept in storage, so the field shows as filled instead of empty. + Reads, writes and the `secretsSet` flag all follow that row; storage-backed + fields are global. - Read them at runtime with `context.settings(scope?, projectId?)`. +#### Repeatable rows (`list`) + +`type: 'list'` renders one inline row per item — sub-fields side by side, a +remove button per row and an add button below: + +```ts +{ + key: 'registries', + type: 'list', + label: { en: 'Registries', fr: 'Registres' }, + addLabel: { en: 'Add registry', fr: 'Ajouter un registre' }, + removeLabel: { en: 'Remove', fr: 'Supprimer' }, + minItems: 1, + maxItems: 5, + default: '[]', + itemFields: [ + { + key: 'source', + type: 'select', + label: { en: 'Source', fr: 'Source' }, + options: [ + { value: 'github', label: { en: 'GitHub', fr: 'GitHub' } }, + { value: 'gitlab', label: { en: 'GitLab', fr: 'GitLab' } }, + ], + default: 'github', + }, + { key: 'url', type: 'text', label: { en: 'Registry URL', fr: 'URL du registre' } }, + { key: 'token', type: 'password', label: { en: 'Token', fr: 'Jeton' }, secret: true }, + ], +} +``` + +- `itemFields` accepts the same field types as top-level fields (minus `list`, + `button`, `status`); sub-field defaults seed a freshly added row. +- The value is a **JSON array string**, so `context.settings()` hands you + `'[{"source":"github","url":"…","token":"…"}]'` — parse it yourself. +- Secret sub-fields are masked in the settings view like any other secret. A + masked or empty submitted value keeps the token stored **for the same row + index**, so removing a row drops its token. +- `minItems` / `maxItems` bound the number of rows; every item is validated + against `itemFields` on save (`Setting 'registries[0].url' must be a string`). + +#### Open-the-provider-page button (`linkButton`) + +`linkButton` renders a small "open in a new tab" button next to a field input — +typically to send the user to the page where an access token is generated. It +works on a top-level field or on a `list` sub-field: + +```ts +{ + key: 'token', + type: 'password', + label: { en: 'Personal Access Token', fr: 'Jeton d’accès personnel' }, + secret: true, + linkButton: { + label: { en: 'Generate a token', fr: 'Générer un jeton' }, + href: 'https://github.com/settings/tokens/new', + hrefByField: 'source', + hrefByValue: { + github: 'https://github.com/settings/tokens/new', + gitlab: '{{url.origin}}/-/user_settings/personal_access_tokens', + }, + }, +} +``` + +- `href` is a URL template. `{{key}}` is replaced by the value of `key`, and + `{{key.origin}}` by its URL origin (`https://gitlab.corp.com/g/r/-/raw/main/index.json` + → `https://gitlab.corp.com`) — the way to reach a self-hosted instance. +- `hrefByField` + `hrefByValue` pick the template from the value of another + field of the same row (e.g. a `source` select); `href` is the fallback. +- A list sub-field resolves placeholders against its own row (falling back to + the top-level fields); a top-level field resolves against the whole form. +- The button is **disabled while the resolved URL is not an absolute http(s) + URL**, so a template built from a not-yet-filled field stays greyed out. + ### UI (`ui`) UI contributions are **declarative descriptors** the host renders. Plugins never @@ -246,14 +327,21 @@ Rich custom UI is supported through sandboxed iframe panels. **Slots** -| Slot | Rendered in | -| ------------------------ | ------------------------------ | -| `header.actions` | Top header | -| `session.header.actions` | Session header | -| `message.actions` | Message context menu | -| `composer.actions` | Chat composer, above the input | -| `session.row.badges` | Session rows in the sidebar | -| `session.header.badges` | Session header | +| Slot | Rendered in | +| ------------------------ | ---------------------------------------- | +| `header.actions` | Top header | +| `session.header.actions` | Session header | +| `message.actions` | Message context menu | +| `composer.actions` | Chat composer, above the input | +| `session.row.badges` | Session rows in the sidebar | +| `session.header.badges` | Session header | +| `plugin.menu` | The plugin's own row in the plugins menu | + +`plugin.menu` is the one slot that does not add a row: it takes over the row +showing your plugin in the header's plugins menu. `label` becomes the row label +(replacing `displayName`) and activating the row runs `onActivate`, so a plugin +can send its name anywhere it likes. Without it the row stays an inert group +header. Only the first visible `plugin.menu` action of a plugin is used. Settings are not a slot: `registerSettings()` drives the schema-rendered form that appears in the Plugins tab. @@ -278,8 +366,18 @@ registry.registerUiAction({ `onActivate` kinds: - `{ kind: 'rpc', method, params? }` — calls your RPC method with the current - context (`sessionId`, `workdir`) attached. + context (`sessionId`, `workdir`, `projectId`) attached. The RPC handler may return + `{ openPanel: string, content?: DeclarativeNode[] }` to immediately open a panel + and pre-fill its content in a single round-trip, and/or + `{ invalidate: string[] }` to tell the client which cached item lists went stale + (`'agents'`, `'commands'`, `'skills'`, `'workflows'`, `'mcpServers'`). The named + lists are refetched in place, so items written to disk by an RPC (an installed + pack, a generated agent, …) show up without a page reload or a server restart. - `{ kind: 'openPanel', panelId }` — opens one of your panels. +- `{ kind: 'openSettings', tab? }` — opens the global settings modal, optionally + on a given tab: a core tab id (`plugins`, `tools`, `skills`, …) or a full + plugin tab reference `plugin::` for one of the settings + tabs you registered. Omit `tab` for the default tab. - `{ kind: 'openUrl', url }` — opens a URL in a new tab. `visibleWhen` gates a contribution on the slot context: `hasSession`, @@ -326,30 +424,122 @@ is rendered repeatedly. Existing plugins keep the original session-first cache behavior when it is omitted. Session-row badge RPC context includes `sessionId`, `projectId`, and the effective `workdir`. -**Panels** +**Panels and Declarative UI** ```ts registry.registerUiPanel({ id: 'quota', title: { en: 'Usage & quota', fr: 'Utilisation et quota' }, - size: 'xl', // 'sm' | 'md' | 'lg' | 'xl' (80vw) | 'full' (95vw) (default: 'md') + size: 'xl', // 'sm' | 'md' | 'lg' | 'xl' (80vw) | '2xl' | '3xl' | 'full' (95vw) (default: 'md') kind: 'declarative', content: [ - { type: 'text', text: { en: 'Live usage', fr: 'Utilisation en direct' } }, + { type: 'text', text: { en: 'Live usage', fr: 'Utilisation en direct' }, muted: false, className: 'mb-2' }, { type: 'keyValue', items: [{ key: { en: 'Remaining', fr: 'Restant' }, value: '{{tokens}}' }] }, { type: 'progress', label: { en: 'Budget', fr: 'Budget' }, value: 25, max: 100, tone: 'info' }, { type: 'table', columns: [{ en: 'Model', fr: 'Modèle' }], rows: [['gpt-x']] }, - { type: 'badge', label: { en: 'Pro', fr: 'Pro' }, tone: 'info' }, - { type: 'button', label: { en: 'Refresh', fr: 'Actualiser' }, onActivate: { kind: 'rpc', method: 'refresh' } }, + { type: 'badge', label: { en: 'Pro', fr: 'Pro' }, tone: 'info', color: '#10b981', className: 'px-2' }, + { + type: 'button', + label: { en: 'Refresh', fr: 'Actualiser' }, + title: { en: 'Refresh quota', fr: 'Actualiser le quota' }, + variant: 'default', // 'default' | 'primary' | 'danger' | 'ghost' | 'pill' + icon: 'refresh', + disabled: false, + onActivate: { kind: 'rpc', method: 'refresh' }, + }, + { + type: 'stack', + direction: 'row', // 'row' | 'column' + gap: 'sm', // 'none' | 'xs' | 'sm' | 'md' | 'lg' + align: 'center', // 'start' | 'center' | 'end' | 'stretch' + justify: 'between', // 'start' | 'center' | 'end' | 'between' + children: [ + { + type: 'input', + id: 'user-input', + inputType: 'text', // 'text' | 'number' | 'password' | 'checkbox' | 'textarea' + label: { en: 'Name', fr: 'Nom' }, + placeholder: { en: 'Enter name...', fr: 'Entrer un nom...' }, + defaultValue: '{{name}}', + rows: 3, // for textarea + defaultChecked: false, // for checkbox + disabled: false, + onChange: { kind: 'rpc', method: 'updateField' }, + onBlur: { kind: 'rpc', method: 'saveField' }, + }, + { + type: 'select', + id: 'category', + label: { en: 'Category', fr: 'Catégorie' }, + options: [{ value: 'general', label: { en: 'General', fr: 'Général' } }], + defaultValue: 'general', + onChange: { kind: 'rpc', method: 'updateCategory' }, + }, + ], + }, + { + type: 'card', + title: { en: 'Details', fr: 'Détails' }, + subtitle: { en: 'Summary', fr: 'Résumé' }, + tone: 'neutral', + children: [{ type: 'text', text: { en: 'Card body', fr: 'Corps de carte' } }], + }, + { + type: 'details', + title: { en: 'Advanced options', fr: 'Options avancées' }, + defaultOpen: false, + children: [{ type: 'text', text: { en: 'Hidden content', fr: 'Contenu masqué' } }], + }, + { + type: 'callout', + title: { en: 'Notice', fr: 'Remarque' }, + text: { en: 'Important note', fr: 'Note importante' }, + tone: 'warning', + icon: 'warning', + }, + { type: 'icon', icon: 'star', tone: 'warning' }, + { type: 'iframe', url: 'https://example.com/widget', height: 250, width: '100%' }, { type: 'divider' }, ], }) ``` -Declarative node types: `text`, `keyValue`, `table`, `progress`, `badge`, -`button`, `divider`. String values may contain `{{key}}` placeholders filled -from values you publish with `context.publish(panelId, key, value)`; published -state arrives over WebSocket (`plugin.ui_state`) and re-renders the open panel. +**Zones, components and overrides** + +Zones are named mount points in the core UI. `registerUiComponent({ zone, component })` +injects a node into a zone; `registerUiOverride({ zone, mode, replacement })` +replaces (`mode: 'replace'`) or hides (`mode: 'hide'`) the native content of a +zone. Both accept `visibleWhen`, including `{ eq: { key: value } }` matched +against the zone context (`provider.modal.auth` and `provider.modal.step2` pass +`providerId`, `backend`, `authAdapter`, `transportAdapter`; `providerId` is the +real provider id, so per-provider data — accounts, tokens — must be scoped by +it). + +```ts +registry.registerUiOverride({ + id: 'my-provider-auth', + zone: 'provider.modal.auth', + mode: 'replace', + visibleWhen: { eq: { transportAdapter: 'my-transport' } }, + replacement: staticShellNode, // provider-agnostic fallback + contentSource: { kind: 'rpc', method: 'getAuthUi', refreshMs: 3000 }, +}) +``` + +`contentSource` keeps a zone's content live instead of freezing it at +registration time: the host calls the RPC when the contribution mounts and, when +`refreshMs` is set, again on that interval while it stays mounted (cancelled on +unmount). The RPC receives the zone context as params (`providerId`, `modelId`, +`tabId`, `contributionId`) plus `sessionId`/`workdir`/`projectId`, and returns +`{ content: DeclarativeNode }` or `{ nodes: DeclarativeNode[] }`. Source content +wins over `component`/`replacement`; a failing call keeps the last rendered +content, so the static declaration is only ever the fallback. + +**Declarative node types:** `text`, `keyValue`, `table`, `progress`, `badge`, `button`, `toggle`, `stack`, `card`, `callout`, `icon`, `details`, `input`, `select`, `iframe`, `divider`. String values may contain `{{key}}` placeholders filled from values you publish with `context.publish(panelId, key, value)`; published state arrives over WebSocket (`plugin.ui_state`) and re-renders the open panel. + +**Panel Lifecycle Hooks:** + +When a declarative panel or settings tab opens, the host automatically calls the plugin's `initPanel` RPC method with `{ panelId: string, tabId?: string }` and the effective project/session context (`workdir`, `projectId`, `sessionId`). The RPC can return `{ content: DeclarativeNode[] }` or publish state to dynamically hydrate or refresh content upon modal display. When a panel is opened via an action returning `{ openPanel, content }`, the pre-filled content takes precedence and `initPanel` is not invoked to prevent accidental overwrites. Iframe panels: @@ -456,6 +646,28 @@ registry.registerAsset('board.html') Files are served read-only from `/api/plugins//assets/`; only registered relative paths are reachable, and path traversal is rejected. +### Message transforms (`transforms`) + +```ts +registry.registerMessageTransform({ + id: 'compressor', + priority: 50, // optional ordering (lower runs first, default: 100) + transform: async (messages, context) => { + // context: { sessionId, projectId?, workdir, model, systemPrompt, mode?, signal? } + const compressed = await compress(messages, context.model) + return { + messages: compressed, + systemPrompt: context.systemPrompt, + metadata: { tokensSaved: 150 }, + } + }, +}) +``` + +- Transforms intercept and mutate context messages and/or system prompt before dispatch to the LLM. +- **Fail-open resilience**: If a transform throws an error or times out (5 s), the core logs a warning and proceeds with uncompressed/unmodified messages without interrupting the turn. +- Multiple active transforms execute sequentially in priority order. + ### Context API | Member | Description | diff --git a/plugins-registry.json b/plugins-registry.json index 8cf3c481d..c554a4ec6 100644 --- a/plugins-registry.json +++ b/plugins-registry.json @@ -3,36 +3,120 @@ "name": "openfox-chatgpt", "displayName": "ChatGPT", "description": "Authenticate with a ChatGPT Plus or Pro account via browser-based OAuth.", - "githubUrl": "https://github.com/arthurlacoste/openfox-chatgpt" + "githubUrl": "https://github.com/arthurlacoste/openfox-chatgpt", + "author": "arthurlacoste", + "icon": "https://chatgpt.com/favicon.ico" }, { "name": "openfox-github-copilot", "displayName": "Github Copilot", "description": "Authenticate with a Github copilot account via browser-based OAuth.", - "githubUrl": "https://github.com/JamesDAdams/openfox-github-copilot" + "githubUrl": "https://github.com/JamesDAdams/openfox-github-copilot", + "author": "James Adams", + "icon": "https://github.githubassets.com/favicons/favicon.png" }, { "name": "openfox-google-antigravity", "displayName": "Google Antigravity", "description": "Authenticate with a Google Antigravity account via browser-based OAuth | ⚠️ Using this plugin violates Google's Terms of Service.", - "githubUrl": "https://github.com/JamesDAdams/openfox-google-antigravity" + "githubUrl": "https://github.com/JamesDAdams/openfox-google-antigravity", + "author": "James Adams", + "icon": "https://brandlogos.net/wp-content/uploads/2025/12/google_antigravity-logo_brandlogos.net_qu4jc.png" }, { "name": "openfox-xai-supergrok", "displayName": "xAI Grok (SuperGrok)", "description": "Authenticate with an X (xAI) SuperGrok subscription via OAuth. No API key required. | ⚠️ Using this plugin may violate xAI's Terms of Service.", - "githubUrl": "https://github.com/Olgean-Group/openfox-xai-supergrok" + "githubUrl": "https://github.com/Olgean-Group/openfox-xai-supergrok", + "author": "Olgean-Group", + "icon": "https://x.ai/favicon.ico" }, { "name": "openfox-openrouter-free", "displayName": "OpenRouter (Free Models)", "description": "OpenRouter provider filtered to include only free plans, with automatic hourly updates (once per hour) and 1-Click OAuth / API key authentication.", - "githubUrl": "https://github.com/JamesDAdams/openfox-openrouter-free" + "githubUrl": "https://github.com/JamesDAdams/openfox-openrouter-free", + "author": "James Adams", + "icon": "https://openrouter.ai/favicon.ico" }, { "name": "openfox-opencode-free", "displayName": "OpenCode (Free Models)", "description": "OpenCode provider filtered to free models only (-free suffix), with automatic hourly model updates (1x/hour) and API key auth.", - "githubUrl": "https://github.com/JamesDAdams/openfox-opencode-free" + "githubUrl": "https://github.com/JamesDAdams/openfox-opencode-free", + "author": "James Adams", + "icon": "https://opencode.ai/_build/assets/preview-opencode-logo-dark-ZBwNGoYp.png" + }, + { + "name": "openfox-opencode-go", + "displayName": "OpenCode Go", + "description": "Access curated open coding models with generous limits via your OpenCode Go subscription ($10/month).", + "githubUrl": "https://github.com/JamesDAdams/openfox-opencode-go", + "author": "James Adams", + "icon": "https://opencode.ai/_build/assets/preview-opencode-logo-dark-ZBwNGoYp.png" + }, + { + "name": "openfox-cheaperinference", + "displayName": "Cheaper Inference", + "description": "Access leading discounted AI models from multiple providers through one API with live marketplace rates.", + "githubUrl": "https://github.com/JamesDAdams/openfox-cheaperinference", + "author": "James Adams", + "icon": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT61SdDKpiR436_O4PWXqxT2q9bx4G3E_GoijE7Hxskhw&s" + }, + { + "name": "openfox-headroom", + "displayName": "Headroom Compression", + "description": "Native context compression for OpenFox using Headroom proxy to optimize tokens and reduce costs without breaking prefix caching.", + "githubUrl": "https://github.com/JamesDAdams/openfox-headroom", + "author": "James Adams", + "icon": "https://avatars.githubusercontent.com/u/294291659?s=60&v=4" + }, + { + "name": "openfox-omniroute-quota", + "displayName": "OmniRoute Quota", + "description": "Reports OmniRoute usage and quotas to OpenFox via the quota plugin.", + "githubUrl": "https://github.com/JamesDAdams/openfox-omniroute-quota", + "author": "James Adams", + "icon": "" + }, + { + "name": "openfox-quota", + "displayName": "Usage & Quotas", + "description": "Usage and quota tracking plugin for OpenFox with generic providers and custom component overrides.", + "githubUrl": "https://github.com/JamesDAdams/openfox-quota", + "author": "James Adams", + "icon": "" + }, + { + "name": "openfox-model-pricing", + "displayName": "Model Pricing", + "description": "Comprehensive model pricing, discount badges, dynamic rates, and real-time session cost tracking plugin for OpenFox.", + "githubUrl": "https://github.com/JamesDAdams/openfox-model-pricing", + "author": "James Adams", + "icon": "" + }, + { + "name": "openfox-rtk", + "displayName": "RTK Token Optimizer", + "description": "Reduce token consumption by filtering and rewriting shell command output through RTK (Rust Token Killer).", + "githubUrl": "https://github.com/JamesDAdams/openfox-rtk", + "author": "James Adams", + "icon": "https://avatars.githubusercontent.com/u/258253854?s=60&v=4" + }, + { + "name": "openfox-community-hub", + "displayName": "Community Hub", + "description": "Share, export, import, and install OpenFox workflows, agents, sub-agents, MCP servers, skills, and commands via static Git/CDN registries.", + "githubUrl": "https://github.com/JamesDAdams/openfox-community-hub", + "author": "James Adams", + "icon": "" + }, + { + "name": "openfox-whitelist", + "displayName": "Whitelist File Access", + "description": "Adds Whitelist and Whitelist Only danger levels to allow configured external paths without confirmation, and optionally auto-reject unauthorized files.", + "githubUrl": "https://github.com/JamesDAdams/openfox-whitelist", + "author": "James Adams", + "icon": "" } ] diff --git a/src/cli/config.ts b/src/cli/config.ts index c664b3bc9..3b0cf6faa 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -36,8 +36,8 @@ const backendSchema = z.enum([ const modelConfigSchema = z .object({ id: z.string(), - contextWindow: z.number(), - source: z.enum(['backend', 'user', 'default']), + contextWindow: z.number().default(128000), + source: z.enum(['backend', 'user', 'default']).default('backend'), temperature: z.number().optional(), topP: z.number().optional(), topK: z.number().optional(), diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 707b39137..20dd4f17a 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import type { ModelConfig, Provider } from '../shared/types.js' +import type { LLMMessage } from '../server/llm/types.js' import type { LocalizedString, PluginBadgeTone, @@ -28,12 +29,15 @@ export type { DeclarativeNode, LocalizedString, PluginActivation, + PluginBadgeTone, PluginCapability, PluginContributionSummary, + PluginDangerLevelView, PluginInfo, PluginNotification, PluginNotificationAction, PluginSettingsField, + PluginSettingsLinkButton, PluginSettingsSchema, PluginSettingsTab, PluginSettingScope, @@ -70,12 +74,14 @@ export const PLUGIN_API_VERSION = 2 export const pluginManifestSchema = z.object({ name: z.string().min(1), version: z.string().min(1), + author: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(), openfox: z.object({ apiVersion: z.number().int(), entry: z.string().min(1).optional(), plugin: z.string().min(1).optional(), displayName: z.string().min(1).optional(), description: z.string().optional(), + author: z.string().optional(), icon: z.string().optional(), logo: z.string().optional(), capabilities: z.array(z.string()).optional(), @@ -86,12 +92,14 @@ export const pluginManifestSchema = z.object({ export interface PluginManifest { name: string version: string + author?: string openfox: { apiVersion: number entry?: string plugin?: string displayName?: string description?: string + author?: string icon?: string logo?: string capabilities?: PluginCapability[] @@ -275,6 +283,52 @@ export interface PluginRegistry { ): void registerRpc(method: string, handler: PluginRpcHandler): void registerAsset(relativePath: string): void + registerMessageTransform(transform: PluginMessageTransform): void + registerDangerLevel(dangerLevel: PluginDangerLevel): void +} + +export interface PluginPathAccessContext { + paths: string[] + workdir: string + sessionId: string + projectId?: string | undefined + tool: string + command?: string | undefined +} + +export type PluginPathAccessDecision = { action: 'allow' } | { action: 'deny'; message?: string } | { action: 'ask' } + +export interface PluginDangerLevel { + id: string + label: LocalizedString + description?: LocalizedString + badgeTone?: PluginBadgeTone + evaluatePathAccess?(context: PluginPathAccessContext): Promise | PluginPathAccessDecision +} + +export interface PluginMessageTransformContext { + sessionId: string + projectId?: string + workdir: string + model: string + systemPrompt: string + mode?: string + signal?: AbortSignal +} + +export interface PluginMessageTransformResult { + messages: LLMMessage[] + systemPrompt?: string + metadata?: Record +} + +export interface PluginMessageTransform { + id: string + priority?: number + transform( + messages: LLMMessage[], + context: PluginMessageTransformContext, + ): Promise | PluginMessageTransformResult | LLMMessage[] } export interface PluginTransitionContext { diff --git a/src/server/chat/agent-loop.test.ts b/src/server/chat/agent-loop.test.ts index 0d488aef1..4dea6a028 100644 --- a/src/server/chat/agent-loop.test.ts +++ b/src/server/chat/agent-loop.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { ToolResult, ToolCall } from '../../shared/types.js' import type { SessionManager } from '../session/index.js' import type { ToolRegistry } from '../tools/types.js' @@ -60,6 +60,7 @@ vi.mock('./stream-pure.js', async (importOriginal) => { } }) +import { setPluginMessageTransforms, clearPluginMessageTransforms } from '../plugins/message-transforms.js' import { runTopLevelAgentLoop } from './agent-loop.js' import { executeTools } from './execute-tools.js' import { getEventStore } from '../events/store.js' @@ -2270,3 +2271,121 @@ describe('runTopLevelAgentLoop queue draining', () => { expect(queuedInHistory).toHaveLength(0) }) }) + +// ============================================================================ +// runTopLevelAgentLoop — plugin message transforms +// ============================================================================ + +describe('runTopLevelAgentLoop plugin message transforms', () => { + let mockEventStore: EventStore + let mockSessionManager: SessionManager + let mockLLMClient: any + let mockTurnMetrics: TurnMetrics + + beforeEach(() => { + vi.clearAllMocks() + clearPluginMessageTransforms() + + mockEventStore = { + append: vi.fn(), + getEvents: vi.fn().mockReturnValue([]), + getLatestSeq: vi.fn().mockReturnValue(0), + cleanupOldEvents: vi.fn().mockReturnValue(0), + } as unknown as EventStore + ;(getEventStore as any).mockReturnValue(mockEventStore) + + mockLLMClient = { + getModel: vi.fn().mockReturnValue('test-model'), + } + + mockTurnMetrics = { + addToolTime: vi.fn(), + addLLMCall: vi.fn(), + addThinkingTime: vi.fn(), + buildStats: vi.fn().mockReturnValue({}), + } as unknown as TurnMetrics + + mockSessionManager = { + enterPauseGate: vi.fn().mockResolvedValue('released'), + requireSession: vi.fn().mockReturnValue({ + workdir: '/test', + projectId: 'test-project', + executionState: null, + criteria: [], + isRunning: false, + }), + getEffectiveWorkdir: vi.fn().mockReturnValue('/test'), + getProjectWorkdir: vi.fn().mockReturnValue('/test'), + getContextState: vi.fn().mockReturnValue({ + currentTokens: 0, + maxTokens: 200000, + compactionCount: 0, + }), + getCurrentModelContext: vi.fn().mockReturnValue(200000), + getCurrentModelSettings: vi.fn().mockReturnValue(undefined), + getModelCompactionThreshold: vi.fn().mockReturnValue(0.85), + setCurrentContextSize: vi.fn(), + drainAsapMessages: vi.fn().mockReturnValue([]), + } as unknown as SessionManager + + ;(getAllInstructions as any).mockResolvedValue({ content: '', files: [] }) + ;(getEnabledSkillMetadata as any).mockResolvedValue([]) + ;(consumeStreamGenerator as any).mockResolvedValue({ + content: 'done', + toolCalls: [], + segments: [], + usage: { promptTokens: 10, completionTokens: 5 }, + timing: { durationMs: 10 }, + modelParams: {}, + }) + }) + + afterEach(() => { + clearPluginMessageTransforms() + }) + + it('passes transformed messages and systemPrompt to streamLLMPure', async () => { + setPluginMessageTransforms([ + { + pluginId: 'test-compressor', + transform: { + id: 'compressor', + transform: (_msgs, ctx) => { + expect(ctx.sessionId).toBe('test-session') + expect(ctx.workdir).toBe('/test') + return { + messages: [{ role: 'user', content: 'compressed prompt' }], + systemPrompt: 'compressed system prompt', + } + }, + }, + }, + ]) + + const assembleRequestMock = vi.fn().mockReturnValue({ + systemPrompt: 'original system prompt', + messages: [{ role: 'user', content: 'uncompressed prompt' }], + tools: [], + }) + + await runTopLevelAgentLoop( + { + mode: 'builder', + append: vi.fn(), + sessionManager: mockSessionManager, + sessionId: 'test-session', + llmClient: mockLLMClient, + statsIdentity: { providerId: 'test', providerName: 'Test', backend: 'unknown' as const, model: 'test-model' }, + assembleRequest: assembleRequestMock as any, + getToolRegistry: () => ({ tools: [], definitions: [], execute: vi.fn() }) as any, + getConversationMessages: vi.fn().mockResolvedValue([]), + }, + mockTurnMetrics, + ).catch(() => {}) + + const callArgs = (streamLLMPure as any).mock.calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.systemPrompt).toBe('compressed system prompt') + expect(callArgs.messages).toEqual([{ role: 'user', content: 'compressed prompt' }]) + }) +}) diff --git a/src/server/chat/agent-loop.ts b/src/server/chat/agent-loop.ts index f9edf905e..1596ed27c 100644 --- a/src/server/chat/agent-loop.ts +++ b/src/server/chat/agent-loop.ts @@ -53,6 +53,7 @@ import { drainQueue } from './drain-queue.js' import { COMPACTION_PROMPT, CONTINUE_PROMPT, CONTINUE_AFTER_STREAM_ERROR_PROMPT } from './prompts.js' import { logger } from '../utils/logger.js' import { emitPluginHook } from '../plugins/hook-emitter.js' +import { applyPluginMessageTransforms } from '../plugins/message-transforms.js' import type { LLMRetryPolicy } from '../runner/types.js' import { DEFAULT_LLM_RETRY_POLICY } from '../runner/types.js' import { serverT } from '../i18n.js' @@ -402,12 +403,22 @@ export async function runTopLevelAgentLoop( const allAgents = await loadAllAgentsDefault(sessionManager.getProjectWorkdir(sessionId)) const subAgentAliases = new Set(getSubAgents(allAgents).map((a) => a.metadata.id)) + const transformResult = await applyPluginMessageTransforms(assembledRequest.messages, { + sessionId, + ...(session.projectId ? { projectId: session.projectId } : {}), + workdir: sessionManager.getEffectiveWorkdir(sessionId), + model: attemptClient.getModel(), + systemPrompt: assembledRequest.systemPrompt, + ...(config.mode ? { mode: config.mode } : {}), + ...(signal ? { signal } : {}), + }) + const streamGen = streamLLMPure({ messageId: assistantMsgId, - systemPrompt: assembledRequest.systemPrompt, + systemPrompt: transformResult.systemPrompt, llmClient: attemptClient, sessionId, - messages: assembledRequest.messages, + messages: transformResult.messages, tools: assembledRequest.tools, toolChoice: 'auto', signal, diff --git a/src/server/chat/execute-tools.ts b/src/server/chat/execute-tools.ts index 79ab2150a..333e4d8cc 100644 --- a/src/server/chat/execute-tools.ts +++ b/src/server/chat/execute-tools.ts @@ -130,7 +130,9 @@ export async function executeTools( return { // LLM-facing (rendered into the tool content) — English by design. success: false, - error: `User denied access to ${error.paths.join(', ')}. If you need this file, explain why and ask for permission.`, + error: + error.customMessage ?? + `User denied access to ${error.paths.join(', ')}. If you need this file, explain why and ask for permission.`, durationMs: Date.now() - startTime, truncated: false, } diff --git a/src/server/db/projects.ts b/src/server/db/projects.ts index 9bf566a8a..997d8d340 100644 --- a/src/server/db/projects.ts +++ b/src/server/db/projects.ts @@ -1,4 +1,4 @@ -import type { Project } from '../../shared/types.js' +import type { Project, DangerLevel } from '../../shared/types.js' import { getDatabase } from './index.js' // ============================================================================ @@ -212,4 +212,4 @@ function rowToProject(row: ProjectRow): Project { } } -export type DangerLevel = 'normal' | 'dangerous' +export type { DangerLevel } from '../../shared/types.js' diff --git a/src/server/db/sessions.ts b/src/server/db/sessions.ts index 7d5c0aa8a..4528ce69c 100644 --- a/src/server/db/sessions.ts +++ b/src/server/db/sessions.ts @@ -5,10 +5,10 @@ * is stored in the events table and derived via EventStore folding. */ -import type { Session, SessionSummary, SessionMode, SessionPhase } from '../../shared/types.js' +import type { Session, SessionSummary, SessionMode, SessionPhase, DangerLevel } from '../../shared/types.js' import { getDatabase } from './index.js' import { resolveDefaultAgentId } from '../agents/registry.js' -export type DangerLevel = 'normal' | 'dangerous' +export type { DangerLevel } from '../../shared/types.js' function getProjectDangerLevel(projectId: string): DangerLevel { try { diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts index 43121b055..dd2af4946 100644 --- a/src/server/db/settings.ts +++ b/src/server/db/settings.ts @@ -34,6 +34,10 @@ export const SETTINGS_KEYS = { DISPLAY_MODEL_FAVORITES: 'display.modelFavorites', DISPLAY_MOBILE_FULLSCREEN_COMPOSER: 'display.mobileFullscreenComposer', DISPLAY_FULLSCREEN_SLASH_COMMAND: 'display.fullscreenSlashCommand', + DISPLAY_SHOW_PROJECTS_ABOVE_SESSIONS: 'display.showProjectsAboveSessions', + DISPLAY_DANGER_LEVEL_DISPLAY_MODE: 'display.dangerLevelDisplayMode', + DISPLAY_DANGER_LEVEL_AUTO_LIST: 'display.dangerLevelAutoList', + DISPLAY_DANGER_LEVEL_AUTO_LIST_THRESHOLD: 'display.dangerLevelAutoListThreshold', LLM_DYNAMIC_SYSTEM_PROMPT: 'llm.dynamicSystemPrompt', LLM_CAVEMAN_THINKING: 'llm.cavemanThinking', CACHE_WARMING: 'cache.warming', @@ -46,7 +50,6 @@ export const SETTINGS_KEYS = { SEARCH_TAVILY_API_KEY: 'search.tavilyApiKey', SEARCH_SEARXNG_URL: 'search.searxngUrl', SEARCH_SEARXNG_API_KEY: 'search.searxngApiKey', - TOOLS_USE_RTK: 'tools.useRtk', TOOLS_SHELL: 'tools.shell', CONFIRM_ON_WORKSPACE_ACTIONS: 'tools.confirmOnWorkspaceActions', FEATURES_PER_SESSION_MCP: 'features.perSessionMcp', @@ -85,7 +88,11 @@ export const SETTINGS_DEFAULTS: Record = { [SETTINGS_KEYS.DISPLAY_COLLAPSE_FAVORITES_BY_DEFAULT]: 'false', [SETTINGS_KEYS.DISPLAY_MODEL_FAVORITES]: '[]', [SETTINGS_KEYS.DISPLAY_MOBILE_FULLSCREEN_COMPOSER]: 'false', + [SETTINGS_KEYS.DISPLAY_SHOW_PROJECTS_ABOVE_SESSIONS]: 'false', [SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND]: 'false', + [SETTINGS_KEYS.DISPLAY_DANGER_LEVEL_DISPLAY_MODE]: 'default', + [SETTINGS_KEYS.DISPLAY_DANGER_LEVEL_AUTO_LIST]: 'false', + [SETTINGS_KEYS.DISPLAY_DANGER_LEVEL_AUTO_LIST_THRESHOLD]: '3', [SETTINGS_KEYS.LLM_DYNAMIC_SYSTEM_PROMPT]: 'false', [SETTINGS_KEYS.LLM_CAVEMAN_THINKING]: 'false', [SETTINGS_KEYS.CACHE_WARMING]: 'false', @@ -102,7 +109,6 @@ export const SETTINGS_DEFAULTS: Record = { { type: 'chord', key: '4', modifiers: ['ctrl'] }, ], }), - [SETTINGS_KEYS.TOOLS_USE_RTK]: 'false', [SETTINGS_KEYS.TOOLS_SHELL]: 'cmd', [SETTINGS_KEYS.CONFIRM_ON_WORKSPACE_ACTIONS]: 'false', [SETTINGS_KEYS.FEATURES_PER_SESSION_MCP]: 'false', diff --git a/src/server/index.ts b/src/server/index.ts index 72bec1967..1de4eec50 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,7 +22,8 @@ import { buildModelsUrl } from './llm/url-utils.js' import { createMockLLMClient } from './llm/mock.js' import { createProviderManager, parseDefaultModelSelection } from './provider-manager.js' -import { isReasoningEffortValidForModel } from '../shared/reasoning-effort.js' +import { cascadeProviderDelete } from './providers/adapters/provider-deletion.js' +import { isReasoningEffortValidForModel, collapseModeFamilies } from '../shared/reasoning-effort.js' import { createToolRegistry, setMcpTools, getBuiltInToolNames } from './tools/index.js' import { ALWAYS_ALLOWED, ALWAYS_ALLOWED_FOR_SUBAGENTS, TOP_LEVEL_ONLY_TOOLS } from './tools/tool-policy.js' import { McpManager, createMcpTools } from './mcp/index.js' @@ -221,6 +222,20 @@ export async function createServerHandle(config: Config): Promise }) }) } + + // Background-refresh transport-adapter providers that have no stored models + // so their model list (with reasoningEfforts) is available immediately in the UI. + const transportProviders = providerManager + .getProviders() + .filter((p) => p.transportAdapter && p.models.length === 0 && p.id !== activeProvider?.id) + for (const p of transportProviders) { + providerManager.refreshProviderModels(p.id).catch((err) => { + logger.debug('Startup transport provider model refresh failed', { + providerId: p.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + } } initLLM().catch((err) => @@ -500,12 +515,12 @@ export async function createServerHandle(config: Config): Promise const updates: { name?: string customInstructions?: string | null - dangerLevel?: 'normal' | 'dangerous' | null + dangerLevel?: string | null defaultAgent?: string | null } = {} if (name !== undefined) updates.name = name if (customInstructions !== undefined) updates.customInstructions = customInstructions - if (dangerLevel !== undefined) updates.dangerLevel = dangerLevel as 'normal' | 'dangerous' | null + if (dangerLevel !== undefined) updates.dangerLevel = dangerLevel as string | null if (defaultAgent !== undefined) updates.defaultAgent = defaultAgent as string | null const updated = updateProject(req.params.id, updates) if (!updated) { @@ -1355,8 +1370,8 @@ export async function createServerHandle(config: Config): Promise } const { dangerLevel } = req.body - if (!dangerLevel || !['normal', 'dangerous'].includes(dangerLevel)) { - return res.status(400).json({ error: 'dangerLevel is required and must be "normal" or "dangerous"' }) + if (!dangerLevel || typeof dangerLevel !== 'string') { + return res.status(400).json({ error: 'dangerLevel is required and must be a string' }) } sessionManager.setDangerLevel(sessionId, dangerLevel) @@ -1919,25 +1934,6 @@ export async function createServerHandle(config: Config): Promise res.json({ key, value }) }) - // RTK availability check - app.get('/api/tools/rtk-check', async (_req, res) => { - const { spawn } = await import('node:child_process') - try { - const available = await new Promise((resolve) => { - const proc = spawn('rtk', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }) - let out = '' - proc.stdout?.on('data', (d: Buffer) => { - out += d.toString() - }) - proc.on('error', () => resolve(false)) - proc.on('close', (code) => resolve(code === 0 && out.startsWith('rtk '))) - }) - res.json({ available }) - } catch { - res.json({ available: false }) - } - }) - // Shells available for the tools.shell setting (Windows only; empty elsewhere) app.get('/api/tools/shells', async (_req, res) => { const { listAvailableShells } = await import('./utils/platform.js') @@ -2202,27 +2198,34 @@ export async function createServerHandle(config: Config): Promise const { fetchModelsWithContext } = await import('./provider-manager.js') const { getModelProfile } = await import('./llm/profiles.js') const { getCatalogEntry } = await import('./providers/model-catalog.js') - const models = await fetchModelsWithContext( + const rawModels = await fetchModelsWithContext( url, apiKey, backend as 'ollama' | 'vllm' | 'sglang' | 'llamacpp' | 'lmstudio' | 'unsloth' | 'unknown' | undefined, ) - if (models.length === 0) { + if (rawModels.length === 0) { return res.status(404).json({ error: `No models found at ${buildModelsUrl(url)}`, url }) } + const models = collapseModeFamilies(rawModels) res.json({ models: models.map((m) => { const profile = getModelProfile(m.id) const catalog = getCatalogEntry(m.id) return { id: m.id, + name: m.name, contextWindow: m.contextWindow, supportsVision: m.supportsVision ?? profile.supportsVision, defaultTemperature: profile.temperature, defaultTopP: profile.topP, defaultTopK: profile.topK, defaultMaxTokens: profile.defaultMaxTokens, - ...(catalog ? { reasoningEfforts: catalog.reasoningEfforts } : {}), + ...(m.modes?.length ? { modes: m.modes } : {}), + ...(m.reasoningEfforts?.length + ? { reasoningEfforts: m.reasoningEfforts } + : catalog + ? { reasoningEfforts: catalog.reasoningEfforts } + : {}), } }), url, @@ -2687,6 +2690,11 @@ export async function createServerHandle(config: Config): Promise const { id } = req.params const { loadGlobalConfig, saveGlobalConfig, removeProvider } = await import('../cli/config.js') const globalConfig = await loadGlobalConfig(config.mode ?? 'production', config.globalConfigPath) + const existingProvider = globalConfig.providers.find((p) => p.id === id) + + // Deleting a provider deletes the credentials (accounts) it created. + await cascadeProviderDelete(providerAdapters.getAuth(existingProvider?.authAdapter), id) + const updatedConfig = removeProvider(globalConfig, id) await saveGlobalConfig(config.mode ?? 'production', updatedConfig, config.globalConfigPath) diff --git a/src/server/plugins/danger-levels.test.ts b/src/server/plugins/danger-levels.test.ts new file mode 100644 index 000000000..298a5db0e --- /dev/null +++ b/src/server/plugins/danger-levels.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { PluginRegistry } from './registry.js' +import { + setPluginDangerLevels, + getPluginDangerLevel, + listPluginDangerLevels, + clearPluginDangerLevels, + evaluatePluginPathAccess, +} from './danger-levels.js' +import type { PluginDangerLevel } from '../../plugin/index.js' + +describe('Plugin Danger Levels & Security Policies', () => { + beforeEach(() => { + clearPluginDangerLevels() + }) + + it('registers a custom danger level on PluginRegistry', () => { + const registry = new PluginRegistry({ mode: 'development', configDirectory: '/tmp' }) + registry.beginPlugin('test-plugin', { + id: 'test-plugin', + version: '1.0.0', + runtime: { mode: 'development', configDirectory: '/tmp' }, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + storage: { get: vi.fn(), set: vi.fn() }, + settings: vi.fn().mockReturnValue({}), + notify: vi.fn(), + publish: vi.fn(), + }) + + const customDl: PluginDangerLevel = { + id: 'custom-safe', + label: { en: 'Custom Safe', fr: 'Personnalisé Sûr' }, + description: { en: 'Custom description', fr: 'Description personnalisée' }, + badgeTone: 'success', + evaluatePathAccess: vi.fn().mockResolvedValue({ action: 'allow' }), + } + + registry.registerDangerLevel(customDl) + registry.endPlugin() + + const summary = registry.getContributionSummary('test-plugin') + expect(summary.dangerLevels).toBe(1) + + const ui = registry.getUiContributions() + expect(ui.dangerLevels).toHaveLength(1) + expect(ui.dangerLevels?.[0]).toEqual({ + id: 'custom-safe', + pluginId: 'test-plugin', + label: { en: 'Custom Safe', fr: 'Personnalisé Sûr' }, + description: { en: 'Custom description', fr: 'Description personnalisée' }, + badgeTone: 'success', + }) + + const dangerLevels = registry.getDangerLevels() + expect(dangerLevels).toHaveLength(1) + expect(dangerLevels[0]?.dangerLevel.id).toBe('custom-safe') + }) + + it('evaluates path access via evaluatePluginPathAccess', async () => { + const evaluateFn = vi.fn().mockResolvedValue({ action: 'deny', message: 'Custom rejection message' }) + setPluginDangerLevels([ + { + pluginId: 'test-plugin', + dangerLevel: { + id: 'strict', + label: { en: 'Strict', fr: 'Strict' }, + evaluatePathAccess: evaluateFn, + }, + }, + ]) + + expect(getPluginDangerLevel('strict')).toBeDefined() + expect(listPluginDangerLevels()).toHaveLength(1) + + const result = await evaluatePluginPathAccess('strict', { + paths: ['/etc/passwd'], + workdir: '/app', + sessionId: 'sess-1', + tool: 'read_file', + }) + + expect(evaluateFn).toHaveBeenCalledWith({ + paths: ['/etc/passwd'], + workdir: '/app', + sessionId: 'sess-1', + tool: 'read_file', + }) + expect(result).toEqual({ action: 'deny', message: 'Custom rejection message' }) + }) + + it('returns undefined when evaluating an unknown danger level', async () => { + const result = await evaluatePluginPathAccess('unknown', { + paths: ['/etc/passwd'], + workdir: '/app', + sessionId: 'sess-1', + tool: 'read_file', + }) + expect(result).toBeUndefined() + }) +}) diff --git a/src/server/plugins/danger-levels.ts b/src/server/plugins/danger-levels.ts new file mode 100644 index 000000000..50112a502 --- /dev/null +++ b/src/server/plugins/danger-levels.ts @@ -0,0 +1,43 @@ +import type { PluginDangerLevel, PluginPathAccessContext, PluginPathAccessDecision } from '../../plugin/index.js' +import { logger } from '../utils/logger.js' + +export interface OwnedPluginDangerLevel { + pluginId: string + dangerLevel: PluginDangerLevel +} + +let dangerLevels: OwnedPluginDangerLevel[] = [] + +export function setPluginDangerLevels(next: OwnedPluginDangerLevel[]): void { + dangerLevels = [...next] +} + +export function listPluginDangerLevels(): OwnedPluginDangerLevel[] { + return [...dangerLevels] +} + +export function getPluginDangerLevel(id: string): OwnedPluginDangerLevel | undefined { + return dangerLevels.find((entry) => entry.dangerLevel.id === id) +} + +export function clearPluginDangerLevels(): void { + dangerLevels = [] +} + +export async function evaluatePluginPathAccess( + dangerLevelId: string, + context: PluginPathAccessContext, +): Promise { + const owned = getPluginDangerLevel(dangerLevelId) + if (!owned || !owned.dangerLevel.evaluatePathAccess) return undefined + try { + return await owned.dangerLevel.evaluatePathAccess(context) + } catch (error) { + logger.warn('Plugin danger level evaluatePathAccess failed', { + pluginId: owned.pluginId, + dangerLevel: dangerLevelId, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } +} diff --git a/src/server/plugins/host.test.ts b/src/server/plugins/host.test.ts index c86369d7c..c1dfc6c6d 100644 --- a/src/server/plugins/host.test.ts +++ b/src/server/plugins/host.test.ts @@ -7,6 +7,7 @@ import { loadConfig } from '../config.js' import { PluginHost } from './host.js' import { emitPluginHook } from './hook-emitter.js' import { listPluginModelMetadataProviders } from './model-metadata.js' +import { listPluginMessageTransforms } from './message-transforms.js' import { listPluginTransitionHandlers, runPluginTransitionHandler } from './transition-handlers.js' import { getAllSettings } from '../db/settings.js' @@ -269,6 +270,20 @@ describe('PluginHost', () => { expect((globalThis as Record)['__deactivated']).toBe(2) }) + it('reinstalls an installed plugin and reloads its diagnostic', async () => { + await writePlugin( + configDirectory, + 'reinstall-plugin', + 2, + `registry.registerTool({ name: 'reinstall_tool', description: 'x', parameters: {}, execute: async () => ({ success: true }) });`, + ) + const host = makeHost(configDirectory) + await host.start() + const diag = await host.reinstall('reinstall-plugin') + expect(diag.loaded).toBe(true) + expect(diag.packageName).toBe('reinstall-plugin') + }) + it('refuses to uninstall plugins discovered outside the plugins directory', async () => { const cwd = join(configDirectory, 'app') const externalDir = join(cwd, 'node_modules', 'external-plugin') @@ -363,6 +378,27 @@ describe('PluginHost', () => { expect(host.getSettingsView('settings-plugin').values['limit']).toBe(10) }) + it('never persists or exposes read-only settings fields', async () => { + await writePlugin( + configDirectory, + 'readonly-plugin', + 2, + `registry.registerSettings({ fields: [ + { key: 'official', type: 'text', label: { en: 'Official', fr: 'Officiel' }, default: 'https://official.test', readOnly: true }, + { key: 'extra', type: 'text', label: { en: 'Extra', fr: 'Extra' } }, + ] });`, + ) + const host = makeHost(configDirectory) + await host.start() + + expect(host.updateSettings('readonly-plugin', { official: 'https://hacked.test', extra: 'kept' })).toEqual({ + errors: [], + }) + expect(getAllSettings()['plugin.readonly-plugin.global.official']).toBeUndefined() + expect(host.getSettingsView('readonly-plugin').values['official']).toBeUndefined() + expect(host.getSettingsView('readonly-plugin').values['extra']).toBe('kept') + }) + it('scopes plugin settings per project when requested', async () => { await writePlugin( configDirectory, @@ -571,4 +607,105 @@ describe('PluginHost', () => { }) }) }) + + it('stops update checker on host stop', async () => { + const host = makeHost(configDirectory) + const stopSpy = vi.spyOn(host.updateChecker, 'stop') + await host.start() + host.stop() + expect(stopSpy).toHaveBeenCalled() + }) + + it('registers, applies, and cleans up message transforms on enable/disable', async () => { + await writePlugin( + configDirectory, + 'transform-plugin', + 2, + `registry.registerMessageTransform({ + id: 'headroom_compressor', + priority: 10, + transform: (msgs) => msgs.map(m => ({ ...m, content: '[compressed] ' + m.content })) + });`, + { capabilities: ['transforms'] }, + ) + + const host = makeHost(configDirectory) + await host.start() + + expect(host.registry.getMessageTransforms()).toHaveLength(1) + expect(host.getPlugins()[0]?.contributions.messageTransforms).toBe(1) + expect(listPluginMessageTransforms()).toHaveLength(1) + + // Disable plugin + await host.disable('transform-plugin') + expect(host.registry.getMessageTransforms()).toHaveLength(0) + expect(listPluginMessageTransforms()).toHaveLength(0) + + // Re-enable plugin + await host.enable('transform-plugin') + expect(host.registry.getMessageTransforms()).toHaveLength(1) + expect(listPluginMessageTransforms()).toHaveLength(1) + }) + + it('keeps the original plugin owner when a UI component is re-registered at runtime', async () => { + await writePlugin( + configDirectory, + 'runtime-plugin', + 2, + ` + globalThis.__runtimeRegistry = registry; + registry.registerUiComponent({ id: 'runtime-comp', zone: 'header.actions', component: { type: 'stack', direction: 'row', children: [] } }); + `, + ) + + const host = makeHost(configDirectory) + await host.start() + + const globals = globalThis as unknown as Record + const runtimeRegistry = globals['__runtimeRegistry'] as { registerUiComponent: (component: unknown) => void } + delete globals['__runtimeRegistry'] + + runtimeRegistry.registerUiComponent({ + id: 'runtime-comp', + zone: 'header.actions', + component: { + type: 'button', + label: { en: 'Open', fr: 'Ouvrir' }, + onActivate: { kind: 'openPanel', panelId: 'runtime-panel' }, + }, + }) + + const component = host.getUiContributions().components.find((c) => c.id === 'runtime-comp') + expect(component?.pluginId).toBe('runtime-plugin') + expect(component?.component).toMatchObject({ type: 'button' }) + }) + + it('rejects duplicate message transform IDs across plugins', async () => { + await writePlugin( + configDirectory, + 'plugin-t1', + 2, + `registry.registerMessageTransform({ + id: 'shared_transform', + transform: (msgs) => msgs + });`, + ) + await writePlugin( + configDirectory, + 'plugin-t2', + 2, + `registry.registerMessageTransform({ + id: 'shared_transform', + transform: (msgs) => msgs + });`, + ) + + const host = makeHost(configDirectory) + const diagnostics = await host.start() + + const t2 = diagnostics.find((d) => d.packageName === 'plugin-t2')! + expect(t2.loaded).toBe(false) + expect(t2.error).toContain("Plugin messageTransform 'shared_transform' is already registered by 'plugin-t1'") + expect(host.registry.getMessageTransforms()).toHaveLength(1) + }) }) diff --git a/src/server/plugins/host.ts b/src/server/plugins/host.ts index b5da337b5..c113bc6d8 100644 --- a/src/server/plugins/host.ts +++ b/src/server/plugins/host.ts @@ -1,5 +1,7 @@ -import { readdir, rm } from 'node:fs/promises' +import { readdir, rm, stat } from 'node:fs/promises' import { join, resolve, sep } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' import type { EventStore } from '../events/store.js' import type { StoredEvent } from '../events/types.js' import type { Tool, ToolContext } from '../tools/types.js' @@ -33,11 +35,26 @@ import { PluginRegistry } from './registry.js' import { HookBus, type HookLogger } from './hooks.js' import { NotificationService } from './notifications.js' import { loadPluginFromDirectory, loadPlugins, readPluginManifest, type PluginDiagnostic } from './loader.js' -import { installPluginFromGithub, installPluginFromNpm, installPluginFromPath, removeNpmArtifacts } from './install.js' -import { readPluginSettings, readPluginSettingsView, writePluginSettings } from './settings.js' +import { + buildIfNeeded, + installPluginFromGithub, + installPluginFromNpm, + installPluginFromPath, + removeNpmArtifacts, +} from './install.js' +import { + pluginStorageKey as storageKey, + readPluginSettings, + readPluginSettingsView, + writePluginSettings, +} from './settings.js' import { setPluginModelMetadataProviders } from './model-metadata.js' +import { setPluginMessageTransforms } from './message-transforms.js' +import { setPluginDangerLevels } from './danger-levels.js' import { setPluginHookEmitter } from './hook-emitter.js' +import { PluginUpdateChecker, type PluginUpdateInfo } from './update-checker.js' +const execFileP = promisify(execFile) const DISABLED_KEY = 'plugin.disabled' export interface PluginHostOptions { @@ -60,6 +77,7 @@ export class PluginHost { readonly registry: PluginRegistry readonly hooks: HookBus readonly notifications: NotificationService + readonly updateChecker: PluginUpdateChecker private readonly records = new Map() private readonly pendingDeactivates = new Map void | Promise>() private readonly logger: HookLogger @@ -72,6 +90,11 @@ export class PluginHost { options.registry ?? new PluginRegistry({ mode: options.mode, configDirectory: options.configDirectory }) this.hooks = new HookBus(this.registry, options.logger) this.notifications = new NotificationService() + this.updateChecker = new PluginUpdateChecker({ + configDirectory: options.configDirectory, + notifications: this.notifications, + logger: options.logger, + }) } async start(): Promise { @@ -100,9 +123,18 @@ export class PluginHost { setPluginHookEmitter((event, payload) => { void this.hooks.emit(event, payload) }) + this.updateChecker.start() return diagnostics } + stop(): void { + this.updateChecker.stop() + } + + async checkUpdates(): Promise { + return this.updateChecker.checkUpdates() + } + getDiagnostics(): PluginDiagnostic[] { return [...this.records.values()].map((record) => record.diagnostic) } @@ -112,6 +144,7 @@ export class PluginHost { id: record.diagnostic.packageName, displayName: record.diagnostic.displayName, ...(record.diagnostic.description ? { description: record.diagnostic.description } : {}), + ...(record.diagnostic.author ? { author: record.diagnostic.author } : {}), ...(record.diagnostic.icon ? { icon: record.diagnostic.icon } : {}), ...(record.diagnostic.logo ? { logo: record.diagnostic.logo } : {}), version: record.diagnostic.version ?? '0.0.0', @@ -263,6 +296,35 @@ export class PluginHost { return this.installFromDirectory(dir) } + async reinstall(pluginId: string): Promise { + const record = this.records.get(pluginId) + if (!record) throw new Error(`Plugin not found: ${pluginId}`) + const source = record.diagnostic.source + + const isGit = await stat(join(source, '.git')).catch(() => null) + if (isGit?.isDirectory()) { + try { + const { stdout: remoteUrl } = await execFileP('git', ['-C', source, 'config', '--get', 'remote.origin.url'], { + timeout: 5000, + }) + const url = remoteUrl.trim() + if (url) { + return await this.installFromGithub(url) + } + } catch { + // Fall back to local rebuild + } + } + + const nodeModules = join(this.pluginsDir(), 'node_modules') + if (resolve(source, '..') === resolve(nodeModules) || resolve(source, '../..') === resolve(nodeModules)) { + return await this.installFromNpm(pluginId) + } + + await buildIfNeeded(source) + return await this.installFromDirectory(source) + } + private async installFromDirectory(dir: string): Promise { const manifest = await readPluginManifest(dir) if (!manifest) throw new Error('Installed package is not an OpenFox plugin (missing openfox manifest)') @@ -322,6 +384,8 @@ export class PluginHost { this.registry.getOwnedCommands().map((entry) => toCommandDefinition(entry.command, entry.pluginId)), ) setPluginModelMetadataProviders(this.registry.getModelMetadataProviders()) + setPluginMessageTransforms(this.registry.getMessageTransforms()) + setPluginDangerLevels(this.registry.getDangerLevels()) void this.refreshSkillSources() } @@ -459,10 +523,6 @@ function joinPluginsDir(configDirectory: string): string { return `${configDirectory}/plugins` } -function storageKey(pluginId: string, key: string): string { - return `plugin.${pluginId}.storage.${key}` -} - function readStorageValue(pluginId: string, key: string): PluginSettingValue | undefined { const raw = getAllSettings()[storageKey(pluginId, key)] if (raw === undefined) return undefined diff --git a/src/server/plugins/loader.ts b/src/server/plugins/loader.ts index f37de7322..070a5961f 100644 --- a/src/server/plugins/loader.ts +++ b/src/server/plugins/loader.ts @@ -15,6 +15,7 @@ export interface PluginDiagnostic { apiVersion: number displayName: string description?: string + author?: string icon?: string logo?: string capabilities: PluginCapability[] @@ -45,21 +46,69 @@ export function resolvePluginEntry(manifest: PluginManifest): string | undefined return manifest.openfox.entry ?? manifest.openfox.plugin } +function parseAuthorString(rawPkg: Record): string | undefined { + const author = rawPkg['author'] + if (typeof author === 'string') { + const cleaned = author + .replace(/<[^>]*>/g, '') + .replace(/\([^)]*\)/g, '') + .trim() + if (cleaned) return cleaned + } + if ( + typeof author === 'object' && + author !== null && + 'name' in author && + typeof (author as { name?: unknown }).name === 'string' + ) { + const name = ((author as { name: string }).name || '').trim() + if (name) return name + } + + const repo = rawPkg['repository'] + const repoUrl = + typeof repo === 'string' + ? repo + : typeof repo === 'object' && + repo !== null && + 'url' in repo && + typeof (repo as { url?: unknown }).url === 'string' + ? (repo as { url: string }).url + : undefined + if (repoUrl) { + const m = repoUrl.match(/github\.com[/:]([^/]+)/) + if (m?.[1]) return m[1] + } + + const homepage = typeof rawPkg['homepage'] === 'string' ? rawPkg['homepage'] : undefined + if (homepage) { + const m = homepage.match(/github\.com[/:]([^/]+)/) + if (m?.[1]) return m[1] + } + + return undefined +} + export async function readPluginManifest(packageDir: string): Promise { try { const raw = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) as unknown const parsed = pluginManifestSchema.safeParse(raw) if (!parsed.success) return undefined const data = parsed.data + const author = + data.openfox.author ?? + (raw && typeof raw === 'object' ? parseAuthorString(raw as Record) : undefined) return { name: data.name, version: data.version, + ...(author ? { author } : {}), openfox: { apiVersion: data.openfox.apiVersion, ...(data.openfox.entry ? { entry: data.openfox.entry } : {}), ...(data.openfox.plugin ? { plugin: data.openfox.plugin } : {}), ...(data.openfox.displayName ? { displayName: data.openfox.displayName } : {}), ...(data.openfox.description ? { description: data.openfox.description } : {}), + ...(author ? { author } : {}), ...(data.openfox.icon ? { icon: data.openfox.icon } : {}), ...(data.openfox.logo ? { logo: data.openfox.logo } : {}), ...(data.openfox.capabilities ? { capabilities: data.openfox.capabilities as PluginCapability[] } : {}), @@ -80,6 +129,7 @@ function baseDiagnostic(manifest: PluginManifest, source: string): PluginDiagnos apiVersion: manifest.openfox.apiVersion, displayName: manifest.openfox.displayName ?? manifest.name, ...(manifest.openfox.description ? { description: manifest.openfox.description } : {}), + ...(manifest.author || manifest.openfox.author ? { author: manifest.author ?? manifest.openfox.author } : {}), ...(manifest.openfox.icon ? { icon: manifest.openfox.icon } : {}), ...(manifest.openfox.logo ? { logo: manifest.openfox.logo } : {}), capabilities: manifest.openfox.capabilities ?? [], diff --git a/src/server/plugins/message-transforms.test.ts b/src/server/plugins/message-transforms.test.ts new file mode 100644 index 000000000..88a79e078 --- /dev/null +++ b/src/server/plugins/message-transforms.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + setPluginMessageTransforms, + clearPluginMessageTransforms, + applyPluginMessageTransforms, +} from './message-transforms.js' +import type { ContextMessage } from '../events/folding.js' + +describe('message-transforms', () => { + beforeEach(() => { + clearPluginMessageTransforms() + vi.clearAllMocks() + }) + + it('returns original messages when no transforms are registered', async () => { + const messages: ContextMessage[] = [{ role: 'user', content: 'hello' }] + const result = await applyPluginMessageTransforms(messages, { + sessionId: 's1', + workdir: '/tmp', + model: 'gpt-4o', + systemPrompt: 'sys prompt', + }) + + expect(result.messages).toEqual(messages) + expect(result.systemPrompt).toBe('sys prompt') + expect(result.metadata).toEqual({}) + }) + + it('executes transforms in ascending priority order', async () => { + const executionOrder: string[] = [] + + setPluginMessageTransforms([ + { + pluginId: 'plugin-b', + transform: { + id: 'transform-b', + priority: 200, + transform: (msgs) => { + executionOrder.push('b') + return msgs.map((m) => ({ ...m, content: `${m.content} -> b` })) + }, + }, + }, + { + pluginId: 'plugin-a', + transform: { + id: 'transform-a', + priority: 50, + transform: (msgs) => { + executionOrder.push('a') + return msgs.map((m) => ({ ...m, content: `${m.content} -> a` })) + }, + }, + }, + { + pluginId: 'plugin-c', + transform: { + id: 'transform-c', + priority: 100, + transform: (msgs) => { + executionOrder.push('c') + return msgs.map((m) => ({ ...m, content: `${m.content} -> c` })) + }, + }, + }, + ]) + + const messages: ContextMessage[] = [{ role: 'user', content: 'init' }] + const result = await applyPluginMessageTransforms(messages, { + sessionId: 's1', + workdir: '/tmp', + model: 'gpt-4o', + systemPrompt: 'sys prompt', + }) + + expect(executionOrder).toEqual(['a', 'c', 'b']) + expect(result.messages[0]?.content).toBe('init -> a -> c -> b') + }) + + it('supports modifying systemPrompt and accumulating metadata', async () => { + setPluginMessageTransforms([ + { + pluginId: 'compressor-plugin', + transform: { + id: 'compressor', + transform: () => ({ + messages: [{ role: 'user', content: 'compressed user' }], + systemPrompt: 'compressed system prompt', + metadata: { tokensSaved: 120, algorithm: 'smart_crush' }, + }), + }, + }, + { + pluginId: 'analytics-plugin', + transform: { + id: 'analytics', + priority: 200, + transform: (_msgs, context) => { + expect(context.systemPrompt).toBe('compressed system prompt') + return { + messages: _msgs, + metadata: { inspected: true }, + } + }, + }, + }, + ]) + + const result = await applyPluginMessageTransforms([{ role: 'user', content: 'raw prompt' }], { + sessionId: 's1', + workdir: '/tmp', + model: 'gpt-4o', + systemPrompt: 'original system prompt', + }) + + expect(result.messages[0]?.content).toBe('compressed user') + expect(result.systemPrompt).toBe('compressed system prompt') + expect(result.metadata).toEqual({ + tokensSaved: 120, + algorithm: 'smart_crush', + inspected: true, + }) + }) + + it('fails open when a transform throws an error', async () => { + setPluginMessageTransforms([ + { + pluginId: 'buggy-plugin', + transform: { + id: 'buggy', + transform: () => { + throw new Error('Connection refused to compression service') + }, + }, + }, + { + pluginId: 'good-plugin', + transform: { + id: 'good', + priority: 200, + transform: (msgs) => msgs.map((m) => ({ ...m, content: `${m.content} [processed]` })), + }, + }, + ]) + + const messages: ContextMessage[] = [{ role: 'user', content: 'original' }] + const result = await applyPluginMessageTransforms(messages, { + sessionId: 's1', + workdir: '/tmp', + model: 'gpt-4o', + systemPrompt: 'sys', + }) + + // The buggy transform is skipped fail-open, the good transform still runs + expect(result.messages[0]?.content).toBe('original [processed]') + }) + + it('fails open when a transform times out', async () => { + setPluginMessageTransforms([ + { + pluginId: 'hanging-plugin', + transform: { + id: 'hanging', + transform: async () => { + await new Promise((resolve) => setTimeout(resolve, 500)) + return [{ role: 'user', content: 'should never happen' }] + }, + }, + }, + ]) + + const messages: ContextMessage[] = [{ role: 'user', content: 'original' }] + const result = await applyPluginMessageTransforms( + messages, + { + sessionId: 's1', + workdir: '/tmp', + model: 'gpt-4o', + systemPrompt: 'sys', + }, + { timeoutMs: 50 }, + ) + + expect(result.messages[0]?.content).toBe('original') + }) + + it('stops transform pipeline immediately when AbortSignal is aborted', async () => { + const controller = new AbortController() + controller.abort() + + const transformFn = vi.fn() + setPluginMessageTransforms([ + { + pluginId: 'p1', + transform: { id: 't1', transform: transformFn }, + }, + ]) + + const messages: ContextMessage[] = [{ role: 'user', content: 'hello' }] + const result = await applyPluginMessageTransforms(messages, { + sessionId: 's1', + workdir: '/tmp', + model: 'gpt-4o', + systemPrompt: 'sys', + signal: controller.signal, + }) + + expect(transformFn).not.toHaveBeenCalled() + expect(result.messages).toEqual(messages) + }) +}) diff --git a/src/server/plugins/message-transforms.ts b/src/server/plugins/message-transforms.ts new file mode 100644 index 000000000..e882f8838 --- /dev/null +++ b/src/server/plugins/message-transforms.ts @@ -0,0 +1,113 @@ +import type { ContextMessage } from '../events/folding.js' +import type { + PluginMessageTransform, + PluginMessageTransformContext, + PluginMessageTransformResult, +} from '../../plugin/index.js' +import { logger } from '../utils/logger.js' + +export interface OwnedPluginMessageTransform { + pluginId: string + transform: PluginMessageTransform +} + +let transforms: OwnedPluginMessageTransform[] = [] + +export function setPluginMessageTransforms(next: OwnedPluginMessageTransform[]): void { + transforms = [...next].sort((a, b) => (a.transform.priority ?? 100) - (b.transform.priority ?? 100)) +} + +export function listPluginMessageTransforms(): OwnedPluginMessageTransform[] { + return [...transforms] +} + +export function clearPluginMessageTransforms(): void { + transforms = [] +} + +export interface ApplyMessageTransformsOptions { + timeoutMs?: number +} + +const DEFAULT_TRANSFORM_TIMEOUT_MS = 5000 + +export async function applyPluginMessageTransforms( + initialMessages: ContextMessage[], + context: PluginMessageTransformContext, + options?: ApplyMessageTransformsOptions, +): Promise<{ + messages: ContextMessage[] + systemPrompt: string + metadata: Record +}> { + let currentMessages = initialMessages + let currentSystemPrompt = context.systemPrompt + const accumulatedMetadata: Record = {} + + if (transforms.length === 0) { + return { + messages: currentMessages, + systemPrompt: currentSystemPrompt, + metadata: accumulatedMetadata, + } + } + + const defaultTimeout = options?.timeoutMs ?? DEFAULT_TRANSFORM_TIMEOUT_MS + + for (const { pluginId, transform } of transforms) { + if (context.signal?.aborted) { + break + } + + try { + const transformContext: PluginMessageTransformContext = { + ...context, + systemPrompt: currentSystemPrompt, + } + + let timer: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`Transform '${transform.id}' from plugin '${pluginId}' timed out after ${defaultTimeout}ms`)) + }, defaultTimeout) + timer.unref?.() + }) + + const execPromise = Promise.resolve( + transform.transform(currentMessages as unknown as import('../llm/types.js').LLMMessage[], transformContext), + ) + + let rawResult: PluginMessageTransformResult | import('../llm/types.js').LLMMessage[] + try { + rawResult = await Promise.race([execPromise, timeoutPromise]) + } finally { + if (timer) clearTimeout(timer) + } + + if (Array.isArray(rawResult)) { + currentMessages = rawResult as unknown as ContextMessage[] + } else if (rawResult && typeof rawResult === 'object') { + const res = rawResult as PluginMessageTransformResult + if (Array.isArray(res.messages)) { + currentMessages = res.messages as unknown as ContextMessage[] + } + if (typeof res.systemPrompt === 'string') { + currentSystemPrompt = res.systemPrompt + } + if (res.metadata && typeof res.metadata === 'object') { + Object.assign(accumulatedMetadata, res.metadata) + } + } + } catch (error) { + logger.warn(`Message transform '${transform.id}' from plugin '${pluginId}' failed (fail-open fallback applied)`, { + error: error instanceof Error ? error.message : String(error), + }) + } + } + + return { + messages: currentMessages, + systemPrompt: currentSystemPrompt, + metadata: accumulatedMetadata, + } +} diff --git a/src/server/plugins/registry.ts b/src/server/plugins/registry.ts index 26b802e8c..9939e495c 100644 --- a/src/server/plugins/registry.ts +++ b/src/server/plugins/registry.ts @@ -9,8 +9,10 @@ import type { import type { PluginCommand, PluginContext, + PluginDangerLevel, PluginHookEvent, PluginHookHandler, + PluginMessageTransform, PluginModelMetadataProvider, PluginNotificationRequest, PluginRegistry as PluginRegistryContract, @@ -53,6 +55,8 @@ type Kind = | 'uiComponent' | 'uiOverride' | 'asset' + | 'messageTransform' + | 'dangerLevel' interface Owned { pluginId: string @@ -176,7 +180,8 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon } registerRpc(method: string, handler: PluginRpcHandler): void { - this.register('rpc', method, handler) + const pluginId = this.currentPluginId ?? UNKNOWN_PLUGIN + this.register('rpc', `${pluginId}:${method}`, handler) } registerAsset(relativePath: string): void { @@ -186,6 +191,14 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon this.assets.set(pluginId, set) } + registerMessageTransform(transform: PluginMessageTransform): void { + this.register('messageTransform', transform.id, transform) + } + + registerDangerLevel(dangerLevel: PluginDangerLevel): void { + this.register('dangerLevel', dangerLevel.id, dangerLevel) + } + notify(request: PluginNotificationRequest): void { this.context.notify(request) } @@ -231,6 +244,20 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon return this.list('skillSource') } + getMessageTransforms(): { pluginId: string; transform: PluginMessageTransform }[] { + return this.listOwned('messageTransform').map((entry) => ({ + pluginId: entry.pluginId, + transform: entry.value, + })) + } + + getDangerLevels(): { pluginId: string; dangerLevel: PluginDangerLevel }[] { + return this.listOwned('dangerLevel').map((entry) => ({ + pluginId: entry.pluginId, + dangerLevel: entry.value, + })) + } + getSettingsSchema(pluginId: string): PluginSettingsSchema | undefined { return this.get('settings', pluginId) } @@ -256,6 +283,13 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon ...entry.value, pluginId: entry.pluginId, })), + dangerLevels: this.listOwned('dangerLevel').map((entry) => ({ + id: entry.value.id, + pluginId: entry.pluginId, + label: entry.value.label, + ...(entry.value.description ? { description: entry.value.description } : {}), + ...(entry.value.badgeTone ? { badgeTone: entry.value.badgeTone } : {}), + })), } } @@ -264,8 +298,8 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon } getRpcHandler(pluginId: string, method: string): PluginRpcHandler | undefined { - const entry = this.entries.get('rpc')?.get(method) - return entry && entry.pluginId === pluginId ? (entry.value as PluginRpcHandler) : undefined + const entry = this.entries.get('rpc')?.get(`${pluginId}:${method}`) + return entry ? (entry.value as PluginRpcHandler) : undefined } getAssets(pluginId: string): string[] { @@ -280,7 +314,13 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon const result: { kind: string; id: string }[] = [] for (const [kind, map] of this.entries) { for (const [id, entry] of map) { - if (entry.pluginId === pluginId) result.push({ kind, id }) + if (entry.pluginId === pluginId) { + const rawId = + (kind === 'rpc' || kind === 'transition') && id.startsWith(`${pluginId}:`) + ? id.slice(pluginId.length + 1) + : id + result.push({ kind, id: rawId }) + } } } return result @@ -307,6 +347,8 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon settingsTabs: count('settingsTab'), uiComponents: count('uiComponent'), uiOverrides: count('uiOverride'), + messageTransforms: count('messageTransform'), + dangerLevels: count('dangerLevel'), } } @@ -373,11 +415,11 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon private register(kind: Kind, id: string, value: T): boolean { if (!id.trim()) throw new Error(`Plugin ${kind} id cannot be empty`) - const pluginId = this.currentPluginId ?? UNKNOWN_PLUGIN const map = this.entries.get(kind) ?? new Map>() this.entries.set(kind, map) const existing = map.get(id) + const pluginId = this.currentPluginId ?? existing?.pluginId ?? UNKNOWN_PLUGIN if (existing && this.currentPluginId !== undefined && existing.pluginId !== pluginId) { this.conflicts.push(`Plugin ${kind} '${id}' is already registered by '${existing.pluginId}'`) return false diff --git a/src/server/plugins/settings.test.ts b/src/server/plugins/settings.test.ts new file mode 100644 index 000000000..f455bfa9a --- /dev/null +++ b/src/server/plugins/settings.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { closeDatabase, initDatabase } from '../db/index.js' +import { loadConfig } from '../config.js' +import { getAllSettings, setSetting } from '../db/settings.js' +import { + MASKED_SECRET, + pluginSettingKey, + pluginStorageKey, + readPluginSettings, + readPluginSettingsView, + validatePluginSettings, + writePluginSettings, +} from './settings.js' +import type { PluginSettingsSchema } from '../../shared/plugin.js' + +const schema: PluginSettingsSchema = { + fields: [ + { + key: 'registries', + type: 'list', + label: { en: 'Registries', fr: 'Registres' }, + default: '[]', + itemFields: [ + { + key: 'source', + type: 'select', + label: { en: 'Source', fr: 'Source' }, + options: [ + { value: 'github', label: { en: 'GitHub', fr: 'GitHub' } }, + { value: 'gitlab', label: { en: 'GitLab', fr: 'GitLab' } }, + ], + }, + { key: 'url', type: 'text', label: { en: 'Registry URL', fr: 'URL du registre' } }, + { key: 'token', type: 'password', secret: true, label: { en: 'Token', fr: 'Jeton' } }, + ], + }, + ], +} + +function storeList(pluginId: string, items: Array>): void { + setSetting(pluginSettingKey(pluginId, 'global', undefined, 'registries'), JSON.stringify(JSON.stringify(items))) +} + +describe('plugin settings list fields', () => { + beforeEach(() => { + closeDatabase() + const config = loadConfig() + config.database.path = ':memory:' + initDatabase(config) + }) + + afterEach(() => { + closeDatabase() + }) + + it('falls back to the default when the stored list is not a JSON array', () => { + const broken: PluginSettingsSchema = { + fields: [{ ...schema.fields[0]!, default: '[]' }], + } + setSetting(pluginSettingKey('demo', 'global', undefined, 'registries'), JSON.stringify('{not-json')) + + expect(readPluginSettings('demo', broken)['registries']).toBe('[]') + + setSetting(pluginSettingKey('demo', 'global', undefined, 'registries'), JSON.stringify('{"a":1}')) + expect(readPluginSettings('demo', broken)['registries']).toBe('[]') + }) + + it('reads a list as a JSON array string, unmasked at runtime and masked in the settings view', () => { + storeList('demo', [{ source: 'github', url: 'https://github.test/index.json', token: 'ghp_secret' }]) + + expect(readPluginSettings('demo', schema)['registries']).toBe( + JSON.stringify([{ source: 'github', url: 'https://github.test/index.json', token: 'ghp_secret' }]), + ) + + const view = readPluginSettingsView('demo', schema) + expect(view.secretsSet).toEqual([]) + expect(JSON.parse(String(view.values['registries']))).toEqual([ + { source: 'github', url: 'https://github.test/index.json', token: MASKED_SECRET }, + ]) + }) + + it('keeps the stored token of a row when the submitted one is masked or empty', () => { + storeList('demo', [ + { source: 'github', url: 'https://github.test/index.json', token: 'ghp_secret' }, + { source: 'gitlab', url: 'https://gitlab.test/index.json', token: 'glpat_secret' }, + ]) + + const incoming = [ + { source: 'github', url: 'https://github.test/index.json', token: MASKED_SECRET }, + { source: 'gitlab', url: 'https://gitlab.test/other.json', token: '' }, + ] + const result = writePluginSettings('demo', schema, { registries: JSON.stringify(incoming) }) + expect(result.errors).toEqual([]) + + expect(JSON.parse(String(readPluginSettings('demo', schema)['registries']))).toEqual([ + { source: 'github', url: 'https://github.test/index.json', token: 'ghp_secret' }, + { source: 'gitlab', url: 'https://gitlab.test/other.json', token: 'glpat_secret' }, + ]) + }) + + it('stores a newly typed token and drops the one of a removed row', () => { + storeList('demo', [ + { source: 'github', url: 'https://github.test/index.json', token: 'ghp_secret' }, + { source: 'gitlab', url: 'https://gitlab.test/index.json', token: 'glpat_secret' }, + ]) + + writePluginSettings('demo', schema, { + registries: JSON.stringify([{ source: 'gitlab', url: 'https://gitlab.test/index.json', token: 'glpat_new' }]), + }) + + expect(JSON.parse(String(readPluginSettings('demo', schema)['registries']))).toEqual([ + { source: 'gitlab', url: 'https://gitlab.test/index.json', token: 'glpat_new' }, + ]) + }) + + it('validates item shape, select options, item types and row bounds', () => { + const bounded: PluginSettingsSchema = { + fields: [{ ...schema.fields[0]!, minItems: 1, maxItems: 2 }], + } + + expect(validatePluginSettings(schema, { registries: 'not-json' })).toEqual([ + "Setting 'registries' must be a JSON array string", + ]) + expect(validatePluginSettings(schema, { registries: '{"a":1}' })).toEqual([ + "Setting 'registries' must be a JSON array string", + ]) + expect(validatePluginSettings(schema, { registries: '[1]' })).toEqual(["Setting 'registries[0]' must be an object"]) + expect(validatePluginSettings(schema, { registries: '[{"source":"bitbucket","url":"u"}]' })).toEqual([ + "Setting 'registries[0].source' must be one of the declared options", + ]) + expect(validatePluginSettings(schema, { registries: '[{"source":"github","url":42}]' })).toEqual([ + "Setting 'registries[0].url' must be a string", + ]) + expect(validatePluginSettings(bounded, { registries: '[]' })).toEqual([ + "Setting 'registries' needs at least 1 item", + ]) + expect( + validatePluginSettings(bounded, { + registries: '[{"source":"github","url":"a"},{"source":"github","url":"b"},{"source":"github","url":"c"}]', + }), + ).toEqual(["Setting 'registries' accepts at most 2 items"]) + expect(validatePluginSettings(schema, { registries: '[{"source":"github","url":"a","token":"x"}]' })).toEqual([]) + }) +}) + +const storageSchema: PluginSettingsSchema = { + fields: [ + { key: 'endpoint', type: 'text', label: { en: 'Endpoint', fr: 'Endpoint' }, default: 'https://api.test' }, + { + key: 'token', + type: 'password', + secret: true, + storageKey: 'legacy_token', + label: { en: 'Token', fr: 'Jeton' }, + }, + ], +} + +describe('plugin settings backed by the plugin storage', () => { + beforeEach(() => { + closeDatabase() + const config = loadConfig() + config.database.path = ':memory:' + initDatabase(config) + }) + + afterEach(() => { + closeDatabase() + }) + + it('reports a token kept in the plugin storage as set, without ever returning it', () => { + setSetting(pluginStorageKey('demo', 'legacy_token'), JSON.stringify('ghp_legacy')) + + const view = readPluginSettingsView('demo', storageSchema) + expect(view.secretsSet).toEqual(['token']) + expect(view.values['token']).toBeUndefined() + expect(JSON.stringify(view)).not.toContain('ghp_legacy') + }) + + it('reads the stored value at runtime, with the same "set" rule as a settings-backed secret', () => { + expect(readPluginSettings('demo', storageSchema)['token']).toBeUndefined() + expect(readPluginSettingsView('demo', storageSchema).secretsSet).toEqual([]) + + setSetting(pluginStorageKey('demo', 'legacy_token'), JSON.stringify('ghp_legacy')) + expect(readPluginSettings('demo', storageSchema)['token']).toBe('ghp_legacy') + + // The row still exists, so it counts as set — exactly like the settings store. + setSetting(pluginStorageKey('demo', 'legacy_token'), JSON.stringify('')) + expect(readPluginSettings('demo', storageSchema)['token']).toBe('') + expect(readPluginSettingsView('demo', storageSchema).secretsSet).toEqual(['token']) + }) + + it('writes a submitted value to the plugin storage row, not to the settings row', () => { + const result = writePluginSettings('demo', storageSchema, { token: 'ghp_new' }) + expect(result.errors).toEqual([]) + + expect(readPluginSettings('demo', storageSchema)['token']).toBe('ghp_new') + expect(getAllSettings()[pluginSettingKey('demo', 'global', undefined, 'token')]).toBeUndefined() + expect(readPluginSettingsView('demo', storageSchema).secretsSet).toEqual(['token']) + }) + + it('keeps the stored token when the submitted one is masked or empty', () => { + setSetting(pluginStorageKey('demo', 'legacy_token'), JSON.stringify('ghp_legacy')) + + writePluginSettings('demo', storageSchema, { token: MASKED_SECRET }) + expect(readPluginSettings('demo', storageSchema)['token']).toBe('ghp_legacy') + + writePluginSettings('demo', storageSchema, { token: '' }) + expect(readPluginSettings('demo', storageSchema)['token']).toBe('ghp_legacy') + }) +}) diff --git a/src/server/plugins/settings.ts b/src/server/plugins/settings.ts index d9efde4a6..32f7c5cc9 100644 --- a/src/server/plugins/settings.ts +++ b/src/server/plugins/settings.ts @@ -28,18 +28,107 @@ export function pluginSettingKey( return `plugin.${pluginId}.${scopeSegment}.${key}` } +/** Key of a value a plugin keeps in its own storage (`context.storage`). */ +export function pluginStorageKey(pluginId: string, key: string): string { + return `plugin.${pluginId}.storage.${key}` +} + +/** Row a field is backed by: the plugin's storage when it declares `storageKey`. */ +function fieldStorageRow( + stored: Record, + pluginId: string, + field: PluginSettingsField, + scope: PluginSettingScope, + projectId: string | undefined, +): string | undefined { + return field.storageKey + ? stored[pluginStorageKey(pluginId, field.storageKey)] + : stored[pluginSettingKey(pluginId, field.scope ?? scope, projectId, field.key)] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isSecret(field: PluginSettingsField): boolean { + return field.secret === true || field.type === 'password' +} + +/** + * A `list` value is stored (and travels) as a JSON array string, so the stored + * blob is the JSON encoding of that string, exactly like every other setting. + */ +function parseJsonArray(raw: unknown): unknown[] | undefined { + if (typeof raw !== 'string') return undefined + try { + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) ? parsed : undefined + } catch { + return undefined + } +} + +function parseListValue(raw: unknown): Record[] | undefined { + const parsed = parseJsonArray(raw) + return parsed ? parsed.filter(isRecord) : undefined +} + function coerce(field: PluginSettingsField, raw: string | undefined): PluginSettingValue | undefined { if (raw === undefined) return field.default try { const parsed = JSON.parse(raw) as unknown if (field.type === 'boolean') return typeof parsed === 'boolean' ? parsed : field.default if (field.type === 'number') return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : field.default + if (field.type === 'list') { + if (Array.isArray(parsed)) return JSON.stringify(parsed) + if (typeof parsed === 'string') return parseJsonArray(parsed) ? parsed : field.default + return field.default + } return typeof parsed === 'string' ? parsed : field.default } catch { return field.default } } +/** Replaces every configured secret sub-value of a list with the mask. */ +function maskListSecrets(field: PluginSettingsField, raw: string): string { + const items = parseListValue(raw) + if (!items) return raw + const masked = items.map((item) => { + const next: Record = { ...item } + for (const sub of field.itemFields ?? []) { + if (!isSecret(sub)) continue + const value = next[sub.key] + if (typeof value === 'string' && value !== '') next[sub.key] = MASKED_SECRET + } + return next + }) + return JSON.stringify(masked) +} + +/** + * Secrets nested in list items are matched by row index: a masked or empty + * submitted value keeps whatever was stored for that row. + */ +function mergeListSecrets(field: PluginSettingsField, incoming: string, stored: string | undefined): string { + const incomingItems = parseListValue(incoming) + if (!incomingItems) return incoming + const storedItems = stored === undefined ? [] : (parseListValue(stored) ?? []) + const merged = incomingItems.map((item, index) => { + const next: Record = { ...item } + for (const sub of field.itemFields ?? []) { + if (!isSecret(sub)) continue + const value = next[sub.key] + if (typeof value === 'string' && value !== '' && !isMaskedValue(value)) continue + const previous = storedItems[index]?.[sub.key] + if (typeof previous === 'string' && previous !== '') next[sub.key] = previous + else delete next[sub.key] + } + return next + }) + return JSON.stringify(merged) +} + export function readPluginSettings( pluginId: string, schema: PluginSettingsSchema, @@ -49,7 +138,8 @@ export function readPluginSettings( const stored = getAllSettings() const values: PluginSettingsValues = {} for (const field of schema.fields) { - const raw = stored[pluginSettingKey(pluginId, field.scope ?? scope, projectId, field.key)] + if (field.type === 'button' || field.type === 'status') continue + const raw = fieldStorageRow(stored, pluginId, field, scope, projectId) const value = coerce(field, raw) if (value !== undefined) values[field.key] = value } @@ -66,19 +156,64 @@ export function readPluginSettingsView( const values: PluginSettingsValues = {} const secretsSet: string[] = [] for (const field of schema.fields) { - const raw = stored[pluginSettingKey(pluginId, field.scope ?? scope, projectId, field.key)] + if (field.type === 'button' || field.type === 'status' || field.readOnly) continue + const raw = fieldStorageRow(stored, pluginId, field, scope, projectId) if (isSecret(field)) { if (raw !== undefined && raw !== '') secretsSet.push(field.key) continue } const value = coerce(field, raw) - if (value !== undefined) values[field.key] = value + if (value === undefined) continue + values[field.key] = field.type === 'list' && typeof value === 'string' ? maskListSecrets(field, value) : value } return { values, secretsSet } } -export function isSecret(field: PluginSettingsField): boolean { - return field.secret === true || field.type === 'password' +function validateFieldValue(field: PluginSettingsField, value: unknown, path: string, errors: string[]): void { + if (field.type === 'boolean') { + if (typeof value !== 'boolean') errors.push(`Setting '${path}' must be a boolean`) + } else if (field.type === 'number') { + if (typeof value !== 'number' || !Number.isFinite(value)) errors.push(`Setting '${path}' must be a number`) + } else if (field.type === 'select') { + if (typeof value !== 'string' || !(field.options ?? []).some((option) => option.value === value)) { + errors.push(`Setting '${path}' must be one of the declared options`) + } + } else if (field.type === 'list') { + validateListValue(field, value, path, errors) + } else if (typeof value !== 'string') { + errors.push(`Setting '${path}' must be a string`) + } +} + +function validateListValue(field: PluginSettingsField, value: unknown, path: string, errors: string[]): void { + const items = parseJsonArray(value) + if (!items) { + errors.push(`Setting '${path}' must be a JSON array string`) + return + } + if (field.minItems !== undefined && items.length < field.minItems) { + errors.push(`Setting '${path}' needs at least ${field.minItems} item${field.minItems === 1 ? '' : 's'}`) + } + if (field.maxItems !== undefined && items.length > field.maxItems) { + errors.push(`Setting '${path}' accepts at most ${field.maxItems} item${field.maxItems === 1 ? '' : 's'}`) + } + items.forEach((item, index) => { + if (!isRecord(item)) { + errors.push(`Setting '${path}[${index}]' must be an object`) + return + } + for (const sub of field.itemFields ?? []) { + const subPath = `${path}[${index}].${sub.key}` + const subValue = item[sub.key] + if (subValue === undefined || subValue === null || subValue === '') { + if (isSecret(sub)) continue + if (sub.required) errors.push(`Missing required setting '${subPath}'`) + continue + } + if (isSecret(sub) && isMaskedValue(subValue)) continue + validateFieldValue(sub, subValue, subPath, errors) + } + }) } export function validatePluginSettings( @@ -88,6 +223,7 @@ export function validatePluginSettings( ): string[] { const errors: string[] = [] for (const field of schema.fields) { + if (field.type === 'button' || field.type === 'status' || field.readOnly) continue if (!(field.key in values)) { if (field.required) { if (isSecret(field) && isExistingSecret && isExistingSecret(field.key)) { @@ -108,17 +244,7 @@ export function validatePluginSettings( } continue } - if (field.type === 'boolean') { - if (typeof value !== 'boolean') errors.push(`Setting '${field.key}' must be a boolean`) - } else if (field.type === 'number') { - if (typeof value !== 'number' || !Number.isFinite(value)) errors.push(`Setting '${field.key}' must be a number`) - } else if (field.type === 'select') { - if (typeof value !== 'string' || !(field.options ?? []).some((option) => option.value === value)) { - errors.push(`Setting '${field.key}' must be one of the declared options`) - } - } else if (typeof value !== 'string') { - errors.push(`Setting '${field.key}' must be a string`) - } + validateFieldValue(field, value, field.key, errors) } return errors } @@ -133,18 +259,28 @@ export function writePluginSettings( const stored = getAllSettings() const isExistingSecret = (key: string) => { const field = schema.fields.find((f) => f.key === key) - const raw = stored[pluginSettingKey(pluginId, field?.scope ?? scope, projectId, key)] + const raw = field + ? fieldStorageRow(stored, pluginId, field, scope, projectId) + : stored[pluginSettingKey(pluginId, scope, projectId, key)] return raw !== undefined && raw !== '' } const errors = validatePluginSettings(schema, incoming, isExistingSecret) if (errors.length > 0) return { errors } for (const field of schema.fields) { + if (field.readOnly) continue if (!(field.key in incoming)) continue const value = incoming[field.key] if (isSecret(field) && (value === '' || value === null || value === undefined || isMaskedValue(value))) { continue } - setSetting(pluginSettingKey(pluginId, field.scope ?? scope, projectId, field.key), JSON.stringify(value)) + const key = field.storageKey + ? pluginStorageKey(pluginId, field.storageKey) + : pluginSettingKey(pluginId, field.scope ?? scope, projectId, field.key) + const next = + field.type === 'list' && typeof value === 'string' + ? mergeListSecrets(field, value, coerce(field, stored[key]) as string | undefined) + : value + setSetting(key, JSON.stringify(next)) } return { errors: [] } } diff --git a/src/server/plugins/update-checker.test.ts b/src/server/plugins/update-checker.test.ts new file mode 100644 index 000000000..0829f87bd --- /dev/null +++ b/src/server/plugins/update-checker.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { PluginUpdateChecker } from './update-checker.js' +import { NotificationService } from './notifications.js' +import { setSetting } from '../db/settings.js' +import { closeDatabase, initDatabase } from '../db/index.js' +import { loadConfig } from '../config.js' + +describe('PluginUpdateChecker', () => { + let configDirectory: string + let notifications: NotificationService + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + + beforeEach(async () => { + closeDatabase() + const config = loadConfig() + config.database.path = ':memory:' + initDatabase(config) + configDirectory = await mkdtemp(join(tmpdir(), 'openfox-update-checker-')) + await mkdir(join(configDirectory, 'plugins'), { recursive: true }) + notifications = new NotificationService() + }) + + afterEach(async () => { + closeDatabase() + await rm(configDirectory, { recursive: true, force: true }) + }) + + it('maps intervals correctly', () => { + const checker = new PluginUpdateChecker({ configDirectory, notifications, logger }) + expect(checker.getIntervalMs('1h')).toBe(3_600_000) + expect(checker.getIntervalMs('6h')).toBe(21_600_000) + expect(checker.getIntervalMs('24h')).toBe(86_400_000) + expect(checker.getIntervalMs('startup')).toBeNull() + expect(checker.getIntervalMs('unknown')).toBeNull() + }) + + it('reads settings from database', () => { + const checker = new PluginUpdateChecker({ configDirectory, notifications, logger }) + setSetting( + 'notification_settings', + JSON.stringify({ pluginUpdateNotificationEnabled: false, pluginUpdateCheckInterval: '24h' }), + ) + const settings = checker.getSettings() + expect(settings.pluginUpdateNotificationEnabled).toBe(false) + expect(settings.pluginUpdateCheckInterval).toBe('24h') + }) + + it('checks npm plugin updates and emits notification when update is available', async () => { + const pluginDir = join(configDirectory, 'plugins', 'test-plugin') + await mkdir(pluginDir, { recursive: true }) + await writeFile( + join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'test-plugin', + version: '1.0.0', + openfox: { apiVersion: 2, displayName: 'Test Plugin' }, + }), + ) + + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.2.0' }), + }) + + const emitSpy = vi.spyOn(notifications, 'emit') + + const checker = new PluginUpdateChecker({ + configDirectory, + notifications, + logger, + fetcher: fetcher as unknown as typeof fetch, + }) + + const results = await checker.checkUpdates() + expect(results).toHaveLength(1) + expect(results[0]?.updateAvailable).toBe(true) + expect(results[0]?.currentVersion).toBe('1.0.0') + expect(results[0]?.latestVersion).toBe('1.2.0') + expect(emitSpy).toHaveBeenCalledWith( + 'openfox', + expect.objectContaining({ + title: { en: 'Update available: Test Plugin', fr: 'Mise à jour disponible : Test Plugin' }, + }), + ) + + // Verify subsequent check does not emit duplicate notification + emitSpy.mockClear() + await checker.checkUpdates() + expect(emitSpy).not.toHaveBeenCalled() + }) + + it('discovers and checks scoped npm plugins in node_modules/@scope/pkg', async () => { + const scopedDir = join(configDirectory, 'plugins', 'node_modules', '@openfox', 'scoped-plugin') + await mkdir(scopedDir, { recursive: true }) + await writeFile( + join(scopedDir, 'package.json'), + JSON.stringify({ + name: '@openfox/scoped-plugin', + version: '0.9.0', + openfox: { apiVersion: 2, displayName: 'Scoped Plugin' }, + }), + ) + + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + }) + + const emitSpy = vi.spyOn(notifications, 'emit') + + const checker = new PluginUpdateChecker({ + configDirectory, + notifications, + logger, + fetcher: fetcher as unknown as typeof fetch, + }) + + const results = await checker.checkUpdates() + expect(results).toHaveLength(1) + expect(results[0]?.packageName).toBe('@openfox/scoped-plugin') + expect(results[0]?.updateAvailable).toBe(true) + expect(emitSpy).toHaveBeenCalledWith( + 'openfox', + expect.objectContaining({ + title: { en: 'Update available: Scoped Plugin', fr: 'Mise à jour disponible : Scoped Plugin' }, + }), + ) + }) + + it('handles start and stop lifecycle gracefully', () => { + setSetting( + 'notification_settings', + JSON.stringify({ pluginUpdateNotificationEnabled: true, pluginUpdateCheckInterval: '6h' }), + ) + const checker = new PluginUpdateChecker({ configDirectory, notifications, logger }) + checker.start() + checker.stop() + }) +}) diff --git a/src/server/plugins/update-checker.ts b/src/server/plugins/update-checker.ts new file mode 100644 index 000000000..7538c6d91 --- /dev/null +++ b/src/server/plugins/update-checker.ts @@ -0,0 +1,266 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { readdir, readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { NotificationService } from './notifications.js' +import type { HookLogger } from './hooks.js' +import { getSetting } from '../db/settings.js' + +const execFileP = promisify(execFile) + +export interface PluginUpdateCheckerOptions { + configDirectory: string + notifications: NotificationService + logger: HookLogger + fetcher?: typeof fetch +} + +export interface PluginUpdateInfo { + packageName: string + displayName: string + currentVersion?: string + latestVersion?: string + updateAvailable: boolean +} + +export class PluginUpdateChecker { + private timer: NodeJS.Timeout | null = null + private readonly notifiedUpdates = new Set() + private readonly configDirectory: string + private readonly notifications: NotificationService + private readonly logger: HookLogger + private readonly fetcher: typeof fetch + + constructor(options: PluginUpdateCheckerOptions) { + this.configDirectory = options.configDirectory + this.notifications = options.notifications + this.logger = options.logger + this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis) + } + + start(): void { + this.stop() + const settings = this.getSettings() + if (!settings.pluginUpdateNotificationEnabled) return + + void this.checkUpdates() + + const intervalMs = this.getIntervalMs(settings.pluginUpdateCheckInterval) + if (intervalMs) { + this.timer = setInterval(() => { + void this.checkUpdates() + }, intervalMs) + } + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + getIntervalMs(interval: string): number | null { + switch (interval) { + case '1h': + return 3_600_000 + case '6h': + return 21_600_000 + case '24h': + return 86_400_000 + case 'startup': + default: + return null + } + } + + getSettings(): { pluginUpdateNotificationEnabled: boolean; pluginUpdateCheckInterval: string } { + try { + const raw = getSetting('notification_settings') + if (raw) { + const parsed = JSON.parse(raw) as { + pluginUpdateNotificationEnabled?: boolean + pluginUpdateCheckInterval?: string + } + return { + pluginUpdateNotificationEnabled: parsed.pluginUpdateNotificationEnabled !== false, + pluginUpdateCheckInterval: parsed.pluginUpdateCheckInterval ?? '1h', + } + } + } catch { + // Ignore settings read / JSON parse error + } + return { + pluginUpdateNotificationEnabled: true, + pluginUpdateCheckInterval: '1h', + } + } + + async checkUpdates(): Promise { + const pluginsDir = join(this.configDirectory, 'plugins') + const packageDirs = await this.discoverPluginDirectories(pluginsDir) + const results: PluginUpdateInfo[] = [] + + for (const packageDir of packageDirs) { + try { + const manifestRaw = await readFile(join(packageDir, 'package.json'), 'utf8').catch(() => null) + if (!manifestRaw) continue + const manifest = JSON.parse(manifestRaw) as { + name?: string + version?: string + openfox?: { displayName?: string } + } + const packageName = manifest.name + if (!packageName) continue + const displayName = manifest.openfox?.displayName ?? manifest.name ?? packageName + const currentVersion = manifest.version + + const isGit = await stat(join(packageDir, '.git')).catch(() => null) + if (isGit?.isDirectory()) { + const update = await this.checkGitUpdate(packageDir, packageName, displayName, currentVersion) + if (update) { + results.push(update) + if (update.updateAvailable) { + this.notifyUpdate(update) + } + } + } else if (currentVersion) { + const update = await this.checkNpmUpdate(packageName, displayName, currentVersion) + if (update) { + results.push(update) + if (update.updateAvailable) { + this.notifyUpdate(update) + } + } + } + } catch (err) { + this.logger.debug('Failed to check plugin update', { plugin: packageDir, error: String(err) }) + } + } + + return results + } + + private async discoverPluginDirectories(pluginsDir: string): Promise { + const roots = [pluginsDir, join(pluginsDir, 'node_modules')] + const directories: string[] = [] + const seen = new Set() + + for (const root of roots) { + let entries + try { + entries = await readdir(root, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + if (entry.name.startsWith('.') || (root === pluginsDir && entry.name === 'node_modules')) continue + const entryPath = join(root, entry.name) + const isDirectory = + entry.isDirectory() || + (entry.isSymbolicLink() && (await stat(entryPath).catch(() => undefined))?.isDirectory()) + if (!isDirectory) continue + + if (entry.name.startsWith('@')) { + const scoped = await readdir(entryPath, { withFileTypes: true }).catch(() => []) + for (const child of scoped) { + if (child.name.startsWith('.')) continue + const childPath = join(entryPath, child.name) + const childIsDirectory = + child.isDirectory() || + (child.isSymbolicLink() && (await stat(childPath).catch(() => undefined))?.isDirectory()) + if (childIsDirectory && !seen.has(childPath)) { + seen.add(childPath) + directories.push(childPath) + } + } + } else if (!seen.has(entryPath)) { + seen.add(entryPath) + directories.push(entryPath) + } + } + } + + return directories + } + + private async checkGitUpdate( + packageDir: string, + packageName: string, + displayName: string, + currentVersion?: string, + ): Promise { + try { + const { stdout: localHead } = await execFileP('git', ['-C', packageDir, 'rev-parse', 'HEAD'], { timeout: 5000 }) + const cleanLocal = localHead.trim() + + const { stdout: remoteOut } = await execFileP('git', ['-C', packageDir, 'ls-remote', 'origin', 'HEAD'], { + timeout: 10000, + }) + const remoteMatch = remoteOut.match(/^([0-9a-fA-F]{40})\s+/m) + if (!remoteMatch) return null + + const cleanRemote = remoteMatch[1]! + const updateAvailable = cleanLocal !== cleanRemote + + return { + packageName, + displayName, + currentVersion: currentVersion ?? cleanLocal.slice(0, 7), + latestVersion: cleanRemote.slice(0, 7), + updateAvailable, + } + } catch { + return null + } + } + + private async checkNpmUpdate( + packageName: string, + displayName: string, + currentVersion: string, + ): Promise { + try { + const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest` + const res = await this.fetcher(url, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) return null + const data = (await res.json()) as { version?: string } + if (!data.version) return null + const updateAvailable = data.version !== currentVersion + return { + packageName, + displayName, + currentVersion, + latestVersion: data.version, + updateAvailable, + } + } catch { + return null + } + } + + private notifyUpdate(update: PluginUpdateInfo): void { + const notifyKey = `${update.packageName}:${update.latestVersion ?? 'new'}` + if (this.notifiedUpdates.has(notifyKey)) return + this.notifiedUpdates.add(notifyKey) + + this.notifications.emit('openfox', { + title: { + en: `Update available: ${update.displayName}`, + fr: `Mise à jour disponible : ${update.displayName}`, + }, + body: { + en: `A new version of "${update.displayName}" is available.`, + fr: `Une nouvelle version de « ${update.displayName} » est disponible.`, + }, + level: 'info', + actions: [ + { + label: { en: 'Plugins', fr: 'Plugins' }, + onActivate: { kind: 'openPanel', panelId: 'plugins' }, + }, + ], + }) + } +} diff --git a/src/server/provider-manager.test.ts b/src/server/provider-manager.test.ts index 1befd9d6a..d7ce6b067 100644 --- a/src/server/provider-manager.test.ts +++ b/src/server/provider-manager.test.ts @@ -573,6 +573,178 @@ describe('ProviderManager - Model Selection', () => { expect(models[0]!.contextWindow).toBe(900000) }) + it('drops models that the authoritative transport catalog dropped on refresh', async () => { + const transport = { + id: 'example-transport', + listModels: vi.fn(async () => [{ id: 'catalog-a', contextWindow: 1048576, source: 'backend' as const }]), + complete: vi.fn(), + stream: vi.fn(), + } + const adapters = { getTransport: vi.fn((id?: string) => (id === 'example-transport' ? transport : undefined)) } + const chatConfig: Config = { + ...config, + providers: [ + { + id: 'external', + name: 'External Provider', + url: 'https://provider.example/v1', + backend: 'openai', + transportAdapter: 'example-transport', + models: [ + { id: 'picked-model', contextWindow: 200000, source: 'user', selected: true }, + { id: 'stale-model', contextWindow: 200000, source: 'user' }, + ], + isActive: true, + createdAt: new Date().toISOString(), + }, + ], + defaultModelSelection: 'external/picked-model', + } + const manager = createProviderManager(chatConfig, { adapters: adapters as never }) + + await manager.refreshProviderModels('external') + + const models = manager.getProviders()[0]!.models + expect(models.map((model) => model.id)).toEqual(['catalog-a']) + }) + + it('lets the catalog replace a placeholder context window and a profile-derived vision flag', async () => { + const transport = { + id: 'example-transport', + listModels: vi.fn(async () => [ + { id: 'claude-sonnet-4-6', contextWindow: 250000, supportsVision: true, source: 'backend' as const }, + { id: 'gemini-3-flash', contextWindow: 1048576, supportsVision: true, source: 'backend' as const }, + ]), + complete: vi.fn(), + stream: vi.fn(), + } + const adapters = { getTransport: vi.fn((id?: string) => (id === 'example-transport' ? transport : undefined)) } + const chatConfig: Config = { + ...config, + providers: [ + { + id: 'external', + name: 'External Provider', + url: 'https://provider.example/v1', + backend: 'openai', + transportAdapter: 'example-transport', + models: [ + { id: 'claude-sonnet-4-6', contextWindow: 200000, source: 'user' }, + { id: 'gemini-3-flash', contextWindow: 300000, supportsVision: false, source: 'user' }, + ], + isActive: true, + createdAt: new Date().toISOString(), + }, + ], + defaultModelSelection: 'external/gemini-3-flash', + } + const manager = createProviderManager(chatConfig, { adapters: adapters as never }) + + await manager.refreshProviderModels('external') + + const models = manager.getProviders()[0]!.models + const claude = models.find((model) => model.id === 'claude-sonnet-4-6') + expect(claude?.contextWindow).toBe(250000) + expect(claude?.supportsVision).toBe(true) + const gemini = models.find((model) => model.id === 'gemini-3-flash') + expect(gemini?.contextWindow).toBe(300000) + expect(gemini?.supportsVision).toBe(false) + }) + + it('replaces a stored raw-id name with the catalog display name', async () => { + const transport = { + id: 'example-transport', + listModels: vi.fn(async () => [ + { + id: 'gemini-3.8-flash-tiered', + name: 'Gemini 3.8 Flash', + contextWindow: 1048576, + source: 'backend' as const, + }, + ]), + complete: vi.fn(), + stream: vi.fn(), + } + const adapters = { getTransport: vi.fn((id?: string) => (id === 'example-transport' ? transport : undefined)) } + const chatConfig: Config = { + ...config, + providers: [ + { + id: 'external', + name: 'External Provider', + url: 'https://provider.example/v1', + backend: 'openai', + transportAdapter: 'example-transport', + models: [ + { + id: 'gemini-3.8-flash-tiered', + name: 'gemini-3.8-flash-tiered', + contextWindow: 1048576, + source: 'user', + selected: true, + }, + ], + isActive: true, + createdAt: new Date().toISOString(), + }, + ], + defaultModelSelection: 'external/gemini-3.8-flash-tiered', + } + const manager = createProviderManager(chatConfig, { adapters: adapters as never }) + + await manager.refreshProviderModels('external') + + const models = manager.getProviders()[0]!.models + expect(models.find((model) => model.id === 'gemini-3.8-flash-tiered')?.name).toBe('Gemini 3.8 Flash') + }) + + it('keeps a stored display name that merely looks like a mode-suffixed catalog name', async () => { + const transport = { + id: 'example-transport', + listModels: vi.fn(async () => [ + { + id: 'gemini-3.6-flash', + name: 'Gemini 3.6 Flash', + contextWindow: 1048576, + source: 'backend' as const, + }, + ]), + complete: vi.fn(), + stream: vi.fn(), + } + const adapters = { getTransport: vi.fn((id?: string) => (id === 'example-transport' ? transport : undefined)) } + const chatConfig: Config = { + ...config, + providers: [ + { + id: 'external', + name: 'External Provider', + url: 'https://provider.example/v1', + backend: 'openai', + transportAdapter: 'example-transport', + models: [ + { + id: 'gemini-3.6-flash', + name: 'Gemini 3.6 Flash (High)', + contextWindow: 1048576, + source: 'user', + selected: true, + }, + ], + isActive: true, + createdAt: new Date().toISOString(), + }, + ], + defaultModelSelection: 'external/gemini-3.6-flash', + } + const manager = createProviderManager(chatConfig, { adapters: adapters as never }) + + await manager.refreshProviderModels('external') + + const models = manager.getProviders()[0]!.models + expect(models.find((model) => model.id === 'gemini-3.6-flash')?.name).toBe('Gemini 3.6 Flash (High)') + }) + it('preserves user overrides during refresh', async () => { await providerManager.updateModelContext('provider-1', 'model-a', 150000) @@ -648,6 +820,52 @@ describe('ProviderManager - Model Selection', () => { expect(merged?.modes?.length).toBe(3) }) + it('adopts a variant context window for a merged mode-chip model on refresh', async () => { + const mergedConfig: Config = { + ...config, + providers: [ + { + id: 'omni', + name: 'OmniRoute', + url: 'http://localhost:9100', + backend: 'openai', + models: [ + { + id: 'gemini-3.6-flash', + contextWindow: 200000, + source: 'user', + selected: true, + modes: [ + { level: 'low', apiModelId: 'gemini-3.6-flash-low' }, + { level: 'high', apiModelId: 'gemini-3.6-flash-high' }, + ], + }, + ], + isActive: true, + createdAt: new Date().toISOString(), + }, + ], + } + const manager = createProviderManager(mergedConfig) + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: 'gemini-3.6-flash-low', max_model_len: 1048576 }, + { id: 'gemini-3.6-flash-high', max_model_len: 1048576 }, + ], + }), + }) + + await manager.refreshProviderModels('omni') + + const models = manager.getProviders().find((p) => p.id === 'omni')?.models ?? [] + expect(models.map((m) => m.id)).toEqual(['gemini-3.6-flash']) + expect(models[0]!.contextWindow).toBe(1048576) + expect(models[0]!.selected).toBe(true) + }) + it('returns error when backend returns no models', async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/src/server/provider-manager.ts b/src/server/provider-manager.ts index ef176531a..db9e41d50 100644 --- a/src/server/provider-manager.ts +++ b/src/server/provider-manager.ts @@ -14,7 +14,12 @@ import { getBackendCapabilities, type Backend, } from './llm/backend.js' -import { resolveEffortForModel, resolveModeModelId } from '../shared/reasoning-effort.js' +import { + resolveEffortForModel, + resolveModeModelId, + collapseModeFamilies, + UNKNOWN_CONTEXT_WINDOW, +} from '../shared/reasoning-effort.js' /** * num_ctx is the context window we request from Ollama's native /api/chat @@ -87,6 +92,12 @@ function enrichWithProfileDefaults(model: ModelConfig): ModelConfig { } } +/** + * The context window hardcoded everywhere a model's real limit is unknown. + * A stored value equal to it means "unset", so a catalog can still fill it in. + */ +const LEGACY_PLACEHOLDER_CONTEXT_WINDOW = UNKNOWN_CONTEXT_WINDOW + /** * Add curated reasoning-effort info to models that lack it. Display-only and * idempotent: existing per-model config (thinkingLevel, reasoningEfforts) is @@ -104,43 +115,114 @@ function enrichWithCatalogDefaults(model: ModelConfig): ModelConfig { } } +/** + * A merged mode-chip model stands in for concrete catalog ids, so the catalog + * may know its variants but not the merged id itself. Adopt a variant's + * context window when the merged model still carries the unknown-value + * placeholder. + */ +function fillFromModeVariants(userModel: ModelConfig, backendModels: Map): ModelConfig { + if (userModel.contextWindow !== LEGACY_PLACEHOLDER_CONTEXT_WINDOW || !userModel.modes?.length) return userModel + for (const mode of userModel.modes) { + const variant = mode.apiModelId ? backendModels.get(normalizeModelId(mode.apiModelId)) : undefined + const contextWindow = variant?.contextWindow + if (contextWindow !== undefined && contextWindow !== LEGACY_PLACEHOLDER_CONTEXT_WINDOW) { + return { ...userModel, contextWindow } + } + } + return userModel +} + function mergeModelsWithUserOverrides( - backendModels: ModelConfig[], + rawBackendModels: ModelConfig[], userModels: ModelConfig[], preserveMissingUserModels = true, ): ModelConfig[] { + const backendModels = collapseModeFamilies(rawBackendModels) const normalizedUserIdMap = new Map(userModels.map((m) => [normalizeModelId(m.id), m])) - // A user model with `modes` is a merged mode-chip model that represents + // Detect families the user explicitly unmerged: they stored ≥2 suffixed + // variants of a family without a merged model for that base id. When this is + // the case the auto-collapsed base must not reappear — the user's variants + // are the authoritative representation. + const userUnmergedBases = new Set() + for (const backendMerged of backendModels) { + if (!backendMerged.modes?.length) continue + const baseNorm = normalizeModelId(backendMerged.id) + if (normalizedUserIdMap.has(baseNorm)) continue + const userVariantCount = backendMerged.modes.filter((mode) => + normalizedUserIdMap.has(normalizeModelId(mode.apiModelId)), + ).length + if (userVariantCount >= 2) { + userUnmergedBases.add(baseNorm) + } + } + + // A model with `modes` is a merged mode-chip model that represents // multiple concrete backend catalog ids (its modes' apiModelIds). Hide // those suffixed backend variants so they don't reappear alongside the // merged model on refresh. const claimedByMergedModes = new Set() - for (const userModel of userModels) { - if (!userModel.modes?.length) continue - claimedByMergedModes.add(normalizeModelId(userModel.id)) - for (const mode of userModel.modes) { - if (mode.apiModelId) claimedByMergedModes.add(normalizeModelId(mode.apiModelId)) + for (const model of [...userModels, ...backendModels]) { + if (!model.modes?.length) continue + for (const mode of model.modes) { + if (mode.apiModelId && normalizeModelId(mode.apiModelId) !== normalizeModelId(model.id)) { + claimedByMergedModes.add(normalizeModelId(mode.apiModelId)) + } } } - const filteredBackendModels = backendModels.filter((m) => !claimedByMergedModes.has(normalizeModelId(m.id))) + const filteredBackendModels = backendModels.filter( + (m) => !claimedByMergedModes.has(normalizeModelId(m.id)) && !userUnmergedBases.has(normalizeModelId(m.id)), + ) const updatedModels = filteredBackendModels.map((backendModel) => { const existingUserModel = normalizedUserIdMap.get(normalizeModelId(backendModel.id)) if (existingUserModel) { - return enrichWithProfileDefaults({ ...backendModel, ...existingUserModel, id: backendModel.id }) + // Stored models are profile-enriched at load, so a stored capability equal + // to the profile default carries no user intent — the catalog wins. + const { supportsVision: storedVision, name: storedName, ...userOverrides } = existingUserModel + const supportsVision = + storedVision !== undefined && storedVision !== getModelProfile(existingUserModel.id).supportsVision + ? storedVision + : backendModel.supportsVision + // A stored name that is just the raw id is not a user rename — the + // catalog's display name wins. Any other stored name is kept verbatim. + const name = storedName !== undefined && storedName !== existingUserModel.id ? storedName : backendModel.name + // A stored context window equal to the legacy hardcoded placeholder means + // "unknown", not a deliberate cap — let the catalog update it. + const contextWindow = + existingUserModel.contextWindow === LEGACY_PLACEHOLDER_CONTEXT_WINDOW && + backendModel.contextWindow !== undefined && + backendModel.contextWindow !== LEGACY_PLACEHOLDER_CONTEXT_WINDOW + ? backendModel.contextWindow + : existingUserModel.contextWindow + return enrichWithProfileDefaults({ + ...backendModel, + ...userOverrides, + // The transport catalog always knows the authoritative effort list; + // a stored list that differs only means stale data from a previous + // save — let the backend win so chips stay correct after a re-fetch. + ...(backendModel.reasoningEfforts?.length ? { reasoningEfforts: backendModel.reasoningEfforts } : {}), + contextWindow, + ...(name !== undefined ? { name } : {}), + ...(supportsVision !== undefined ? { supportsVision } : {}), + id: backendModel.id, + }) } return enrichWithProfileDefaults(backendModel) }) - if (preserveMissingUserModels) { - const normalizedBackendIds = new Set(filteredBackendModels.map((m) => normalizeModelId(m.id))) - for (const userModel of userModels) { - if (!normalizedBackendIds.has(normalizeModelId(userModel.id))) { - updatedModels.push(enrichWithProfileDefaults(userModel)) - } - } + const normalizedBackendIds = new Set(filteredBackendModels.map((m) => normalizeModelId(m.id))) + // Variant lookup runs against the raw (pre-collapse) catalog so suffixed + // variants are still reachable when filling context windows for merged models. + const backendModelById = new Map(rawBackendModels.map((m) => [normalizeModelId(m.id), m])) + for (const userModel of userModels) { + if (normalizedBackendIds.has(normalizeModelId(userModel.id))) continue + // When preserveMissingUserModels is false (authoritative catalog / transport adapter), + // models dropped by the catalog are removed completely. + if (!preserveMissingUserModels) continue + updatedModels.push(enrichWithProfileDefaults(fillFromModeVariants(userModel, backendModelById))) } return updatedModels @@ -794,8 +876,8 @@ export function createProviderManager(config: Config, options: ProviderManagerOp return provider.models } - // Fallback: fetch from backend if no stored models - return fetchProviderModels(provider) + // Fallback: fetch from backend if no stored models; collapse suffix variants + return collapseModeFamilies(await fetchProviderModels(provider)) }, async setDefaultModelSelection(providerId: string, model: string) { diff --git a/src/server/providers/adapters/provider-deletion.test.ts b/src/server/providers/adapters/provider-deletion.test.ts new file mode 100644 index 000000000..c665c1b17 --- /dev/null +++ b/src/server/providers/adapters/provider-deletion.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ProviderAuthAdapter } from '../../../provider/index.js' +import { logger } from '../../utils/logger.js' +import { cascadeProviderDelete } from './provider-deletion.js' + +function adapterWith(deleteProvider?: (providerId: string) => Promise): ProviderAuthAdapter { + return { + id: 'multi-account-auth', + beginLogin: vi.fn(), + getStatus: vi.fn(), + getAccessContext: vi.fn(), + logout: vi.fn(), + ...(deleteProvider ? { deleteProvider } : {}), + } as unknown as ProviderAuthAdapter +} + +describe('cascadeProviderDelete', () => { + it('removes the accounts owned by the deleted provider', async () => { + const deleteProvider = vi.fn(async () => undefined) + + await cascadeProviderDelete(adapterWith(deleteProvider), 'provider-1') + + expect(deleteProvider).toHaveBeenCalledWith('provider-1') + }) + + it('does nothing when the adapter has no provider-deletion hook', async () => { + await expect(cascadeProviderDelete(adapterWith(), 'provider-1')).resolves.toBeUndefined() + await expect(cascadeProviderDelete(undefined, 'provider-1')).resolves.toBeUndefined() + }) + + it('logs and swallows adapter failures so the provider stays deletable', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined) + const deleteProvider = vi.fn(async () => { + throw new Error('boom') + }) + + await expect(cascadeProviderDelete(adapterWith(deleteProvider), 'provider-1')).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith( + 'Failed to cascade provider deletion to its auth adapter', + expect.objectContaining({ providerId: 'provider-1', error: 'boom' }), + ) + warn.mockRestore() + }) +}) diff --git a/src/server/providers/adapters/provider-deletion.ts b/src/server/providers/adapters/provider-deletion.ts new file mode 100644 index 000000000..ec737c96e --- /dev/null +++ b/src/server/providers/adapters/provider-deletion.ts @@ -0,0 +1,31 @@ +import type { ProviderAuthAdapter } from '../../../provider/index.js' +import { logger } from '../../utils/logger.js' + +/** + * Auth adapters that own per-provider credentials (multi-account plugins store + * one credential per account) may expose this hook so deleting the provider + * also removes the accounts it created. + */ +export type AuthAdapterWithProviderDeletion = ProviderAuthAdapter & { + deleteProvider?(providerId: string): Promise +} + +/** + * Cascade a provider deletion to its auth adapter. Failures are logged, never + * thrown: a provider must stay deletable even when its plugin misbehaves. + */ +export async function cascadeProviderDelete( + adapter: ProviderAuthAdapter | undefined, + providerId: string, +): Promise { + const withDeletion = adapter as AuthAdapterWithProviderDeletion | undefined + if (!withDeletion || typeof withDeletion.deleteProvider !== 'function') return + try { + await withDeletion.deleteProvider(providerId) + } catch (error) { + logger.warn('Failed to cascade provider deletion to its auth adapter', { + providerId, + error: error instanceof Error ? error.message : String(error), + }) + } +} diff --git a/src/server/routes/plugins.test.ts b/src/server/routes/plugins.test.ts index eb46c3653..71780d33f 100644 --- a/src/server/routes/plugins.test.ts +++ b/src/server/routes/plugins.test.ts @@ -138,4 +138,27 @@ describe('plugin routes', () => { expect(openFolder).not.toHaveBeenCalled() }) }) + + describe('POST /check-updates', () => { + it('triggers update check and returns updates', async () => { + const res = await fetch(`${baseUrl}/api/plugins/check-updates`, { + method: 'POST', + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { success: boolean; updates: unknown[] } + expect(body.success).toBe(true) + expect(Array.isArray(body.updates)).toBe(true) + }) + }) + + describe('POST /:id/reinstall', () => { + it('fails when plugin is not found', async () => { + const res = await fetch(`${baseUrl}/api/plugins/non-existent/reinstall`, { + method: 'POST', + }) + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain('Plugin not found') + }) + }) }) diff --git a/src/server/routes/plugins.ts b/src/server/routes/plugins.ts index 4c306f167..d0bb3f2b0 100644 --- a/src/server/routes/plugins.ts +++ b/src/server/routes/plugins.ts @@ -106,6 +106,16 @@ export function createPluginRoutes(options: PluginRoutesOptions): Router { res.json({ tools: host.getPluginTools() }) }) + router.post('/check-updates', async (_req, res) => { + try { + const updates = await host.checkUpdates() + res.json({ success: true, updates }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + res.status(500).json({ success: false, error: message }) + } + }) + router.post('/install', async (req, res) => { const body = req.body as { githubUrl?: unknown; npm?: unknown; path?: unknown } try { @@ -172,6 +182,13 @@ export function createPluginRoutes(options: PluginRoutesOptions): Router { }) }) + router.post('/:id/reinstall', (req, res) => { + void runForPluginId(req, res, async (id) => { + const diagnostic = await host.reinstall(id) + return { success: true, plugin: diagnostic, plugins: host.getPlugins() } + }) + }) + router.get('/:id/settings', (req, res) => { const id = pluginId(req) if (!requireValidId(id, res)) return @@ -226,6 +243,116 @@ export function createPluginRoutes(options: PluginRoutesOptions): Router { } }) + router.all('/:id/proxy', async (req, res) => { + const id = pluginId(req) + if (!requireValidId(id, res)) return + + let targetUrl: string | undefined = req.query['url'] as string | undefined + const rawUrlIdx = req.originalUrl.indexOf('url=') + if (rawUrlIdx !== -1) { + const rawParam = req.originalUrl.slice(rawUrlIdx + 4) + try { + targetUrl = decodeURIComponent(rawParam) + } catch { + targetUrl = req.query['url'] as string | undefined + } + } + + if (!targetUrl || (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://'))) { + return res.status(400).json({ error: serverT({ en: 'Invalid target URL', fr: 'URL cible invalide' }) }) + } + + try { + const parsed = new URL(targetUrl) + if ( + parsed.hostname !== 'localhost' && + parsed.hostname !== '127.0.0.1' && + parsed.hostname !== '::1' && + !parsed.hostname.endsWith('.localhost') + ) { + return res.status(403).json({ + error: serverT({ + en: 'Only loopback URLs can be proxied', + fr: 'Seules les URLs locales peuvent être relayées', + }), + }) + } + + const forwardHeaders: Record = { + Accept: req.headers['accept'] ?? '*/*', + } + if (req.headers['content-type']) { + forwardHeaders['Content-Type'] = req.headers['content-type'] + } + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' && req.body + const response = await fetch(targetUrl, { + method: req.method, + headers: forwardHeaders, + ...(hasBody ? { body: typeof req.body === 'string' ? req.body : JSON.stringify(req.body) } : {}), + }) + + const contentType = response.headers.get('content-type') ?? 'text/html' + res.status(response.status) + res.setHeader('Content-Type', contentType) + res.removeHeader('X-Frame-Options') + res.removeHeader('Content-Security-Policy') + + if (contentType.includes('text/html')) { + let html = await response.text() + const origin = `${parsed.protocol}//${parsed.host}` + const interceptScript = `` + + // Rewrite relative script and link src/href to proxy + html = html.replace(/(src|href)=["']\/([^"']+)["']/g, (match, attr, path) => { + if (path.startsWith('api/')) return match + return `${attr}="/api/plugins/${encodeURIComponent(id)}/proxy?url=${encodeURIComponent(origin + '/' + path)}"` + }) + + if (html.includes(']*>/i, `$&${interceptScript}`) + } else { + html = interceptScript + html + } + res.send(html) + } else { + const buffer = await response.arrayBuffer() + res.send(Buffer.from(buffer)) + } + } catch (error) { + res.status(502).json({ + error: `Failed to connect to ${targetUrl}: ${error instanceof Error ? error.message : String(error)}`, + }) + } + }) + router.get('/:id/assets/*assetPath', async (req, res) => { const id = pluginId(req) if (!requireValidId(id, res)) return diff --git a/src/server/routes/provider-auth.test.ts b/src/server/routes/provider-auth.test.ts index ed50b55d9..73472e56f 100644 --- a/src/server/routes/provider-auth.test.ts +++ b/src/server/routes/provider-auth.test.ts @@ -34,7 +34,7 @@ describe('provider auth routes', () => { Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))), ) - async function start(withAdapter = true) { + async function start(withAdapter: boolean | Record = true) { const registry = new ProviderRegistry({ mode: 'production', configDirectory: '/tmp/openfox-test' }) if (withAdapter) registry.registerAuth({ @@ -51,7 +51,8 @@ describe('provider auth routes', () => { getStatus: async () => ({ state: 'disconnected' }), getAccessContext: async () => ({}), logout: async () => undefined, - }) + ...(typeof withAdapter === 'object' ? withAdapter : {}), + } as never) const app = express() app.use(express.json()) app.use('/api/provider-auth', createProviderAuthRoutes(config, manager(), registry)) @@ -76,4 +77,24 @@ describe('provider auth routes', () => { expect(response.status).toBe(424) expect(await response.json()).toEqual({ error: 'Missing provider auth plugin: external-auth' }) }) + + it('deletes the accounts owned by the provider when it is disconnected', async () => { + const deleteProvider = vi.fn(async () => undefined) + const url = await start({ getStatus: async () => ({ state: 'connected' }), deleteProvider }) + + const response = await fetch(`${url}/api/provider-auth/provider-1/logout`, { method: 'POST' }) + + expect(response.status).toBe(200) + expect(deleteProvider).toHaveBeenCalledWith('provider-1') + }) + + it('falls back to a single-credential logout when the adapter owns no per-provider accounts', async () => { + const logout = vi.fn(async () => undefined) + const url = await start({ logout }) + + const response = await fetch(`${url}/api/provider-auth/provider-1/logout`, { method: 'POST' }) + + expect(response.status).toBe(200) + expect(logout).toHaveBeenCalledWith('provider-1') + }) }) diff --git a/src/server/routes/provider-auth.ts b/src/server/routes/provider-auth.ts index 9b986cf9d..96dd03cf2 100644 --- a/src/server/routes/provider-auth.ts +++ b/src/server/routes/provider-auth.ts @@ -3,6 +3,7 @@ import type { Config, Provider } from '../../shared/types.js' import type { ProviderAuthAdapter } from '../../provider/index.js' import type { ProviderManager } from '../provider-manager.js' import type { ProviderRegistry } from '../providers/plugins/registry.js' +import { cascadeProviderDelete, type AuthAdapterWithProviderDeletion } from '../providers/adapters/provider-deletion.js' import { logger } from '../utils/logger.js' import { serverT } from '../i18n.js' @@ -90,8 +91,12 @@ export function createProviderAuthRoutes( if (!context) return const { provider, adapter } = context if (adapter) { - const ref = provider.credentialRef ?? provider.id - await adapter.logout(ref) + // Disconnecting a provider also drops the credentials (accounts) it owns. + await cascadeProviderDelete(adapter, provider.id) + if (!(adapter as AuthAdapterWithProviderDeletion).deleteProvider) { + const ref = provider.credentialRef ?? provider.id + await adapter.logout(ref) + } } const { loadGlobalConfig, saveGlobalConfig } = await import('../../cli/config.js') diff --git a/src/server/tools/path-security.plugin.test.ts b/src/server/tools/path-security.plugin.test.ts new file mode 100644 index 000000000..37657c9e3 --- /dev/null +++ b/src/server/tools/path-security.plugin.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { requestPathAccess, PathAccessDeniedError, isPathAllowed, clearAllowedPaths } from './path-security.js' +import { setPluginDangerLevels, clearPluginDangerLevels } from '../plugins/danger-levels.js' + +describe('Path Security with Plugin Danger Levels', () => { + const WORKDIR = process.platform === 'win32' ? 'C:\\app\\project' : '/app/project' + const OUTSIDE_PATH = process.platform === 'win32' ? 'C:\\app\\external\\file.txt' : '/app/external/file.txt' + const SESSION_ID = 'session-dl-test' + + beforeEach(() => { + clearAllowedPaths(SESSION_ID) + clearPluginDangerLevels() + }) + + afterEach(() => { + clearAllowedPaths(SESSION_ID) + clearPluginDangerLevels() + }) + + it('auto-approves outside paths when plugin danger level returns allow', async () => { + setPluginDangerLevels([ + { + pluginId: 'whitelist-plugin', + dangerLevel: { + id: 'whitelist', + label: { en: 'Whitelist Only', fr: 'Liste blanche' }, + evaluatePathAccess: vi.fn().mockResolvedValue({ action: 'allow' }), + }, + }, + ]) + + const onEvent = vi.fn() + await expect( + requestPathAccess([OUTSIDE_PATH], WORKDIR, SESSION_ID, 'call-1', 'read_file', onEvent, 'whitelist'), + ).resolves.toBeUndefined() + + // No modal event should be sent + expect(onEvent).not.toHaveBeenCalled() + // Path should be added to allowed paths + expect(isPathAllowed(SESSION_ID, OUTSIDE_PATH)).toBe(true) + }) + + it('immediately denies access with custom message when plugin danger level returns deny', async () => { + const customMsg = 'Document outside project is strictly forbidden.' + setPluginDangerLevels([ + { + pluginId: 'whitelist-plugin', + dangerLevel: { + id: 'whitelist', + label: { en: 'Whitelist Only', fr: 'Liste blanche' }, + evaluatePathAccess: vi.fn().mockResolvedValue({ action: 'deny', message: customMsg }), + }, + }, + ]) + + const onEvent = vi.fn() + await expect( + requestPathAccess([OUTSIDE_PATH], WORKDIR, SESSION_ID, 'call-2', 'read_file', onEvent, 'whitelist'), + ).rejects.toThrow(PathAccessDeniedError) + + // No modal event should be sent (immediate rejection without popup) + expect(onEvent).not.toHaveBeenCalled() + + try { + await requestPathAccess([OUTSIDE_PATH], WORKDIR, SESSION_ID, 'call-2', 'read_file', onEvent, 'whitelist') + } catch (err) { + expect(err).toBeInstanceOf(PathAccessDeniedError) + expect((err as PathAccessDeniedError).customMessage).toBe(customMsg) + expect((err as PathAccessDeniedError).message).toBe(customMsg) + } + }) + + it('sub-agent: auto-approves outside paths when plugin danger level returns allow', async () => { + setPluginDangerLevels([ + { + pluginId: 'whitelist-plugin', + dangerLevel: { + id: 'whitelist', + label: { en: 'Whitelist Only', fr: 'Liste blanche' }, + evaluatePathAccess: vi.fn().mockResolvedValue({ action: 'allow' }), + }, + }, + ]) + + const onEvent = vi.fn() + await expect( + requestPathAccess( + [OUTSIDE_PATH], + WORKDIR, + SESSION_ID, + 'call-sub', + 'read_file', + onEvent, + 'whitelist', + undefined, + true, + ), + ).resolves.toBeUndefined() + + expect(isPathAllowed(SESSION_ID, OUTSIDE_PATH)).toBe(true) + }) + + it('sub-agent: immediately denies with custom message when plugin danger level returns deny', async () => { + const customMsg = 'Sub-agent denied.' + setPluginDangerLevels([ + { + pluginId: 'whitelist-plugin', + dangerLevel: { + id: 'whitelist', + label: { en: 'Whitelist Only', fr: 'Liste blanche' }, + evaluatePathAccess: vi.fn().mockResolvedValue({ action: 'deny', message: customMsg }), + }, + }, + ]) + + const onEvent = vi.fn() + try { + await requestPathAccess( + [OUTSIDE_PATH], + WORKDIR, + SESSION_ID, + 'call-sub-deny', + 'read_file', + onEvent, + 'whitelist', + undefined, + true, + ) + expect.unreachable('Should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(PathAccessDeniedError) + expect((err as PathAccessDeniedError).customMessage).toBe(customMsg) + } + }) +}) diff --git a/src/server/tools/path-security.ts b/src/server/tools/path-security.ts index f4ad5424b..09700ff7c 100644 --- a/src/server/tools/path-security.ts +++ b/src/server/tools/path-security.ts @@ -5,6 +5,7 @@ import type { ServerMessage } from '../../shared/protocol.js' import { createChatPathConfirmationMessage } from '../ws/protocol.js' import { getEventStore } from '../events/index.js' import { getPlatformShell } from '../utils/platform.js' +import { evaluatePluginPathAccess } from '../plugins/danger-levels.js' // =========================================================================== // Constants @@ -1318,6 +1319,7 @@ export async function requestPathAccess( dangerLevel?: string, command?: string, isSubAgent?: boolean, + projectId?: string, ): Promise { // Sub-agent shortcut: skip all confirmation dialogs since they don't render // properly in the small sub-agent window. Fail closed in normal mode; @@ -1332,6 +1334,29 @@ export async function requestPathAccess( return } + if (dangerLevel && dangerLevel !== 'normal' && dangerLevel !== 'dangerous') { + const allPaths = [...new Set([...result.deniedPaths, ...result.sensitivePaths])] + const decision = await evaluatePluginPathAccess(dangerLevel, { + paths: allPaths, + workdir, + sessionId, + projectId, + tool, + command, + }) + if (decision?.action === 'allow') { + addAllowedPaths(sessionId, allPaths) + return + } + if (decision?.action === 'deny') { + const hasDenied = result.deniedPaths.length > 0 + const hasSensitive = result.sensitivePaths.length > 0 + const reason: PathDenialReason = + hasDenied && hasSensitive ? 'both' : hasDenied ? 'outside_workdir' : 'sensitive_file' + throw new PathAccessDeniedError(allPaths, tool, reason, decision.message) + } + } + const allPaths = [...new Set([...result.deniedPaths, ...result.sensitivePaths])] const hasDenied = result.deniedPaths.length > 0 const hasSensitive = result.sensitivePaths.length > 0 @@ -1424,6 +1449,25 @@ export async function requestPathAccess( ? ('outside_workdir' as const) : ('sensitive_file' as const) + // Custom plugin danger level evaluation + if (dangerLevel && dangerLevel !== 'normal') { + const decision = await evaluatePluginPathAccess(dangerLevel, { + paths: allPathsNeedingConfirmation, + workdir, + sessionId, + projectId, + tool, + command, + }) + if (decision?.action === 'allow') { + addAllowedPaths(sessionId, allPathsNeedingConfirmation) + return + } + if (decision?.action === 'deny') { + throw new PathAccessDeniedError(allPathsNeedingConfirmation, tool, reason, decision.message) + } + } + // Emit pending event for persistence emitPendingEvent(allPathsNeedingConfirmation, reason) diff --git a/src/server/tools/shell.ts b/src/server/tools/shell.ts index 0c2b1e4df..746dc13e0 100644 --- a/src/server/tools/shell.ts +++ b/src/server/tools/shell.ts @@ -1,12 +1,10 @@ -import { spawn } from 'node:child_process' import { resolve, isAbsolute } from 'node:path' -import { access } from 'node:fs/promises' import stripAnsi from 'strip-ansi' import { OUTPUT_LIMITS } from './types.js' import { createTool, requestUserConfirmation } from './tool-helpers.js' import { serverT } from '../i18n.js' import { checkAborted, spawnShellProcess } from '../utils/shell.js' -import { decodeUtf8, createUtf8StreamDecoder } from '../utils/utf8.js' +import { createUtf8StreamDecoder } from '../utils/utf8.js' import { extractAbsolutePathsFromCommand, extractSensitivePathsFromCommand, @@ -14,7 +12,6 @@ import { } from './path-security.js' import { terminateProcessTree } from '../utils/process-tree.js' import { stripTailPipe } from './shell-tail.js' -import { getSetting, SETTINGS_KEYS } from '../db/settings.js' /** * Check if a command performs a Git mutation that changes branches or workspace state. @@ -32,35 +29,6 @@ export function detectGitMutation(command: string): string | null { return null } -let rtkAvailable: boolean | undefined - -async function checkRtkAvailability(): Promise { - if (rtkAvailable !== undefined) return rtkAvailable - try { - await access('/usr/local/bin/rtk') - rtkAvailable = true - } catch { - try { - const out = await new Promise((resolve, reject) => { - const proc = spawn('rtk', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }) - let output = '' - proc.stdout?.on('data', (d: Buffer) => { - output += d.toString() - }) - proc.on('error', reject) - proc.on('close', (code) => { - if (code === 0) resolve(output.trim()) - else reject(new Error(`exit ${code}`)) - }) - }) - rtkAvailable = out.startsWith('rtk ') - } catch { - rtkAvailable = false - } - } - return rtkAvailable -} - export function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms` if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` @@ -182,11 +150,8 @@ export const runCommandTool = createTool( const tailInfo = stripTailPipe(args.command) const execCommand = tailInfo ? tailInfo.command : args.command - const useRtk = getSetting(SETTINGS_KEYS.TOOLS_USE_RTK) === 'true' - const finalCommand = useRtk ? await tryRtkRewrite(execCommand) : execCommand - const execStart = Date.now() - const result = await executeCommand(finalCommand, workingDir, timeout, context.signal, context.onProgress) + const result = await executeCommand(execCommand, workingDir, timeout, context.signal, context.onProgress) let output = '' @@ -250,31 +215,6 @@ interface CommandResult { interrupted?: boolean } -async function tryRtkRewrite(command: string): Promise { - if (!(await checkRtkAvailability())) return command - try { - const result = await new Promise((resolve, reject) => { - const proc = spawn('rtk', ['rewrite', command], { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 2_000, - }) - const chunks: Buffer[] = [] - proc.stdout?.on('data', (data: Buffer) => { - chunks.push(data) - }) - proc.on('error', reject) - proc.on('close', (code) => { - if (code === 0 || code === 3) resolve(decodeUtf8(chunks).trim()) - else reject(new Error(`exit ${code}`)) - }) - }) - if (result && result !== command) return result - } catch { - // rewrite failed — fall through - } - return command -} - function executeCommand( command: string, cwd: string, diff --git a/src/server/tools/tool-helpers.ts b/src/server/tools/tool-helpers.ts index 65423ed6b..cb5bf7a28 100644 --- a/src/server/tools/tool-helpers.ts +++ b/src/server/tools/tool-helpers.ts @@ -222,6 +222,11 @@ export function createTool(name: string, definition: LLMToolDefinition, h checkPathAccess: async (paths: string[], command?: string) => { if (context.onEvent) { + const projectId = + context.projectId ?? + (typeof context.sessionManager?.getSession === 'function' + ? context.sessionManager.getSession(context.sessionId)?.projectId + : undefined) await requestPathAccess( paths, context.workdir, @@ -232,6 +237,7 @@ export function createTool(name: string, definition: LLMToolDefinition, h context.dangerLevel, command, context.isSubAgent, + projectId, ) } }, diff --git a/src/server/tools/types.ts b/src/server/tools/types.ts index d71647ba1..7d6ef4477 100644 --- a/src/server/tools/types.ts +++ b/src/server/tools/types.ts @@ -10,6 +10,7 @@ import type { ProviderManager } from '../provider-manager.js' export interface ToolContext { workdir: string sessionId: string + projectId?: string sessionManager: SessionManager // Injected dependency (replaces singleton import) dangerLevel?: DangerLevel // When 'dangerous', bypass path confirmations isSubAgent?: boolean // When true, sub-agent path restrictions apply (deny outside workdir unless dangerous) diff --git a/src/server/utils/process-tree.test.ts b/src/server/utils/process-tree.test.ts index e05d78efc..7682d93e0 100644 --- a/src/server/utils/process-tree.test.ts +++ b/src/server/utils/process-tree.test.ts @@ -70,8 +70,11 @@ async function getDescendants(rootPid: number): Promise { const pid = parseInt(parts[0]!, 10) const ppid = parseInt(parts[1]!, 10) if (!isNaN(pid) && !isNaN(ppid) && pid > 0 && ppid >= 0) { - if (!children.has(ppid)) children.set(ppid, []) - children.get(ppid)!.push(pid) + // Filter out processes that have already exited (CIM / ps tables can be stale or contain PID reuse) + if (isAlive(pid)) { + if (!children.has(ppid)) children.set(ppid, []) + children.get(ppid)!.push(pid) + } } } const descendants: number[] = [] @@ -84,7 +87,9 @@ async function getDescendants(rootPid: number): Promise { for (const kid of kids) { if (seen.has(kid)) continue seen.add(kid) - descendants.push(kid) + if (isAlive(kid)) { + descendants.push(kid) + } queue.push(kid) } } diff --git a/src/server/workflows/executor.test.ts b/src/server/workflows/executor.test.ts index 5f7d49257..5fe3cb762 100644 --- a/src/server/workflows/executor.test.ts +++ b/src/server/workflows/executor.test.ts @@ -438,11 +438,12 @@ describe('formatModifiedFiles', () => { it('returns (none) when no files are modified', async () => { const tmp = mkdtempSync(join(tmpdir(), 'openfox-test-')) try { - execSync('git init', { cwd: tmp, stdio: 'pipe' }) - execSync('git config user.email test@test.com', { cwd: tmp, stdio: 'pipe' }) - execSync('git config user.name test', { cwd: tmp, stdio: 'pipe' }) + const gitEnv = { ...process.env, GIT_INDEX_FILE: undefined, GIT_DIR: undefined, GIT_WORK_TREE: undefined } + execSync('git init', { cwd: tmp, stdio: 'pipe', env: gitEnv }) + execSync('git config user.email test@test.com', { cwd: tmp, stdio: 'pipe', env: gitEnv }) + execSync('git config user.name test', { cwd: tmp, stdio: 'pipe', env: gitEnv }) writeFileSync(join(tmp, 'README.md'), '# hello') - execSync('git add . && git commit -m init', { cwd: tmp, stdio: 'pipe' }) + execSync('git add . && git commit -m init', { cwd: tmp, stdio: 'pipe', env: gitEnv }) expect(await formatModifiedFiles(tmp)).toBe('(none)') } finally { rmSync(tmp, { recursive: true, force: true }) @@ -452,11 +453,12 @@ describe('formatModifiedFiles', () => { it('lists modified and untracked files', async () => { const tmp = mkdtempSync(join(tmpdir(), 'openfox-test-')) try { - execSync('git init', { cwd: tmp, stdio: 'pipe' }) - execSync('git config user.email test@test.com', { cwd: tmp, stdio: 'pipe' }) - execSync('git config user.name test', { cwd: tmp, stdio: 'pipe' }) + const gitEnv = { ...process.env, GIT_INDEX_FILE: undefined, GIT_DIR: undefined, GIT_WORK_TREE: undefined } + execSync('git init', { cwd: tmp, stdio: 'pipe', env: gitEnv }) + execSync('git config user.email test@test.com', { cwd: tmp, stdio: 'pipe', env: gitEnv }) + execSync('git config user.name test', { cwd: tmp, stdio: 'pipe', env: gitEnv }) writeFileSync(join(tmp, 'existing.ts'), 'original') - execSync('git add . && git commit -m init', { cwd: tmp, stdio: 'pipe' }) + execSync('git add . && git commit -m init', { cwd: tmp, stdio: 'pipe', env: gitEnv }) writeFileSync(join(tmp, 'existing.ts'), 'modified') writeFileSync(join(tmp, 'new.ts'), 'untracked') const result = await formatModifiedFiles(tmp) diff --git a/src/shared/plugin.ts b/src/shared/plugin.ts index da07c5f69..906ff9bb2 100644 --- a/src/shared/plugin.ts +++ b/src/shared/plugin.ts @@ -13,14 +13,18 @@ export type PluginCapability = | 'workflows' | 'rpc' | 'assets' + | 'transforms' + | 'dangerLevels' export type PluginSlotName = | 'header.actions' | 'session.header.actions' | 'message.actions' | 'composer.actions' + | 'composer.top' | 'session.row.badges' | 'session.header.badges' + | 'plugin.menu' | (string & {}) export type PluginZoneId = @@ -44,12 +48,19 @@ export type PluginZoneId = | 'message.bubble' | 'message.actions' | 'composer' + | 'composer.top' | 'composer.toolbar' | 'composer.actions' | 'session.footer' | 'settings.sidebar' | 'settings.content' | 'modal.footer' + | 'stats.modal' + | 'stats.modal.summary' + | 'provider.modal.step1' + | 'provider.modal.step2' + | 'provider.modal.auth' + | 'provider.modal.model_config' | (string & {}) export type PluginBadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' @@ -58,6 +69,14 @@ export type PluginActivation = | { kind: 'rpc'; method: string; params?: Record } | { kind: 'openPanel'; panelId: string } | { kind: 'openUrl'; url: string } + | { kind: 'openSettings'; tab?: PluginSettingsTabRef } + +/** + * Settings tab opened by an `openSettings` activation: a core tab id + * (`plugins`, `tools`, …) or a full plugin tab reference + * (`plugin::`). + */ +export type PluginSettingsTabRef = string /** * Declarative visibility for a contribution. Every field is ANDed; omitted @@ -68,11 +87,18 @@ export interface PluginVisibilityCondition { hasSession?: boolean hasProject?: boolean hasMessage?: boolean + eq?: Record + neq?: Record } export interface PluginUiAction { id: string pluginId?: string + /** + * `plugin.menu` turns the plugin's own row in the plugins menu into the + * action: `label` replaces the plugin display name and activating the row + * runs `onActivate`. + */ slot: PluginSlotName label: LocalizedString icon?: string @@ -123,13 +149,16 @@ export type DeclarativeNode = | { type: 'keyValue'; items: { key: LocalizedString; value: string }[] } | { type: 'table'; columns: LocalizedString[]; rows: string[][] } | { type: 'progress'; label: LocalizedString; value: number; max: number; tone?: PluginBadgeTone } - | { type: 'badge'; label: LocalizedString; tone?: PluginBadgeTone } + | { type: 'badge'; label: LocalizedString; tone?: PluginBadgeTone; color?: string; className?: string } | { type: 'button' label: LocalizedString + title?: LocalizedString variant?: 'default' | 'primary' | 'danger' | 'ghost' | 'pill' icon?: string - onActivate: PluginActivation + disabled?: boolean + onActivate?: PluginActivation + action?: PluginActivation } | { type: 'divider' } | { @@ -174,11 +203,24 @@ export type DeclarativeNode = id: string placeholder?: LocalizedString defaultValue?: string + defaultChecked?: boolean label?: LocalizedString - inputType?: 'text' | 'number' | 'password' + inputType?: 'text' | 'number' | 'password' | 'checkbox' | 'textarea' + rows?: number + disabled?: boolean onChange?: PluginActivation onBlur?: PluginActivation } + | { + type: 'toggle' + id?: string + enabled?: boolean + defaultChecked?: boolean + disabled?: boolean + label?: LocalizedString + onChange?: PluginActivation + onActivate?: PluginActivation + } | { type: 'select' id: string @@ -195,6 +237,19 @@ export type DeclarativeNode = width?: string | number } +/** + * Live content source for a plugin UI contribution. The host calls the RPC with + * the zone context (providerId, modelId, tabId, plus sessionId/workdir/projectId) + * when the contribution mounts and, when `refreshMs` is set, again on that + * interval while it stays mounted. The RPC returns `{ content }` (a single + * declarative node) or `{ nodes }` (a list, rendered as a column stack). + */ +export interface PluginUiContentSource { + kind: 'rpc' + method: string + refreshMs?: number +} + export interface PluginUiComponent { id: string pluginId?: string @@ -203,6 +258,8 @@ export interface PluginUiComponent { order?: number visibleWhen?: PluginVisibilityCondition component: DeclarativeNode + /** When set, the rendered node comes from this RPC instead of `component`. */ + contentSource?: PluginUiContentSource } export interface PluginUiOverride { @@ -213,13 +270,15 @@ export interface PluginUiOverride { order?: number visibleWhen?: PluginVisibilityCondition replacement?: DeclarativeNode + /** When set, the rendered node comes from this RPC instead of `replacement`. */ + contentSource?: PluginUiContentSource } export interface PluginUiPanel { id: string pluginId?: string title: LocalizedString - size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' + size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full' kind: 'declarative' | 'iframe' content?: DeclarativeNode[] url?: string @@ -234,6 +293,14 @@ export interface PluginSettingsTab { content: DeclarativeNode[] } +export interface PluginDangerLevelView { + id: string + pluginId: string + label: LocalizedString + description?: LocalizedString + badgeTone?: PluginBadgeTone +} + export interface PluginUiContributions { actions: PluginUiAction[] badges: PluginUiBadge[] @@ -242,6 +309,7 @@ export interface PluginUiContributions { components: PluginUiComponent[] overrides: PluginUiOverride[] settingsTabs: PluginSettingsTab[] + dangerLevels?: PluginDangerLevelView[] } export interface PluginUiSection { @@ -269,9 +337,10 @@ export interface PluginSettingsOption { export interface PluginSettingsField { key: string - type: 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea' | 'path' | 'button' + type: 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea' | 'path' | 'button' | 'status' | 'list' label: LocalizedString buttonLabel?: LocalizedString + buttonVariant?: 'default' | 'primary' | 'secondary' | 'danger' | 'ghost' rpcMethod?: string description?: LocalizedString default?: PluginSettingValue @@ -283,6 +352,63 @@ export interface PluginSettingsField { parentKey?: string width?: 'full' | 'half' section?: LocalizedString + hideWhenInstalled?: boolean + /** Display-only field: rendered disabled, always shows `default`, never read from or written to storage. */ + readOnly?: boolean + /** Whether to render a directory browser button to pick files or folders from the filesystem. */ + browseDirectory?: boolean + /** Custom label for the browse directory button. */ + browseButtonLabel?: LocalizedString + /** Danger levels for which this setting field is applicable (e.g. ['whitelist_only']). */ + dangerLevels?: string[] + /** + * Sub-fields of a `list` field, rendered inline on a single row per item. + * Values are stored as a JSON array string, so a list value always travels + * through `PluginSettingsValues` as a `string`. + */ + itemFields?: PluginSettingsField[] + /** Label of the "add row" button of a `list` field. */ + addLabel?: LocalizedString + /** Label of the per-row remove button of a `list` field. */ + removeLabel?: LocalizedString + /** Minimum number of rows of a `list` field. */ + minItems?: number + /** Maximum number of rows of a `list` field. */ + maxItems?: number + /** + * "Open the provider page" button rendered next to the input — useful to send + * the user to the page where an access token is generated. + */ + linkButton?: PluginSettingsLinkButton + /** + * Backing store of the value. When set, the field is read from and written to + * the plugin's own storage (`context.storage`) under that key instead of the + * settings store — the way to surface a secret an earlier version of the + * plugin kept in storage. Storage-backed fields are global. + */ + storageKey?: string +} + +/** + * Button that opens an external page (typically "generate an access token") + * next to a field or a `list` sub-field input. + * + * The URL is a template resolved against the values of the row (or the whole + * form for a top-level field): + * - `{{key}}` is replaced by the value of `key`, + * - `{{key.origin}}` by its URL origin (`https://gitlab.example.com/group/x` → `https://gitlab.example.com`). + * + * The button is disabled while the resolved URL is not an absolute http(s) URL, + * so a template built from a not-yet-filled field stays greyed out. + */ +export interface PluginSettingsLinkButton { + label: LocalizedString + /** URL template, also used as the fallback when `hrefByValue` has no match. */ + href?: string + /** Field whose value selects the template in `hrefByValue`. */ + hrefByField?: string + /** Templates keyed by the value of `hrefByField`. */ + hrefByValue?: Record } export interface PluginSettingsSchema { @@ -347,12 +473,15 @@ export interface PluginContributionSummary { settingsTabs: number uiComponents: number uiOverrides: number + messageTransforms: number + dangerLevels: number } export interface PluginInfo { id: string displayName: string description?: string + author?: string icon?: string logo?: string version: string @@ -412,4 +541,6 @@ export const EMPTY_PLUGIN_CONTRIBUTIONS: PluginContributionSummary = { settingsTabs: 0, uiComponents: 0, uiOverrides: 0, + messageTransforms: 0, + dangerLevels: 0, } diff --git a/src/shared/reasoning-effort.test.ts b/src/shared/reasoning-effort.test.ts index 255ebcca2..c95c48e67 100644 --- a/src/shared/reasoning-effort.test.ts +++ b/src/shared/reasoning-effort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveEffortForModel, splitModeSuffix, groupModeFamilies } from './reasoning-effort.js' +import { resolveEffortForModel, splitModeSuffix, collapseModeFamilies } from './reasoning-effort.js' describe('resolveEffortForModel', () => { it('passes an in-list candidate through unchanged', () => { @@ -72,48 +72,68 @@ describe('splitModeSuffix', () => { }) it('handles prefixed ids and keeps the path segment base', () => { - expect(splitModeSuffix('antigravity/gemini-3.6-flash-medium')).toEqual({ - base: 'antigravity/gemini-3.6-flash', + expect(splitModeSuffix('custom/gemini-3.6-flash-medium')).toEqual({ + base: 'custom/gemini-3.6-flash', level: 'medium', }) }) it('returns undefined when there is no trailing mode suffix or hyphen is at start/end', () => { expect(splitModeSuffix('gemini')).toBeUndefined() - expect(splitModeSuffix('antigravity/gemini')).toBeUndefined() + expect(splitModeSuffix('custom/gemini')).toBeUndefined() expect(splitModeSuffix('provider/-model')).toBeUndefined() expect(splitModeSuffix('provider/model-')).toBeUndefined() }) }) -describe('groupModeFamilies', () => { - it('groups models that differ only by a trailing mode suffix', () => { - const groups = groupModeFamilies([ - { id: 'gemini-3.6-flash-high', name: 'Gemini 3.6 Flash (High)' }, - { id: 'gemini-3.6-flash-low', name: 'Gemini 3.6 Flash (Low)' }, - { id: 'gemini-3.6-flash-medium', name: 'Gemini 3.6 Flash (Medium)' }, - ]) - expect(groups).toHaveLength(1) - expect(groups[0]?.baseId).toBe('gemini-3.6-flash') - expect(groups[0]?.members).toHaveLength(3) - }) - - it('uses the stripped base id as the stable name when no un-suffixed model exists', () => { - const groups = groupModeFamilies([{ id: 'claude-sonnet-4-6-low' }, { id: 'claude-sonnet-4-6-high' }]) - expect(groups[0]?.name).toBe('claude-sonnet-4-6') - }) - - it('returns empty for a single mode (not a real duplicate family)', () => { - expect(groupModeFamilies([{ id: 'gemini-3.6-flash-high' }])).toHaveLength(0) - }) - - it('keeps separate families distinct', () => { - const groups = groupModeFamilies([ - { id: 'gemini-3.6-flash-low' }, - { id: 'gemini-3.6-flash-high' }, - { id: 'claude-opus-4-6-low' }, - { id: 'claude-opus-4-6-high' }, - ]) - expect(groups).toHaveLength(2) +describe('collapseModeFamilies', () => { + it('collapses suffixed variants into a single merged model with modes and an id-derived name', () => { + const raw = [ + { id: 'gemini-3.6-flash-high', name: 'Gemini 3.6 Flash (High)', contextWindow: 1048576, supportsVision: true }, + { id: 'gemini-3.6-flash-low', name: 'Gemini 3.6 Flash (Low)', contextWindow: 1048576, supportsVision: true }, + { + id: 'gemini-3.6-flash-medium', + name: 'Gemini 3.6 Flash (Medium)', + contextWindow: 1048576, + supportsVision: true, + }, + { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 }, + ] + const collapsed = collapseModeFamilies(raw) + expect(collapsed).toHaveLength(2) + const flash = collapsed.find((m) => m.id === 'gemini-3.6-flash') as any + expect(flash).toBeDefined() + // No un-suffixed base model carries a clean display name, and the core must + // not guess one from the members' mode-suffixed names. + expect(flash?.name).toBe('gemini 3.6 flash') + expect(flash?.reasoningEfforts).toEqual(['low', 'medium', 'high']) + expect(flash?.modes).toHaveLength(3) + expect(flash?.modes?.[0]?.level).toBe('low') + expect(flash?.modes?.[0]?.apiModelId).toBe('gemini-3.6-flash-low') + expect(flash?.supportsVision).toBe(true) + }) + + it('prefers the un-suffixed base model display name for the merged entry', () => { + const raw = [ + { id: 'gemini-3.6-flash', name: 'Gemini 3.6 Flash', contextWindow: 1048576 }, + { id: 'gemini-3.6-flash-high', name: 'Gemini 3.6 Flash (High)', contextWindow: 1048576 }, + { id: 'gemini-3.6-flash-low', name: 'Gemini 3.6 Flash (Low)', contextWindow: 1048576 }, + ] + const collapsed = collapseModeFamilies(raw) + const flash = collapsed.find((m) => m.id === 'gemini-3.6-flash') as any + expect(flash?.name).toBe('Gemini 3.6 Flash') + expect(flash?.modes).toHaveLength(2) + }) + + it('preserves existing merged models and does not re-collapse them', () => { + const raw = [ + { + id: 'gemini-3.6-flash', + name: 'Gemini 3.6 Flash', + modes: [{ level: 'low', apiModelId: 'gemini-3.6-flash-low' }], + }, + ] + const collapsed = collapseModeFamilies(raw) + expect(collapsed).toEqual(raw) }) }) diff --git a/src/shared/reasoning-effort.ts b/src/shared/reasoning-effort.ts index c5a691462..f5dbfc201 100644 --- a/src/shared/reasoning-effort.ts +++ b/src/shared/reasoning-effort.ts @@ -72,14 +72,33 @@ export function resolveEffortForModel({ } // ============================================================================ -// Mode-suffix model merging (e.g. OmniRoute, which exposes one model as -// "gemini-3.6-flash-low" / "gemini-3.6-flash-medium" / "gemini-3.6-flash-high"). +// Mode-suffix model merging (providers that expose one model as several ids +// differing only by a trailing mode suffix, e.g. "gemini-3.6-flash-low" / +// "gemini-3.6-flash-medium" / "gemini-3.6-flash-high"). // ============================================================================ /** Suffixes OmniRoute-style providers use to qualify a mode on model IDs/names. */ export const MODE_SUFFIXES = ['low', 'medium', 'high', 'xhigh', 'max'] as const export type ModeSuffix = (typeof MODE_SUFFIXES)[number] +/** + * Sentinel context window used when a model's real limit is unknown. + * A stored value equal to this means "unset" and can be overwritten by catalog data. + */ +export const UNKNOWN_CONTEXT_WINDOW = 200000 + +/** Sort a list of models by semantic mode level (low → medium → high → xhigh → max). */ +export function sortByModeLevel(models: T[]): T[] { + const levelOrder = new Map(MODE_SUFFIXES.map((s, i) => [s, i])) + return [...models].sort((a, b) => { + const la = splitModeSuffix(a.id)?.level + const lb = splitModeSuffix(b.id)?.level + const ia = la !== undefined ? (levelOrder.get(la) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER + const ib = lb !== undefined ? (levelOrder.get(lb) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER + return ia - ib || a.id.localeCompare(b.id) + }) +} + export interface ModeModelSeed { id: string name?: string @@ -138,6 +157,65 @@ export function groupModeFamilies( return result } +export interface ModeModelEntry { + id: string + name?: string + apiModelId?: string + contextWindow?: number + supportsVision?: boolean + modes?: Array<{ level: string; apiModelId: string; name?: string }> + reasoningEfforts?: string[] + source?: 'backend' | 'user' | 'default' +} + +/** + * Automatically collapse a list of models containing suffixed mode variants + * (-low, -medium, -high) into merged models with `modes` and `reasoningEfforts`. + */ +export function collapseModeFamilies(models: T[]): T[] { + const unmerged = models.filter((m) => !m.modes?.length) + const families = groupModeFamilies(unmerged) + if (families.length === 0) return models + + let result = [...models] + for (const family of families) { + const baseModel = result.find((m) => m.id === family.baseId) + const members = result.filter((m) => family.members.some((mem) => mem.id === m.id)) + if (members.length < 2) continue + + const sorted = sortByModeLevel(members) + + // The merged model is named after the un-suffixed base model when the + // catalog exposes one; otherwise the base id is all we can derive a display + // name from (member names carry the mode suffix and must not leak into the + // merged entry). Providers that care about the display name advertise the + // clean base name themselves. + const baseName = baseModel?.name ?? family.baseId.split('/').pop()?.replace(/-/g, ' ') ?? family.baseId + + const merged = { + ...sorted[0], + ...(baseModel ?? {}), + id: family.baseId, + name: baseName, + apiModelId: baseModel?.apiModelId ?? (baseModel ? family.baseId : undefined), + contextWindow: baseModel?.contextWindow ?? sorted[0]?.contextWindow ?? UNKNOWN_CONTEXT_WINDOW, + supportsVision: baseModel?.supportsVision ?? sorted.some((m) => m.supportsVision), + reasoningEfforts: sorted.map((m) => splitModeSuffix(m.id)?.level).filter((l): l is string => Boolean(l)), + modes: sorted.map((m) => ({ + level: splitModeSuffix(m.id)!.level, + apiModelId: m.apiModelId ?? m.id, + ...(m.name !== undefined ? { name: m.name.split('/').pop() ?? m.name } : {}), + })), + source: baseModel?.source ?? sorted[0]?.source ?? ('backend' as const), + } as T + + const removedIds = new Set(members.map((m) => m.id)) + result = [merged, ...result.filter((m) => !removedIds.has(m.id) && m.id !== family.baseId)] + } + + return result +} + /** * Resolve the concrete provider model ID to send for a model given a resolved * reasoning effort, and whether the effort should be forwarded as a diff --git a/src/shared/types.ts b/src/shared/types.ts index f64005f45..b60847031 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -92,7 +92,7 @@ export interface WorkflowExecution { updatedAt: number } -export type DangerLevel = 'normal' | 'dangerous' +export type DangerLevel = 'normal' | 'dangerous' | (string & {}) export interface Session { id: string diff --git a/tmp/test.png b/tmp/test.png deleted file mode 100644 index 36bd8c681..000000000 Binary files a/tmp/test.png and /dev/null differ diff --git a/web/src/components/HomePage.test.tsx b/web/src/components/HomePage.test.tsx index 50f597e35..ed35b75f3 100644 --- a/web/src/components/HomePage.test.tsx +++ b/web/src/components/HomePage.test.tsx @@ -24,9 +24,17 @@ vi.mock('../lib/api', () => ({ })) import { authFetch } from '../lib/api' -import { summariesResource } from '../lib/resources' +import { summariesResource, SETTINGS_KEYS } from '../lib/resources' import { clearCache } from '../lib/resourceCache' +const { mockSettings } = vi.hoisted(() => ({ + mockSettings: {} as Record, +})) + +vi.mock('../hooks/useSetting', () => ({ + useSetting: (key: string, fallback = '') => ({ value: mockSettings[key] ?? fallback, loading: false }), +})) + const { listHomeSessionsMock, listSessionsMock, ensureFullSessionListMock, navigateMock } = vi.hoisted(() => ({ listHomeSessionsMock: vi.fn(), listSessionsMock: vi.fn(), @@ -138,6 +146,7 @@ afterEach(() => { beforeEach(() => { sessionStore.sessions = [] sessionStore.sessionsWithPendingConfirmations = [] + Object.keys(mockSettings).forEach((k) => delete mockSettings[k]) document.body.innerHTML = '' navigateMock.mockClear() listHomeSessionsMock.mockClear() @@ -718,4 +727,27 @@ describe('HomePage', () => { expect(deleteProjectMock).toHaveBeenCalledWith('p1') }) }) + + it('renders recent sessions above projects by default', async () => { + sessionStore.sessions = [makeSession('s1', { projectId: 'p1' })] + const { HomePage } = await import('./HomePage') + const container = render() + + const sections = Array.from(container.querySelectorAll('[aria-label="Recent sessions"], [aria-label="Projects"]')) + expect(sections.length).toBe(2) + expect(sections[0]?.getAttribute('aria-label')).toBe('Recent sessions') + expect(sections[1]?.getAttribute('aria-label')).toBe('Projects') + }) + + it('renders projects above recent sessions when DISPLAY_SHOW_PROJECTS_ABOVE_SESSIONS is true', async () => { + mockSettings[SETTINGS_KEYS.DISPLAY_SHOW_PROJECTS_ABOVE_SESSIONS] = 'true' + sessionStore.sessions = [makeSession('s1', { projectId: 'p1' })] + const { HomePage } = await import('./HomePage') + const container = render() + + const sections = Array.from(container.querySelectorAll('[aria-label="Recent sessions"], [aria-label="Projects"]')) + expect(sections.length).toBe(2) + expect(sections[0]?.getAttribute('aria-label')).toBe('Projects') + expect(sections[1]?.getAttribute('aria-label')).toBe('Recent sessions') + }) }) diff --git a/web/src/components/HomePage.tsx b/web/src/components/HomePage.tsx index 7cf414ddd..ee1242b2a 100644 --- a/web/src/components/HomePage.tsx +++ b/web/src/components/HomePage.tsx @@ -5,8 +5,9 @@ import { useSessionStore } from '../stores/session' import { useProjectStore } from '../stores/project' import { useProjects } from '../hooks/useProjects' import { useResource } from '../hooks/useResource' +import { useSetting } from '../hooks/useSetting' import { useT } from '../hooks/useT' -import { summariesResource } from '../lib/resources' +import { SETTINGS_KEYS, summariesResource } from '../lib/resources' import { Button } from './shared/Button' import { OpenProjectModal } from './CreateSessionModal' import { DeleteProjectConfirmationModal } from './DeleteProjectConfirmationModal' @@ -154,6 +155,8 @@ export function HomePage() { const sessions = useSessionStore((state) => state.searchSessions ?? state.sessions) const hasFullCorpus = useSessionStore((state) => state.searchSessions !== null) const sessionsWithPendingConfirmations = useSessionStore((state) => state.sessionsWithPendingConfirmations) + const showProjectsAboveSessions = + useSetting(SETTINGS_KEYS.DISPLAY_SHOW_PROJECTS_ABOVE_SESSIONS, 'false').value === 'true' const { projects, loading } = useProjects() const listHomeSessions = useSessionStore((state) => state.listHomeSessions) const ensureFullSessionList = useSessionStore((state) => state.ensureFullSessionList) @@ -285,6 +288,169 @@ export function HomePage() { const isSearching = debouncedQuery.length > 0 const hasNoResults = isSearching && matchCount === 0 + const recentSessionsSection = ( +
+

+ {t({ en: 'Recent sessions', fr: 'Sessions récentes' })} +

+ {hasNoResults ? ( +
+ +

+ {t({ en: 'No sessions matching', fr: 'Aucune session correspondant à' })}{' '} + “{debouncedQuery}” +

+

+ {t({ + en: 'Try a different keyword or clear the search', + fr: 'Essayez un autre mot-clé ou effacez la recherche', + })} +

+
+ ) : visibleSessions.length > 0 ? ( +
+ {visibleSessions.map((session) => { + const project = projectById.get(session.projectId) + const displayTitle = session.title ?? session.id.slice(0, 8) + const matchType = matchTypes?.get(session.id) + const waiting = sessionsWithPendingConfirmations.includes(session.id) + const rowClass = + 'flex items-center gap-3 px-3 md:px-4 py-2.5 transition-colors' + + (project ? ' hover:bg-bg-tertiary/50 cursor-pointer' : ' cursor-default') + const rowBody = ( + <> + + {isSearching && matchType === 'title' ? highlightMatches(displayTitle, debouncedQuery) : displayTitle} + + {isSearching && matchType && matchType !== 'title' && ( + + + {matchType === 'prompts' + ? t({ en: 'prompts', fr: 'invites' }) + : t({ en: 'project', fr: 'projet' })} + + {matchType === 'prompts' && promptSnippets?.get(session.id) && ( + + {highlightMatches(promptSnippets.get(session.id)!, debouncedQuery)} + + )} + + )} + {formatRelativeDate(session.updatedAt)} + + {t({ en: '{{count}} msgs', fr: '{{count}} msg' }, { count: session.messageCount })} + + + ) + const statusDot = + return project ? ( +
{ + if (e.button !== 0 || e.defaultPrevented) return + if ((e.target as HTMLElement).closest('[data-testid="session-dropdown-menu"]')) return + navigate(`/p/${project.id}/s/${session.id}`) + }} + > + {statusDot} + setTasksProjectId(project.id)} + /> + + {rowBody} + +
+ ) : ( +
+ {statusDot} + + {session.projectId.slice(0, 10)} + +
{rowBody}
+
+ ) + })} +
+ ) : ( +
+ {t({ + en: 'No sessions yet. Start one from a project below.', + fr: 'Aucune session pour le moment. Commencez-en une depuis un projet ci-dessous.', + })} +
+ )} +
+ ) + + const projectsSection = ( +
+

+ {t({ en: 'Projects', fr: 'Projets' })} +

+ {sortedProjects.length > 0 ? ( +
+ {sortedProjects.map((project) => ( +
+
+ + {project.isStarred ? ( + + ) : ( + + )} + {project.name} + +
+ + + {t({ en: '+ New Session', fr: '+ Nouvelle session' })} + +
+ +
+
+ ))} +
+ ) : ( + !loading && ( +
+ {t({ + en: 'No projects yet. Open a project to get started.', + fr: 'Aucun projet pour le moment. Ouvrez un projet pour commencer.', + })} +
+ ) + )} +
+ ) + return (
@@ -351,166 +517,17 @@ export function HomePage() { )}
-
-

- {t({ en: 'Recent sessions', fr: 'Sessions récentes' })} -

- {hasNoResults ? ( -
- -

- {t({ en: 'No sessions matching', fr: 'Aucune session correspondant à' })}{' '} - “{debouncedQuery}” -

-

- {t({ - en: 'Try a different keyword or clear the search', - fr: 'Essayez un autre mot-clé ou effacez la recherche', - })} -

-
- ) : visibleSessions.length > 0 ? ( -
- {visibleSessions.map((session) => { - const project = projectById.get(session.projectId) - const displayTitle = session.title ?? session.id.slice(0, 8) - const matchType = matchTypes?.get(session.id) - const waiting = sessionsWithPendingConfirmations.includes(session.id) - const rowClass = - 'flex items-center gap-3 px-3 md:px-4 py-2.5 transition-colors' + - (project ? ' hover:bg-bg-tertiary/50 cursor-pointer' : ' cursor-default') - const rowBody = ( - <> - - {isSearching && matchType === 'title' - ? highlightMatches(displayTitle, debouncedQuery) - : displayTitle} - - {isSearching && matchType && matchType !== 'title' && ( - - - {matchType === 'prompts' - ? t({ en: 'prompts', fr: 'invites' }) - : t({ en: 'project', fr: 'projet' })} - - {matchType === 'prompts' && promptSnippets?.get(session.id) && ( - - {highlightMatches(promptSnippets.get(session.id)!, debouncedQuery)} - - )} - - )} - {formatRelativeDate(session.updatedAt)} - - {t({ en: '{{count}} msgs', fr: '{{count}} msg' }, { count: session.messageCount })} - - - ) - const statusDot = - return project ? ( -
{ - if (e.button !== 0 || e.defaultPrevented) return - if ((e.target as HTMLElement).closest('[data-testid="session-dropdown-menu"]')) return - navigate(`/p/${project.id}/s/${session.id}`) - }} - > - {statusDot} - setTasksProjectId(project.id)} - /> - - {rowBody} - -
- ) : ( -
- {statusDot} - - {session.projectId.slice(0, 10)} - -
{rowBody}
-
- ) - })} -
- ) : ( -
- {t({ - en: 'No sessions yet. Start one from a project below.', - fr: 'Aucune session pour le moment. Commencez-en une depuis un projet ci-dessous.', - })} -
- )} -
- -
-

- {t({ en: 'Projects', fr: 'Projets' })} -

- {sortedProjects.length > 0 ? ( -
- {sortedProjects.map((project) => ( -
-
- - {project.isStarred ? ( - - ) : ( - - )} - {project.name} - -
- - - {t({ en: '+ New Session', fr: '+ Nouvelle session' })} - -
- -
-
- ))} -
- ) : ( - !loading && ( -
- {t({ - en: 'No projects yet. Open a project to get started.', - fr: 'Aucun projet pour le moment. Ouvrez un projet pour commencer.', - })} -
- ) - )} -
+ {showProjectsAboveSessions ? ( + <> + {projectsSection} + {recentSessionsSection} + + ) : ( + <> + {recentSessionsSection} + {projectsSection} + + )} {loading && (
diff --git a/web/src/components/QuickActionModal.tsx b/web/src/components/QuickActionModal.tsx index 33ad27d36..3171cb7bd 100644 --- a/web/src/components/QuickActionModal.tsx +++ b/web/src/components/QuickActionModal.tsx @@ -16,6 +16,8 @@ import { useSessionScope, useScopedPaneState } from '../stores/session/session-s import { dedupById, fuzzyMatch, handleModalNavigation } from '../lib/modal-utils' import type { WorkflowScope } from '@shared/types.js' import { useResetSearchOnOpen } from '../hooks/useResetSearchOnOpen' +import { usePlugins } from '../hooks/usePlugins' +import { useLocalizedString } from '../hooks/useLocalizedString' interface QuickActionModalProps { isOpen: boolean @@ -50,6 +52,9 @@ export function QuickActionModal({ isAutoScrollActive, }: QuickActionModalProps) { const t = useT() + const loc = useLocalizedString() + const { contributions } = usePlugins() + const pluginDangerLevels = contributions.dangerLevels ?? [] const [, navigate] = useLocation() const sessionId = useSessionScope() const currentMode = useScopedPaneState( @@ -167,14 +172,22 @@ export function QuickActionModal({ prefix: t({ en: 'Workflow > Run', fr: 'Workflow > Exécuter' }), action: () => onSelectWorkflow(w.id, w.scope), })), - ...(['normal', 'dangerous'] as const) + ...(['normal', ...pluginDangerLevels.map((dl) => dl.id), 'dangerous'] as const) .filter((m) => m !== currentDangerLevel) - .map((m) => ({ - id: m, - name: m === 'dangerous' ? t({ en: 'Dangerous', fr: 'Dangereux' }) : t({ en: 'Normal', fr: 'Normal' }), - prefix: t({ en: 'Mode > Switch to', fr: 'Mode > Passer à' }), - action: () => sessionId && switchDangerLevel(sessionId, m), - })), + .map((m) => { + const customDl = pluginDangerLevels.find((dl) => dl.id === m) + const name = customDl + ? loc(customDl.label) + : m === 'dangerous' + ? t({ en: 'Dangerous', fr: 'Dangereux' }) + : t({ en: 'Normal', fr: 'Normal' }) + return { + id: m, + name, + prefix: t({ en: 'Mode > Switch to', fr: 'Mode > Passer à' }), + action: () => sessionId && switchDangerLevel(sessionId, m), + } + }), ] const filteredItems = items.filter((item) => fuzzyMatch(`${item.prefix} ${item.name}`, search)) diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 6f5e8fe9b..09e733742 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -281,7 +281,10 @@ export function Header({ onMenuClick, onCriteriaToggle }: HeaderProps) { className="hidden md:flex items-center gap-2" context={{ ...(project?.id ? { projectId: project.id } : {}), + ...(project?.name ? { projectName: project.name } : {}), + ...(project?.workdir ? { workdir: project.workdir } : {}), ...(session?.id ? { sessionId: session.id } : {}), + ...(session?.workdir ? { workdir: session.workdir } : {}), }} > {isSplit && ( diff --git a/web/src/components/notifications/NotificationToasts.tsx b/web/src/components/notifications/NotificationToasts.tsx index 1cb03b148..38f884f1f 100644 --- a/web/src/components/notifications/NotificationToasts.tsx +++ b/web/src/components/notifications/NotificationToasts.tsx @@ -32,7 +32,7 @@ export function NotificationToasts() { if (toasts.length === 0) return null return ( -
+
{toasts.map(({ notification }) => (
{ expect(screen.getByText('Build terminé')).toBeDefined() }) + it('stays in the foreground above modals and menus', () => { + usePluginToastStore.getState().push(NOTIFICATION) + render() + const container = screen.getByRole('status') + const zIndex = Number(/z-\[(\d+)\]/.exec(container.className)?.[1] ?? 0) + expect(zIndex).toBeGreaterThan(100) + }) + it('renders notification actions and dispatches their activation', async () => { invokePluginRpc.mockResolvedValue('ok') usePluginToastStore.getState().push(NOTIFICATION_WITH_ACTION) diff --git a/web/src/components/plan/ChatInput.tsx b/web/src/components/plan/ChatInput.tsx index ad9613566..b95bd2182 100644 --- a/web/src/components/plan/ChatInput.tsx +++ b/web/src/components/plan/ChatInput.tsx @@ -34,6 +34,7 @@ import { ProviderSelector } from '../settings/ProviderSelector' import { McpSelector } from './McpSelector' import { PluginSlot } from '../plugins/PluginSlot' import { PluginZone } from '../plugins/PluginZone' +import { useCurrentProject } from '../../hooks/useCurrentProject' import { SETTINGS_KEYS } from '../../lib/resources' import { useSetting } from '../../hooks/useSetting' import { @@ -122,6 +123,7 @@ export function ChatInput({ const slashAutocompleteRef = useRef(null) const isRunning = useIsRunning(sessionId) + const currentProject = useCurrentProject() const perSessionMcpEnabled = useSetting(SETTINGS_KEYS.FEATURES_PER_SESSION_MCP, 'false').value === 'true' const fullscreenComposer = useSetting(SETTINGS_KEYS.DISPLAY_MOBILE_FULLSCREEN_COMPOSER, 'false').value === 'true' const stopGeneration = useSessionStore((state) => state.stopGeneration) @@ -695,6 +697,20 @@ export function ChatInput({
+ {(() => { + const pluginCtx = { + ...(sessionId ? { sessionId } : {}), + ...(workdir ? { workdir } : {}), + ...(currentProject?.id ? { projectId: currentProject.id } : {}), + ...(currentProject?.name ? { projectName: currentProject.name } : {}), + } + return ( + <> + + + + ) + })()} state.switchDangerLevel) + const displayModeSetting = useSetting(SETTINGS_KEYS.DISPLAY_DANGER_LEVEL_DISPLAY_MODE, 'default') + const autoListSetting = useSetting(SETTINGS_KEYS.DISPLAY_DANGER_LEVEL_AUTO_LIST, 'false') + const autoListThresholdSetting = useSetting(SETTINGS_KEYS.DISPLAY_DANGER_LEVEL_AUTO_LIST_THRESHOLD, '3') + if (!sessionId) return null + const totalDangerLevels = 2 + pluginDangerLevels.length + const threshold = parseInt(autoListThresholdSetting.value || '3', 10) || 3 + const isAutoList = autoListSetting.value === 'true' && totalDangerLevels > threshold + const isListMode = displayModeSetting.value === 'list' || isAutoList + + if (isListMode) { + return ( +
+ +
+ ) + } + return (
+ {pluginDangerLevels.map((dl) => ( + + ))}
+ )} diff --git a/web/src/components/plugins/DeclarativeRenderer.tsx b/web/src/components/plugins/DeclarativeRenderer.tsx index 0c9b94c01..846ea8066 100644 --- a/web/src/components/plugins/DeclarativeRenderer.tsx +++ b/web/src/components/plugins/DeclarativeRenderer.tsx @@ -1,7 +1,9 @@ +import { useState, useEffect, useRef, type ChangeEvent, type FocusEvent } from 'react' import { useLocalizedString } from '../../hooks/useLocalizedString' import { activatePluginAction, badgeToneClasses, pluginIcon, type PluginActionContext } from './plugin-ui-utils' import type { DeclarativeNode, PluginBadgeTone } from '@shared/plugin.js' import { ChevronDownIcon } from '../shared/icons' +import { Toggle } from '../shared/Toggle' const PROGRESS_COLORS: Record = { neutral: 'bg-text-muted', @@ -49,9 +51,11 @@ const CALLOUT_CLASSES: Record = { danger: 'bg-accent-error/10 border-accent-error/30 text-text-primary', } -export function interpolate(text: string, values?: Record): string { - if (!values) return text - return text.replace(/\{\{(\w+)\}\}/g, (match, key: string) => (key in values ? String(values[key]) : match)) +export function interpolate(text: unknown, values?: Record): string { + if (text === null || text === undefined) return '' + const str = String(text) + if (!values) return str + return str.replace(/\{\{(\w+)\}\}/g, (match, key: string) => (key in values ? String(values[key]) : match)) } export interface DeclarativeRendererProps { @@ -60,6 +64,235 @@ export interface DeclarativeRendererProps { context?: PluginActionContext & { pluginId?: string } } +function parseCssColor(color: string): { bg: string; text: string; border: string } { + const trimmed = color.trim() + if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(trimmed)) { + const hex = + trimmed.length === 4 ? `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}` : trimmed + return { + bg: `${hex}1f`, + text: hex, + border: `${hex}4d`, + } + } + return { + bg: `color-mix(in srgb, ${trimmed} 12%, transparent)`, + text: trimmed, + border: `color-mix(in srgb, ${trimmed} 30%, transparent)`, + } +} + +function DeclarativeTextField({ + node, + isTextarea, + values = {}, + context = {}, +}: { + node: Extract + isTextarea?: boolean + values?: Record + context?: PluginActionContext & { pluginId?: string } +}) { + const localize = useLocalizedString() + const externalVal = interpolate(node.defaultValue ?? '', values) + const [localVal, setLocalVal] = useState(externalVal) + const isFocusedRef = useRef(false) + const pendingExternalRef = useRef(null) + + useEffect(() => { + if (isFocusedRef.current) { + // Never fight the user's typing: remember the incoming value and apply it + // on blur instead of dropping it. + pendingExternalRef.current = externalVal + return + } + pendingExternalRef.current = null + setLocalVal(externalVal) + }, [externalVal]) + + const releaseFocus = (typedValue: string) => { + isFocusedRef.current = false + const pending = pendingExternalRef.current + pendingExternalRef.current = null + if (pending !== null && pending !== typedValue) { + setLocalVal(pending) + } + } + + const triggerAction = (action: typeof node.onChange, value: string) => { + if (action) { + void activatePluginAction(context.pluginId, action, { ...context, fieldId: node.id, value }) + } + } + + const handleFocus = () => { + isFocusedRef.current = true + } + + const handleChange = (e: ChangeEvent) => { + const val = e.target.value + setLocalVal(val) + triggerAction(node.onChange, val) + } + + const handleBlur = (e: FocusEvent) => { + releaseFocus(e.target.value) + triggerAction(node.onBlur, e.target.value) + } + + const placeholder = node.placeholder ? localize(node.placeholder) : undefined + const disabledClass = node.disabled ? 'opacity-60 cursor-not-allowed bg-bg-secondary/80 select-none' : '' + + const lineCount = localVal ? localVal.split('\n').length : 1 + const computedRows = Math.min(Math.max(node.rows ?? 2, lineCount), 15) + + return ( +
+ {node.label && ( + + )} + {isTextarea ? ( +