From d87c11fc81eb292f28b1045a0e4d738ecfeb2c3a Mon Sep 17 00:00:00 2001 From: JamesDAdams Date: Thu, 24 Sep 2026 17:03:20 +0200 Subject: [PATCH 01/14] feat(plugins): enhance plugin system with transforms, updates, icons and dynamic controls --- docs/PLUGIN-ARCHITECTURE.md | 30 +- docs/PLUGINS.md | 38 ++- plugins-registry.json | 72 ++++- src/cli/config.ts | 4 +- src/plugin/index.ts | 31 ++ src/server/chat/agent-loop.test.ts | 121 +++++++- src/server/chat/agent-loop.ts | 15 +- src/server/plugins/host.test.ts | 83 ++++++ src/server/plugins/host.ts | 61 +++- src/server/plugins/loader.ts | 50 ++++ src/server/plugins/message-transforms.test.ts | 211 ++++++++++++++ src/server/plugins/message-transforms.ts | 113 ++++++++ src/server/plugins/registry.ts | 14 + src/server/plugins/settings.ts | 3 + src/server/plugins/update-checker.test.ts | 142 ++++++++++ src/server/plugins/update-checker.ts | 266 ++++++++++++++++++ src/server/routes/plugins.test.ts | 23 ++ src/server/routes/plugins.ts | 127 +++++++++ src/shared/plugin.ts | 11 +- web/src/components/plan/StatsModal.tsx | 1 + .../components/plugins/PluginMenu.test.tsx | 2 +- .../components/plugins/PluginPanelHost.tsx | 7 +- .../plugins/PluginSettingsForm.test.tsx | 58 +++- .../components/plugins/PluginSettingsForm.tsx | 88 +++++- web/src/components/plugins/PluginZone.tsx | 28 +- web/src/components/plugins/plugin-ui.test.tsx | 60 ++++ .../settings/NotificationSettings.test.tsx | 77 +++++ .../settings/NotificationSettings.tsx | 72 +++-- .../settings/tabs/PluginsTab.test.tsx | 25 +- .../components/settings/tabs/PluginsTab.tsx | 76 +++-- .../components/shared/SelfContainedModal.tsx | 4 +- web/src/lib/plugin-actions.ts | 6 + web/src/lib/sound.test.ts | 2 + web/src/stores/notifications.test.ts | 2 + web/src/stores/notifications.ts | 11 + 35 files changed, 1846 insertions(+), 88 deletions(-) create mode 100644 src/server/plugins/message-transforms.test.ts create mode 100644 src/server/plugins/message-transforms.ts create mode 100644 src/server/plugins/update-checker.test.ts create mode 100644 src/server/plugins/update-checker.ts create mode 100644 web/src/components/settings/NotificationSettings.test.tsx 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..98de439f8 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 @@ -456,6 +456,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..16eb07447 100644 --- a/plugins-registry.json +++ b/plugins-registry.json @@ -3,36 +3,96 @@ "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": "" } ] 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..a2a2804ca 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, @@ -70,12 +71,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 +89,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 +280,32 @@ export interface PluginRegistry { ): void registerRpc(method: string, handler: PluginRpcHandler): void registerAsset(relativePath: string): void + registerMessageTransform(transform: PluginMessageTransform): void +} + +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/plugins/host.test.ts b/src/server/plugins/host.test.ts index c86369d7c..e05b69d5d 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') @@ -571,4 +586,72 @@ 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('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..91f225a96 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,20 @@ 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 { + buildIfNeeded, + installPluginFromGithub, + installPluginFromNpm, + installPluginFromPath, + removeNpmArtifacts, +} from './install.js' import { readPluginSettings, readPluginSettingsView, writePluginSettings } from './settings.js' import { setPluginModelMetadataProviders } from './model-metadata.js' +import { setPluginMessageTransforms } from './message-transforms.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 +71,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 +84,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 +117,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 +138,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 +290,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 +378,7 @@ export class PluginHost { this.registry.getOwnedCommands().map((entry) => toCommandDefinition(entry.command, entry.pluginId)), ) setPluginModelMetadataProviders(this.registry.getModelMetadataProviders()) + setPluginMessageTransforms(this.registry.getMessageTransforms()) void this.refreshSkillSources() } 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..3e8e569ea 100644 --- a/src/server/plugins/registry.ts +++ b/src/server/plugins/registry.ts @@ -11,6 +11,7 @@ import type { PluginContext, PluginHookEvent, PluginHookHandler, + PluginMessageTransform, PluginModelMetadataProvider, PluginNotificationRequest, PluginRegistry as PluginRegistryContract, @@ -53,6 +54,7 @@ type Kind = | 'uiComponent' | 'uiOverride' | 'asset' + | 'messageTransform' interface Owned { pluginId: string @@ -186,6 +188,10 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon this.assets.set(pluginId, set) } + registerMessageTransform(transform: PluginMessageTransform): void { + this.register('messageTransform', transform.id, transform) + } + notify(request: PluginNotificationRequest): void { this.context.notify(request) } @@ -231,6 +237,13 @@ 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, + })) + } + getSettingsSchema(pluginId: string): PluginSettingsSchema | undefined { return this.get('settings', pluginId) } @@ -307,6 +320,7 @@ export class PluginRegistry implements ProviderPluginRegistry, PluginRegistryCon settingsTabs: count('settingsTab'), uiComponents: count('uiComponent'), uiOverrides: count('uiOverride'), + messageTransforms: count('messageTransform'), } } diff --git a/src/server/plugins/settings.ts b/src/server/plugins/settings.ts index d9efde4a6..ce8606803 100644 --- a/src/server/plugins/settings.ts +++ b/src/server/plugins/settings.ts @@ -49,6 +49,7 @@ export function readPluginSettings( const stored = getAllSettings() const values: PluginSettingsValues = {} for (const field of schema.fields) { + if (field.type === 'button' || field.type === 'status') continue const raw = stored[pluginSettingKey(pluginId, field.scope ?? scope, projectId, field.key)] const value = coerce(field, raw) if (value !== undefined) values[field.key] = value @@ -66,6 +67,7 @@ export function readPluginSettingsView( const values: PluginSettingsValues = {} const secretsSet: string[] = [] for (const field of schema.fields) { + if (field.type === 'button' || field.type === 'status') continue const raw = stored[pluginSettingKey(pluginId, field.scope ?? scope, projectId, field.key)] if (isSecret(field)) { if (raw !== undefined && raw !== '') secretsSet.push(field.key) @@ -88,6 +90,7 @@ export function validatePluginSettings( ): string[] { const errors: string[] = [] for (const field of schema.fields) { + if (field.type === 'button' || field.type === 'status') continue if (!(field.key in values)) { if (field.required) { if (isSecret(field) && isExistingSecret && isExistingSecret(field.key)) { 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/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/shared/plugin.ts b/src/shared/plugin.ts index da07c5f69..d643af888 100644 --- a/src/shared/plugin.ts +++ b/src/shared/plugin.ts @@ -13,6 +13,7 @@ export type PluginCapability = | 'workflows' | 'rpc' | 'assets' + | 'transforms' export type PluginSlotName = | 'header.actions' @@ -50,6 +51,8 @@ export type PluginZoneId = | 'settings.sidebar' | 'settings.content' | 'modal.footer' + | 'stats.modal' + | 'stats.modal.summary' | (string & {}) export type PluginBadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' @@ -219,7 +222,7 @@ 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 @@ -269,9 +272,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' label: LocalizedString buttonLabel?: LocalizedString + buttonVariant?: 'default' | 'primary' | 'secondary' | 'danger' | 'ghost' rpcMethod?: string description?: LocalizedString default?: PluginSettingValue @@ -347,12 +351,14 @@ export interface PluginContributionSummary { settingsTabs: number uiComponents: number uiOverrides: number + messageTransforms: number } export interface PluginInfo { id: string displayName: string description?: string + author?: string icon?: string logo?: string version: string @@ -412,4 +418,5 @@ export const EMPTY_PLUGIN_CONTRIBUTIONS: PluginContributionSummary = { settingsTabs: 0, uiComponents: 0, uiOverrides: 0, + messageTransforms: 0, } diff --git a/web/src/components/plan/StatsModal.tsx b/web/src/components/plan/StatsModal.tsx index 4f255f42d..453ae119c 100644 --- a/web/src/components/plan/StatsModal.tsx +++ b/web/src/components/plan/StatsModal.tsx @@ -325,6 +325,7 @@ export function StatsModal({ isOpen, onClose, summary, sessionId }: StatsModalPr subValue="tok/s" /> + )} diff --git a/web/src/components/plugins/PluginMenu.test.tsx b/web/src/components/plugins/PluginMenu.test.tsx index 621918863..8c58b92a5 100644 --- a/web/src/components/plugins/PluginMenu.test.tsx +++ b/web/src/components/plugins/PluginMenu.test.tsx @@ -158,7 +158,7 @@ describe('PluginMenu', () => { expect(screen.getByText('Always')).toBeDefined() }) - it('lists a plugin with no visible actions', async () => { + it('lists enabled plugins even when having no actions', async () => { dataRef.current = { plugins: [DEMO_PLUGIN], contributions: { diff --git a/web/src/components/plugins/PluginPanelHost.tsx b/web/src/components/plugins/PluginPanelHost.tsx index 626295fbc..b2746f397 100644 --- a/web/src/components/plugins/PluginPanelHost.tsx +++ b/web/src/components/plugins/PluginPanelHost.tsx @@ -8,11 +8,13 @@ import { DeclarativeRenderer } from './DeclarativeRenderer' import type { PluginActionContext } from './plugin-ui-utils' import type { DeclarativeNode, PluginUiPanel } from '@shared/plugin.js' -const PANEL_SIZES: Record, 'sm' | 'md' | 'lg' | 'xl' | 'full'> = { +const PANEL_SIZES: Record, 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'> = { sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', + '2xl': '2xl', + '3xl': '3xl', full: 'full', } @@ -49,6 +51,9 @@ export function PluginPanelHost() { const iframeUrl = (() => { if (panel.kind !== 'iframe' || !panel.url) return undefined + if (panel.url.startsWith('http://') || panel.url.startsWith('https://')) { + return `/api/plugins/${encodeURIComponent(targetPluginId)}/proxy?url=${encodeURIComponent(panel.url)}` + } const params = new URLSearchParams() if (token) params.set('token', token) if (panelContext.sessionId) params.set('sessionId', panelContext.sessionId) diff --git a/web/src/components/plugins/PluginSettingsForm.test.tsx b/web/src/components/plugins/PluginSettingsForm.test.tsx index fecd97c1c..fe8ec19c8 100644 --- a/web/src/components/plugins/PluginSettingsForm.test.tsx +++ b/web/src/components/plugins/PluginSettingsForm.test.tsx @@ -27,9 +27,14 @@ vi.mock('../../hooks/useResource', () => ({ })) const savePluginSettings = vi.fn() +const invokePluginRpc = vi.fn() vi.mock('../../lib/plugin-actions', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, savePluginSettings: (...args: unknown[]) => savePluginSettings(...args) } + return { + ...actual, + savePluginSettings: (...args: unknown[]) => savePluginSettings(...args), + invokePluginRpc: (...args: unknown[]) => invokePluginRpc(...args), + } }) describe('PluginSettingsForm', () => { @@ -176,4 +181,55 @@ describe('PluginSettingsForm', () => { render() expect(screen.queryByLabelText('Applies to')).toBeNull() }) + + it('renders status field and refreshes after button action', async () => { + invokePluginRpc.mockResolvedValueOnce({ + running: true, + text: { en: 'Running (http://127.0.0.1:8787)', fr: 'Actif (http://127.0.0.1:8787)' }, + tone: 'success', + }) + + settingsRef.current = { + schema: { + fields: [ + { + key: 'daemonStatus', + type: 'status', + label: { en: 'Server Status', fr: 'Statut du serveur' }, + rpcMethod: 'getStatus', + }, + { + key: 'restartProxy', + type: 'button', + label: { en: 'Restart', fr: 'Relancer' }, + buttonVariant: 'secondary', + rpcMethod: 'restartProxy', + }, + ], + }, + values: {}, + secretsSet: [], + } + + render() + + await waitFor(() => { + expect(screen.getByText('Running (http://127.0.0.1:8787)')).toBeDefined() + }) + + invokePluginRpc.mockResolvedValueOnce({ success: true }) + invokePluginRpc.mockResolvedValueOnce({ + running: true, + text: { en: 'Running after restart', fr: 'Actif après redémarrage' }, + tone: 'success', + }) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Restart' })) + + await waitFor(() => { + expect(invokePluginRpc).toHaveBeenCalledWith('demo', 'restartProxy', {}) + expect(screen.getByText('Running after restart')).toBeDefined() + }) + }) }) diff --git a/web/src/components/plugins/PluginSettingsForm.tsx b/web/src/components/plugins/PluginSettingsForm.tsx index 7f89afdc5..429c5315c 100644 --- a/web/src/components/plugins/PluginSettingsForm.tsx +++ b/web/src/components/plugins/PluginSettingsForm.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useState } from 'react' +import { Fragment, useCallback, useEffect, useState } from 'react' import { useResource } from '../../hooks/useResource' import { useT } from '../../hooks/useT' import { useLocalizedString } from '../../hooks/useLocalizedString' @@ -6,13 +6,34 @@ import { pluginSettingsResource, providersResource } from '../../lib/resources' import { invokePluginRpc, savePluginSettings } from '../../lib/plugin-actions' import { Button } from '../shared/Button' import { Toggle } from '../shared/Toggle' -import type { PluginSettingsField, PluginSettingScope, PluginSettingValue } from '@shared/plugin.js' +import type { + LocalizedString, + PluginBadgeTone, + PluginSettingsField, + PluginSettingScope, + PluginSettingValue, +} from '@shared/plugin.js' type FormValues = Record +interface StatusFieldState { + loading?: boolean + running?: boolean + text?: string | LocalizedString + tone?: PluginBadgeTone +} + const FIELD_CLASS = 'w-full px-2.5 py-1.5 text-sm text-text-primary bg-bg-tertiary border border-border rounded' const MASKED_SECRET = '••••••••••••••••' +const STATUS_TONE_DOT: Record = { + success: 'bg-accent-success', + warning: 'bg-accent-warning', + danger: 'bg-accent-error', + info: 'bg-accent-primary', + neutral: 'bg-text-muted', +} + function isSecretField(field: PluginSettingsField): boolean { return field.secret === true || field.type === 'password' } @@ -77,6 +98,9 @@ function initialValue( values: Record, secretsSet: string[], ): FormValues[string] { + if (field.type === 'button' || field.type === 'status') { + return '' + } if (isSecretField(field) && secretsSet.includes(field.key)) { return MASKED_SECRET } @@ -101,15 +125,43 @@ export function PluginSettingsForm({ const [scope, setScope] = useState(initialScope) const { data } = useResource(pluginSettingsResource, pluginId, scope, projectId) const [values, setValues] = useState({}) + const [statusStates, setStatusStates] = useState>({}) const [error, setError] = useState(null) const [saved, setSaved] = useState(false) + const refreshStatuses = useCallback(async () => { + if (!data) return + const statusFields = data.schema.fields.filter((f) => f.type === 'status') + if (statusFields.length === 0) return + + for (const field of statusFields) { + try { + const res = (await invokePluginRpc(pluginId, field.rpcMethod ?? field.key, {})) as Record + setStatusStates((prev) => ({ + ...prev, + [field.key]: { + running: typeof res?.['running'] === 'boolean' ? res['running'] : undefined, + text: (res?.['text'] as string | LocalizedString | undefined) ?? (res?.['message'] as string | undefined), + tone: (res?.['tone'] as PluginBadgeTone | undefined) ?? (res?.['running'] ? 'success' : 'danger'), + loading: false, + }, + })) + } catch { + setStatusStates((prev) => ({ + ...prev, + [field.key]: { tone: 'danger', loading: false }, + })) + } + } + }, [data, pluginId]) + useEffect(() => { if (!data) return const next: FormValues = {} for (const field of data.schema.fields) next[field.key] = initialValue(field, data.values, data.secretsSet) setValues(next) - }, [data]) + void refreshStatuses() + }, [data, refreshStatuses]) if (!data) { return

{t({ en: 'Loading settings…', fr: 'Chargement des paramètres…' })}

@@ -120,7 +172,7 @@ export function PluginSettingsForm({ const saveValues = async (nextValues: FormValues) => { const payload: Record = {} for (const field of data.schema.fields) { - if (field.type === 'button') continue + if (field.type === 'button' || field.type === 'status') continue const value = nextValues[field.key] if (isSecretField(field) && (isMaskedValue(value) || value === '' || value === undefined)) continue if (field.type === 'number' && value === '') continue @@ -209,15 +261,41 @@ export function PluginSettingsForm({ void saveValues(next) }} /> + ) : field.type === 'status' ? ( +
+ + + {statusStates[field.key]?.loading + ? t({ en: 'Checking…', fr: 'Vérification…' }) + : statusStates[field.key]?.text + ? typeof statusStates[field.key]!.text === 'object' + ? localize(statusStates[field.key]!.text as LocalizedString) + : String(statusStates[field.key]!.text) + : statusStates[field.key]?.running + ? t({ en: 'Running', fr: 'Actif' }) + : t({ en: 'Stopped', fr: 'Arrêté' })} + +
) : field.type === 'button' ? (
) : null} - {registryEntry ? ( - - ) : null} + @@ -219,6 +237,24 @@ function InstalledPluginCard({ plugin }: { plugin: PluginInfo }) {
} + footer={ + parts.length > 0 || developer ? ( + <> + {parts.length > 0 ? ( + + {parts.map((part) => `${part.count} ${part.key}`).join(' · ')} + + ) : ( + + )} + {developer ? ( + + {t({ en: 'by {{author}}', fr: 'par {{author}}' }, { author: developer })} + + ) : null} + + ) : null + } > {showSettings && section ? (
@@ -245,6 +281,7 @@ function RegistryPluginCard({ plugin, installed }: { plugin: RegistryPlugin; ins const { refresh } = usePlugins() const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const developer = getDeveloperName(plugin.author, plugin.githubUrl) const install = async () => { setBusy(true) @@ -259,7 +296,7 @@ function RegistryPluginCard({ plugin, installed }: { plugin: RegistryPlugin; ins - + } + footer={ + developer ? ( + + {t({ en: 'by {{author}}', fr: 'par {{author}}' }, { author: developer })} + + ) : null + } /> ) } diff --git a/web/src/components/shared/SelfContainedModal.tsx b/web/src/components/shared/SelfContainedModal.tsx index a6ad0d1bd..6ec825fc9 100644 --- a/web/src/components/shared/SelfContainedModal.tsx +++ b/web/src/components/shared/SelfContainedModal.tsx @@ -8,7 +8,7 @@ interface ModalProps { label?: ReactNode title?: ReactNode headerRight?: ReactNode - size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' + size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full' minHeight?: string children: ReactNode footer?: ReactNode @@ -26,6 +26,8 @@ const sizeClasses = { md: 'max-w-md', lg: 'max-w-2xl', xl: 'max-w-4xl', + '2xl': 'max-w-6xl', + '3xl': 'max-w-7xl', full: 'max-w-[95vw] h-[90vh] flex-1', } diff --git a/web/src/lib/plugin-actions.ts b/web/src/lib/plugin-actions.ts index 38ae47e81..7cf259bda 100644 --- a/web/src/lib/plugin-actions.ts +++ b/web/src/lib/plugin-actions.ts @@ -35,6 +35,7 @@ export interface RegistryPlugin { displayName: string description: string githubUrl: string + author?: string } export async function fetchPluginRegistry(): Promise<{ plugins: RegistryPlugin[] }> { @@ -103,6 +104,11 @@ export async function uninstallPlugin(pluginId: string): Promise<{ ok: boolean; return okOrError(res) } +export async function reinstallPlugin(pluginId: string): Promise<{ ok: boolean; error?: string }> { + const res = await authFetch(`/api/plugins/${encodeURIComponent(pluginId)}/reinstall`, { method: 'POST' }) + return okOrError(res) +} + export interface PluginSettingsData { schema: PluginSettingsSchema values: PluginSettingsValues diff --git a/web/src/lib/sound.test.ts b/web/src/lib/sound.test.ts index a1b3f61e2..ba0449a40 100644 --- a/web/src/lib/sound.test.ts +++ b/web/src/lib/sound.test.ts @@ -43,6 +43,8 @@ describe('sound integration', () => { settings: { soundEnabled: true, browserNotificationEnabled: false, + pluginUpdateNotificationEnabled: true, + pluginUpdateCheckInterval: '1h', events: { complete: { soundEnabled: true, browserNotification: false, customSoundUrl: null }, waiting_for_user: { soundEnabled: true, browserNotification: false, customSoundUrl: null }, diff --git a/web/src/stores/notifications.test.ts b/web/src/stores/notifications.test.ts index 671db6f2b..f10b8375d 100644 --- a/web/src/stores/notifications.test.ts +++ b/web/src/stores/notifications.test.ts @@ -35,6 +35,8 @@ describe('notifications store', () => { const { settings } = useNotificationSettingsStore.getState() expect(settings.soundEnabled).toBe(true) expect(settings.browserNotificationEnabled).toBe(false) + expect(settings.pluginUpdateNotificationEnabled).toBe(true) + expect(settings.pluginUpdateCheckInterval).toBe('1h') expect(settings.events.complete.soundEnabled).toBe(true) expect(settings.events.complete.browserNotification).toBe(false) expect(settings.events.complete.customSoundUrl).toBeNull() diff --git a/web/src/stores/notifications.ts b/web/src/stores/notifications.ts index 6b67bafed..c06b4e21e 100644 --- a/web/src/stores/notifications.ts +++ b/web/src/stores/notifications.ts @@ -5,6 +5,8 @@ import { load as loadResource, snapshot, subscribe } from '../lib/resourceCache' // Sound event types export type SoundEvent = 'complete' | 'waiting_for_user' | 'phase_done' | 'phase_blocked' | 'new_message' +export type PluginUpdateInterval = '1h' | '6h' | '24h' | 'startup' + // Agent types that can have per-agent overrides export type AgentType = 'planner' | 'build' | 'sub-agent' @@ -23,6 +25,10 @@ export interface NotificationSettings { soundEnabled: boolean browserNotificationEnabled: boolean + // Plugin update notifications + pluginUpdateNotificationEnabled: boolean + pluginUpdateCheckInterval: PluginUpdateInterval + // Per-event config (global defaults) events: Record @@ -77,6 +83,8 @@ const DEFAULT_NEW_MESSAGE_CONFIG: EventNotificationConfig = { export const DEFAULT_SETTINGS: NotificationSettings = { soundEnabled: true, browserNotificationEnabled: false, + pluginUpdateNotificationEnabled: true, + pluginUpdateCheckInterval: '1h', events: { complete: { ...DEFAULT_EVENT_CONFIG }, waiting_for_user: { ...DEFAULT_EVENT_CONFIG }, @@ -202,6 +210,9 @@ function mergeWithDefaults(partial: Partial): Notification return { soundEnabled: partial.soundEnabled ?? DEFAULT_SETTINGS.soundEnabled, browserNotificationEnabled: partial.browserNotificationEnabled ?? DEFAULT_SETTINGS.browserNotificationEnabled, + pluginUpdateNotificationEnabled: + partial.pluginUpdateNotificationEnabled ?? DEFAULT_SETTINGS.pluginUpdateNotificationEnabled, + pluginUpdateCheckInterval: partial.pluginUpdateCheckInterval ?? DEFAULT_SETTINGS.pluginUpdateCheckInterval, events: { complete: { ...DEFAULT_EVENT_CONFIG, ...partial.events?.complete }, waiting_for_user: { ...DEFAULT_EVENT_CONFIG, ...partial.events?.waiting_for_user }, From 2328328faee0f7cf79b9f697f926b9afa177b7eb Mon Sep 17 00:00:00 2001 From: JamesDAdams Date: Fri, 25 Sep 2026 00:10:08 +0200 Subject: [PATCH 02/14] refactor(rtk): extract RTK to plugin, add rtk gain dashboard, and improve plugin settings UX --- src/server/db/settings.ts | 2 - src/server/index.ts | 19 ---- src/server/plugins/registry.ts | 15 +++- src/server/tools/shell.ts | 64 +------------- src/shared/plugin.ts | 1 + tmp/test.png | Bin 25880 -> 0 bytes .../components/plugins/PluginPanelHost.tsx | 24 ++++- .../plugins/PluginSettingsForm.test.tsx | 38 ++++++++ .../components/plugins/PluginSettingsForm.tsx | 18 +++- .../settings/tabs/ToolsTab.test.tsx | 82 +----------------- web/src/components/settings/tabs/ToolsTab.tsx | 68 --------------- web/src/lib/resources.ts | 1 - 12 files changed, 90 insertions(+), 242 deletions(-) delete mode 100644 tmp/test.png diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts index 43121b055..091bf2387 100644 --- a/src/server/db/settings.ts +++ b/src/server/db/settings.ts @@ -46,7 +46,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', @@ -102,7 +101,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..df028fb39 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1919,25 +1919,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') diff --git a/src/server/plugins/registry.ts b/src/server/plugins/registry.ts index 3e8e569ea..bfd6dc2c8 100644 --- a/src/server/plugins/registry.ts +++ b/src/server/plugins/registry.ts @@ -178,7 +178,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 { @@ -277,8 +278,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[] { @@ -293,7 +294,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 diff --git a/src/server/tools/shell.ts b/src/server/tools/shell.ts index 32e1a693b..ed685d966 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 hasBackgroundAmpersand(command: string): boolean { // Strip content inside quotes — & inside quotes is literal, not a background operator let processed = command.replace(/'[^']*'/g, ' ').replace(/"[^"]*"/g, ' ') @@ -177,10 +145,7 @@ 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 result = await executeCommand(finalCommand, workingDir, timeout, context.signal, context.onProgress) + const result = await executeCommand(execCommand, workingDir, timeout, context.signal, context.onProgress) let output = '' @@ -242,31 +207,6 @@ interface CommandResult { exitCode: number } -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/shared/plugin.ts b/src/shared/plugin.ts index d643af888..16d4cfa9d 100644 --- a/src/shared/plugin.ts +++ b/src/shared/plugin.ts @@ -287,6 +287,7 @@ export interface PluginSettingsField { parentKey?: string width?: 'full' | 'half' section?: LocalizedString + hideWhenInstalled?: boolean } export interface PluginSettingsSchema { diff --git a/tmp/test.png b/tmp/test.png deleted file mode 100644 index 36bd8c6816eb556d7a83c748a2768a0489806cfa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25880 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 AEdT%j diff --git a/web/src/components/plugins/PluginPanelHost.tsx b/web/src/components/plugins/PluginPanelHost.tsx index b2746f397..fc3387b96 100644 --- a/web/src/components/plugins/PluginPanelHost.tsx +++ b/web/src/components/plugins/PluginPanelHost.tsx @@ -1,10 +1,11 @@ -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { Modal } from '../shared/SelfContainedModal' import { usePlugins } from '../../hooks/usePlugins' import { useLocalizedString } from '../../hooks/useLocalizedString' import { usePluginUiStore } from '../../stores/pluginUi' import { getSessionToken } from '../../lib/api' import { DeclarativeRenderer } from './DeclarativeRenderer' +import { invokePluginRpc } from '../../lib/plugin-actions' import type { PluginActionContext } from './plugin-ui-utils' import type { DeclarativeNode, PluginUiPanel } from '@shared/plugin.js' @@ -38,9 +39,26 @@ export function PluginPanelHost() { [activePanel, contributions.panels], ) - if (!activePanel || !panel) return null + const targetPluginId = panel?.pluginId && panel.pluginId !== 'unknown' ? panel.pluginId : activePanel?.pluginId + + useEffect(() => { + if (!activePanel || !targetPluginId) return + const refresh = async () => { + try { + await invokePluginRpc(targetPluginId, 'refreshGainStats', {}) + } catch { + try { + await invokePluginRpc(targetPluginId, 'getStatus', {}) + } catch { + // ignore + } + } + } + void refresh() + }, [activePanel?.pluginId, activePanel?.panelId, targetPluginId]) + + if (!activePanel || !panel || !targetPluginId) return null - const targetPluginId = panel.pluginId && panel.pluginId !== 'unknown' ? panel.pluginId : activePanel.pluginId const panelContext = activePanel.context ?? {} const context: PluginActionContext & { pluginId: string } = { pluginId: targetPluginId, ...panelContext } const values: Record = {} diff --git a/web/src/components/plugins/PluginSettingsForm.test.tsx b/web/src/components/plugins/PluginSettingsForm.test.tsx index fe8ec19c8..ff089ab61 100644 --- a/web/src/components/plugins/PluginSettingsForm.test.tsx +++ b/web/src/components/plugins/PluginSettingsForm.test.tsx @@ -232,4 +232,42 @@ describe('PluginSettingsForm', () => { expect(screen.getByText('Running after restart')).toBeDefined() }) }) + + it('hides fields with hideWhenInstalled when status is installed', async () => { + invokePluginRpc.mockResolvedValueOnce({ + installed: true, + text: 'Installed (rtk 0.1.0)', + tone: 'success', + }) + + settingsRef.current = { + schema: { + fields: [ + { + key: 'daemonStatus', + type: 'status', + label: { en: 'CLI Status', fr: 'Statut du CLI' }, + rpcMethod: 'getStatus', + }, + { + key: 'installCli', + type: 'button', + label: { en: 'Install RTK CLI', fr: 'Installer le CLI RTK' }, + hideWhenInstalled: true, + rpcMethod: 'installCli', + }, + ], + }, + values: {}, + secretsSet: [], + } + + render() + + await waitFor(() => { + expect(screen.getByText('Installed (rtk 0.1.0)')).toBeDefined() + }) + + expect(screen.queryByRole('button', { name: 'Install RTK CLI' })).toBeNull() + }) }) diff --git a/web/src/components/plugins/PluginSettingsForm.tsx b/web/src/components/plugins/PluginSettingsForm.tsx index 429c5315c..86c2c495e 100644 --- a/web/src/components/plugins/PluginSettingsForm.tsx +++ b/web/src/components/plugins/PluginSettingsForm.tsx @@ -19,6 +19,7 @@ type FormValues = Record interface StatusFieldState { loading?: boolean running?: boolean + installed?: boolean text?: string | LocalizedString tone?: PluginBadgeTone } @@ -137,12 +138,20 @@ export function PluginSettingsForm({ for (const field of statusFields) { try { const res = (await invokePluginRpc(pluginId, field.rpcMethod ?? field.key, {})) as Record + const installed = res?.['installed'] === true || res?.['running'] === true setStatusStates((prev) => ({ ...prev, [field.key]: { + installed, running: typeof res?.['running'] === 'boolean' ? res['running'] : undefined, - text: (res?.['text'] as string | LocalizedString | undefined) ?? (res?.['message'] as string | undefined), - tone: (res?.['tone'] as PluginBadgeTone | undefined) ?? (res?.['running'] ? 'success' : 'danger'), + text: + (res?.['statusText'] as string | undefined) ?? + (res?.['text'] as string | LocalizedString | undefined) ?? + (res?.['message'] as string | undefined), + tone: + (res?.['statusTone'] as PluginBadgeTone | undefined) ?? + (res?.['tone'] as PluginBadgeTone | undefined) ?? + (installed ? 'success' : 'danger'), loading: false, }, })) @@ -219,6 +228,11 @@ export function PluginSettingsForm({ ) : null}
{data.schema.fields.map((field, index) => { + const isAnyInstalled = Object.values(statusStates).some((s) => s.installed === true) + if (field.hideWhenInstalled && isAnyInstalled) { + return null + } + const label = localize(field.label) const description = field.description ? localize(field.description) : undefined const value = values[field.key] diff --git a/web/src/components/settings/tabs/ToolsTab.test.tsx b/web/src/components/settings/tabs/ToolsTab.test.tsx index 6307c6b94..64b319819 100644 --- a/web/src/components/settings/tabs/ToolsTab.test.tsx +++ b/web/src/components/settings/tabs/ToolsTab.test.tsx @@ -130,7 +130,7 @@ describe('ToolsTab MCP server toggle isolation', () => { await screen.findByText('server-a') // The first MCP server toggle is inside the server-a row. Find all toggles - // in the MCP section (skip RTK and confirmation toggles before it). + // in the MCP section (skip confirmation toggles before it). const mcpSectionEl = screen.getByTestId('mcp-servers-heading').closest('div')!.parentElement! const toggles = mcpSectionEl.querySelectorAll('button[role="switch"]') expect(toggles.length).toBe(3) @@ -212,86 +212,6 @@ describe('ToolsTab MCP server toggle isolation', () => { }) }) -describe('ToolsTab RTK shell hint (Windows)', () => { - const HINT_PATTERN = /RTK only rewrites Unix-style commands/ - - const mockFetchWithShells = async (shells: { id: string; label: string; available: boolean }[]) => { - const { authFetch } = await import('../../../lib/api') - const mockFn = authFetch as ReturnType - mockFn.mockImplementation(async (url: string) => ({ - ok: true, - json: async () => { - if (url === '/api/tools/shells') return { shells } - if (url === '/api/tools/rtk-check') return { available: true } - return { servers: [] } - }, - })) - } - - const WINDOWS_SHELLS = [ - { id: 'cmd', label: 'cmd.exe', available: true }, - { id: 'powershell', label: 'PowerShell', available: true }, - { id: 'gitbash', label: 'Git Bash', available: true }, - ] - - beforeEach(() => { - vi.clearAllMocks() - }) - afterEach(() => { - cleanup() - delete mockSettings['tools.useRtk'] - delete mockSettings['tools.shell'] - delete mockSettings['search.engine'] - delete mockSettings['search.tavilyApiKey'] - delete mockSettings['search.searxngUrl'] - delete mockSettings['search.searxngApiKey'] - }) - - it('shows the hint when RTK is enabled with cmd.exe', async () => { - await mockFetchWithShells(WINDOWS_SHELLS) - mockSettings['tools.useRtk'] = 'true' - mockSettings['tools.shell'] = 'cmd' - render() - await screen.findByText('cmd.exe') - expect(screen.getByText(HINT_PATTERN)).toBeDefined() - }) - - it('shows the hint when RTK is enabled with PowerShell', async () => { - await mockFetchWithShells(WINDOWS_SHELLS) - mockSettings['tools.useRtk'] = 'true' - mockSettings['tools.shell'] = 'powershell' - render() - await screen.findByText('Git Bash') - expect(screen.getByText(HINT_PATTERN)).toBeDefined() - }) - - it('hides the hint when the selected shell is Git Bash', async () => { - await mockFetchWithShells(WINDOWS_SHELLS) - mockSettings['tools.useRtk'] = 'true' - mockSettings['tools.shell'] = 'gitbash' - render() - await screen.findByText('cmd.exe') - expect(screen.queryByText(HINT_PATTERN)).toBeNull() - }) - - it('hides the hint when RTK is disabled', async () => { - await mockFetchWithShells(WINDOWS_SHELLS) - mockSettings['tools.useRtk'] = 'false' - mockSettings['tools.shell'] = 'cmd' - render() - await screen.findByText('cmd.exe') - expect(screen.queryByText(HINT_PATTERN)).toBeNull() - }) - - it('hides the hint on non-Windows platforms (no shells)', async () => { - await mockFetchWithShells([]) - mockSettings['tools.useRtk'] = 'true' - render() - await screen.findByText('Enable RTK auto-rewrite') - expect(screen.queryByText(HINT_PATTERN)).toBeNull() - }) -}) - describe('ToolsTab Search Engine settings', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/src/components/settings/tabs/ToolsTab.tsx b/web/src/components/settings/tabs/ToolsTab.tsx index f68b45500..19a4a8b2b 100644 --- a/web/src/components/settings/tabs/ToolsTab.tsx +++ b/web/src/components/settings/tabs/ToolsTab.tsx @@ -334,7 +334,6 @@ export function ToolsTab() { const tavilyKeySetting = useSetting(SETTINGS_KEYS.SEARCH_TAVILY_API_KEY).value const searxngUrlSetting = useSetting(SETTINGS_KEYS.SEARCH_SEARXNG_URL).value const searxngKeySetting = useSetting(SETTINGS_KEYS.SEARCH_SEARXNG_API_KEY).value - const useRtkSetting = useSetting(SETTINGS_KEYS.TOOLS_USE_RTK).value const confirmWorkspaceSetting = useSetting(SETTINGS_KEYS.CONFIRM_ON_WORKSPACE_ACTIONS).value const shellSetting = useSetting(SETTINGS_KEYS.TOOLS_SHELL).value const perSessionMcpSetting = useSetting(SETTINGS_KEYS.FEATURES_PER_SESSION_MCP).value @@ -399,17 +398,6 @@ export function ToolsTab() { }) } - // ── RTK availability ── - const [rtkStatus, setRtkStatus] = useState<'checking' | 'available' | 'unavailable'>('checking') - - useEffect(() => { - // Authorized transient read: one-shot RTK availability probe on mount. - authFetch('/api/tools/rtk-check') - .then((r) => r.json()) - .then((data) => setRtkStatus(data.available ? 'available' : 'unavailable')) - .catch(() => setRtkStatus('unavailable')) - }, []) - // ── Shell selection (Windows only; empty list elsewhere) ── const [shells, setShells] = useState<{ id: string; label: string; available: boolean }[]>([]) @@ -828,62 +816,6 @@ export function ToolsTab() {
- {/* ── Token Optimization Section ── */} -
-

- {t({ en: 'Token Optimization', fr: 'Optimisation des jetons' })} -

-

- {t({ - en: 'Reduce token consumption by filtering command output through RTK. See the', - fr: 'Réduisez la consommation de jetons en filtrant la sortie des commandes via RTK. Voir le', - })}{' '} - - README - {' '} - {t({ en: 'for installation.', fr: 'pour l’installation.' })} -

-
-
-
- - {t({ en: 'Enable RTK auto-rewrite', fr: 'Activer la réécriture automatique RTK' })} - - {rtkStatus === 'checking' && ( - - {t({ en: 'checking…', fr: 'vérification…' })} - - )} - {rtkStatus === 'available' && ( - {t({ en: '● installed', fr: '● installé' })} - )} - {rtkStatus === 'unavailable' && ( - {t({ en: '○ not found', fr: '○ introuvable' })} - )} -
-
- void setSetting(SETTINGS_KEYS.TOOLS_USE_RTK, useRtkSetting === 'true' ? 'false' : 'true')} - /> -
- {shells.length > 0 && useRtkSetting === 'true' && currentShell !== 'gitbash' && ( -

- {t({ - en: 'RTK only rewrites Unix-style commands — with this shell it will rarely apply and can break some commands. Git Bash is recommended.', - fr: 'RTK ne réécrit que les commandes de type Unix — avec ce shell, il s’appliquera rarement et peut casser certaines commandes. Git Bash est recommandé.', - })} -

- )} -
- -
- {/* ── Agent Confirmations Section ── */}

diff --git a/web/src/lib/resources.ts b/web/src/lib/resources.ts index 2a4e4d671..76117db94 100644 --- a/web/src/lib/resources.ts +++ b/web/src/lib/resources.ts @@ -638,7 +638,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', From fd5541c463ef83b133e87070cca329a3b49b5eb0 Mon Sep 17 00:00:00 2001 From: JamesDAdams Date: Sat, 26 Sep 2026 15:38:12 +0200 Subject: [PATCH 03/14] feat(plugins): add list settings fields, native install formats with hot reload, and foreground toasts --- docs/PLUGINS.md | 170 +++++- src/plugin/index.ts | 1 + src/server/plugins/host.test.ts | 21 + src/server/plugins/host.ts | 11 +- src/server/plugins/settings.test.ts | 211 +++++++ src/server/plugins/settings.ts | 173 +++++- src/shared/plugin.ts | 61 +- .../notifications/NotificationToasts.tsx | 2 +- .../notifications/notifications.test.tsx | 8 + .../plugins/DeclarativeRenderer.tsx | 319 +++++++++-- .../components/plugins/PluginPanelHost.tsx | 46 +- .../plugins/PluginSettingsForm.test.tsx | 338 ++++++++++- .../components/plugins/PluginSettingsForm.tsx | 298 +++++++++- web/src/components/plugins/PluginZone.tsx | 16 +- .../components/plugins/plugin-ui-utils.tsx | 98 +++- web/src/components/plugins/plugin-ui.test.tsx | 532 ++++++++++++++++++ .../settings/PluginSettingsTabContent.tsx | 53 +- web/src/lib/resourceCache.test.ts | 49 ++ web/src/lib/resourceCache.ts | 22 +- web/src/lib/resources.test.ts | 32 +- web/src/lib/resources.ts | 22 +- web/src/stores/pluginUi.ts | 16 +- 22 files changed, 2320 insertions(+), 179 deletions(-) create mode 100644 src/server/plugins/settings.test.ts diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 98de439f8..b789c9d92 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -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 @@ -278,7 +359,13 @@ 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: 'openUrl', url }` — opens a URL in a new tab. @@ -326,30 +413,91 @@ 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. +**Declarative node types:** `text`, `keyValue`, `table`, `progress`, `badge`, `button`, `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: diff --git a/src/plugin/index.ts b/src/plugin/index.ts index a2a2804ca..3f3dea39f 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -35,6 +35,7 @@ export type { PluginNotification, PluginNotificationAction, PluginSettingsField, + PluginSettingsLinkButton, PluginSettingsSchema, PluginSettingsTab, PluginSettingScope, diff --git a/src/server/plugins/host.test.ts b/src/server/plugins/host.test.ts index e05b69d5d..6aa81fbe4 100644 --- a/src/server/plugins/host.test.ts +++ b/src/server/plugins/host.test.ts @@ -378,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, diff --git a/src/server/plugins/host.ts b/src/server/plugins/host.ts index 91f225a96..c92e7e4ff 100644 --- a/src/server/plugins/host.ts +++ b/src/server/plugins/host.ts @@ -42,7 +42,12 @@ import { installPluginFromPath, removeNpmArtifacts, } from './install.js' -import { readPluginSettings, readPluginSettingsView, writePluginSettings } from './settings.js' +import { + pluginStorageKey as storageKey, + readPluginSettings, + readPluginSettingsView, + writePluginSettings, +} from './settings.js' import { setPluginModelMetadataProviders } from './model-metadata.js' import { setPluginMessageTransforms } from './message-transforms.js' import { setPluginHookEmitter } from './hook-emitter.js' @@ -516,10 +521,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/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 ce8606803..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, @@ -50,7 +139,7 @@ export function readPluginSettings( const values: PluginSettingsValues = {} for (const field of schema.fields) { if (field.type === 'button' || field.type === 'status') continue - const raw = stored[pluginSettingKey(pluginId, field.scope ?? scope, projectId, field.key)] + const raw = fieldStorageRow(stored, pluginId, field, scope, projectId) const value = coerce(field, raw) if (value !== undefined) values[field.key] = value } @@ -67,20 +156,64 @@ export function readPluginSettingsView( const values: PluginSettingsValues = {} const secretsSet: string[] = [] for (const field of schema.fields) { - if (field.type === 'button' || field.type === 'status') continue - 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( @@ -90,7 +223,7 @@ export function validatePluginSettings( ): string[] { const errors: string[] = [] for (const field of schema.fields) { - if (field.type === 'button' || field.type === 'status') continue + 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)) { @@ -111,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 } @@ -136,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/shared/plugin.ts b/src/shared/plugin.ts index 16d4cfa9d..0d0475bc4 100644 --- a/src/shared/plugin.ts +++ b/src/shared/plugin.ts @@ -126,12 +126,14 @@ 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 + disabled?: boolean onActivate: PluginActivation } | { type: 'divider' } @@ -177,8 +179,11 @@ 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 } @@ -272,7 +277,7 @@ export interface PluginSettingsOption { export interface PluginSettingsField { key: string - type: 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea' | 'path' | 'button' | 'status' + type: 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea' | 'path' | 'button' | 'status' | 'list' label: LocalizedString buttonLabel?: LocalizedString buttonVariant?: 'default' | 'primary' | 'secondary' | 'danger' | 'ghost' @@ -288,6 +293,56 @@ export interface PluginSettingsField { width?: 'full' | 'half' section?: LocalizedString hideWhenInstalled?: boolean + /** Display-only field: rendered disabled, always shows `default`, never read from or written to storage. */ + readOnly?: boolean + /** + * 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 { 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/plugins/DeclarativeRenderer.tsx b/web/src/components/plugins/DeclarativeRenderer.tsx index 0c9b94c01..892dfebea 100644 --- a/web/src/components/plugins/DeclarativeRenderer.tsx +++ b/web/src/components/plugins/DeclarativeRenderer.tsx @@ -1,3 +1,4 @@ +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' @@ -60,6 +61,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 ? ( +