diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index 68a0a5988a..3aef462987 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -8,7 +8,7 @@ import { getPluginDisplayName, hasMcpSettingsFile, listHookConfigFiles, - listPluginTools, + listPluginToolsWithDiagnostics, type RuleConfig, resolveDefaultMcpSettingsPath, resolveMcpServerRegistrations, @@ -372,10 +372,16 @@ async function runToolsConfigCommand( availabilityContext?: BuiltinToolAvailabilityContext, ): Promise { const tools = getToolCatalog(availabilityContext); - const pluginTools = await listPluginTools({ + const pluginReport = await listPluginToolsWithDiagnostics({ workspacePath: cwd, cwd, }); + const pluginTools = pluginReport.tools; + // A plugin that fails to import or set up contributes no tools; list it + // anyway so the failure is visible instead of its tools just vanishing. + const failedPlugins = pluginReport.plugins.filter( + (plugin) => plugin.state === "failed", + ); if (outputMode === "json") { process.stdout.write( @@ -390,11 +396,28 @@ async function runToolsConfigCommand( enabled: tool.enabled, description: tool.description, })), + ...failedPlugins.map((plugin) => ({ + type: "plugin_failure" as const, + pluginName: plugin.pluginName, + path: plugin.path, + state: plugin.state, + enabled: false, + error: plugin.lastError + ? { + phase: plugin.lastError.phase, + message: plugin.lastError.message, + } + : undefined, + })), ]), ); return 0; } - if (tools.length === 0 && pluginTools.length === 0) { + if ( + tools.length === 0 && + pluginTools.length === 0 && + failedPlugins.length === 0 + ) { io.writeln("No tools found."); return 0; } @@ -417,6 +440,17 @@ async function runToolsConfigCommand( ); } } + if (failedPlugins.length > 0) { + io.writeln(); + io.writeln("Failed plugins (their tools are unavailable):"); + for (const plugin of failedPlugins) { + const phase = plugin.lastError?.phase ?? "load"; + const message = plugin.lastError?.message ?? "unknown error"; + io.writeln( + ` ${plugin.pluginName} [failed during ${phase}] ${message} (${plugin.path})`, + ); + } + } return 0; } diff --git a/apps/cli/src/commands/hub.ts b/apps/cli/src/commands/hub.ts index 34380528de..faa9098203 100644 --- a/apps/cli/src/commands/hub.ts +++ b/apps/cli/src/commands/hub.ts @@ -2,6 +2,7 @@ import { clearHubDiscovery, ensureDetachedHubServer, localHubHasNoActiveSessions, + NodeHubClient, probeHubServer, readHubDiscovery, requestHubDrain, @@ -9,7 +10,11 @@ import { resolveSharedHubOwnerContext, stopLocalHubServerGracefully, } from "@cline/core"; -import { formatUptime, resolveClineBuildEnv } from "@cline/shared"; +import { + formatUptime, + type PluginStatusRecord, + resolveClineBuildEnv, +} from "@cline/shared"; import { Command, InvalidArgumentError } from "commander"; import { version as cliVersion } from "../../package.json"; import { createDashboardCommand } from "./dashboard-command"; @@ -163,6 +168,55 @@ export function createHubCommand( }), ); + hub + .command("plugins") + .description("Show the status and last error of each plugin the hub loaded") + .option( + "--reload ", + "Re-import one plugin (name or path) without restarting the hub", + ) + .action( + action(async (cmdOptions: { reload?: string }) => { + const owner = resolveCliHubOwnerContext(); + const discovery = await readHubDiscovery(owner.discoveryPath); + if (!discovery?.url) { + io.writeErr("No hub is running."); + fail(); + return; + } + const client = new NodeHubClient({ + url: discovery.url, + ...(discovery.authToken ? { authToken: discovery.authToken } : {}), + clientType: "cli-hub-plugins", + displayName: "hub plugins", + }); + try { + await client.connect(); + const reply = cmdOptions.reload + ? await client.command("plugins.reload", { + plugin: cmdOptions.reload, + }) + : await client.command("plugins.status"); + if (!reply.ok) { + io.writeErr( + reply.error?.message ?? "The hub rejected the plugins request.", + ); + fail(); + return; + } + const plugins = (reply.payload?.plugins ?? + []) as PluginStatusRecord[]; + io.writeln(JSON.stringify({ plugins })); + } finally { + try { + client.close(); + } catch { + // One-shot connection; a failed close changes nothing. + } + } + }), + ); + hub.command("stop").action( action(async (options: Partial) => { const opts = resolveOptions(options); diff --git a/apps/cli/src/runtime/interactive/session-runtime.ts b/apps/cli/src/runtime/interactive/session-runtime.ts index c6fc4f8e6c..d0abae388f 100644 --- a/apps/cli/src/runtime/interactive/session-runtime.ts +++ b/apps/cli/src/runtime/interactive/session-runtime.ts @@ -28,6 +28,7 @@ import type { } from "../../utils/chat-commands"; import { createRuntimeHooks } from "../../utils/hooks"; import { setActiveCliSession } from "../../utils/output"; +import { formatPluginIssueWarnings } from "../../utils/plugin-issues"; import { loadInteractiveResumeMessages } from "../../utils/resume"; import type { Config } from "../../utils/types"; import { markAbortInProgress } from "../active-runtime"; @@ -140,6 +141,14 @@ export function createInteractiveSessionRuntime(input: { manifest: started.manifest, }); activeSessionId = started.sessionId; + for (const message of formatPluginIssueWarnings(started.pluginIssues)) { + input.onAgentEvent({ + type: "notice", + noticeType: "status", + displayRole: "status", + message, + }); + } }; const ensureSessionManager = async (): Promise => { diff --git a/apps/cli/src/runtime/run-agent.ts b/apps/cli/src/runtime/run-agent.ts index b5c614436f..a99b80b559 100644 --- a/apps/cli/src/runtime/run-agent.ts +++ b/apps/cli/src/runtime/run-agent.ts @@ -32,7 +32,9 @@ import { setActiveCliSession, writeErr, writeln, + writeWarn, } from "../utils/output"; +import { formatPluginIssueWarnings } from "../utils/plugin-issues"; import type { Config } from "../utils/types"; import { shouldShowCliUsageCost } from "../utils/usage-cost-display"; import { setActiveRuntimeAbort } from "./active-runtime"; @@ -307,6 +309,9 @@ export async function runAgent( }); activeSessionId = started.sessionId; + for (const warning of formatPluginIssueWarnings(started.pluginIssues)) { + writeWarn(warning); + } setActiveCliSession({ manifest: started.manifest, }); diff --git a/apps/cli/src/utils/output.ts b/apps/cli/src/utils/output.ts index 1fc3109638..ba81d4dd68 100644 --- a/apps/cli/src/utils/output.ts +++ b/apps/cli/src/utils/output.ts @@ -192,6 +192,14 @@ export function writeErr(text: string): void { console.error(`${c.red}error:${c.reset} ${text}`); } +export function writeWarn(text: string): void { + if (currentOutputMode === "json") { + emitJsonLine("stderr", { type: "warning", message: text }); + return; + } + console.error(`${c.yellow}warning:${c.reset} ${text}`); +} + // ============================================================================= // Formatting helpers // ============================================================================= diff --git a/apps/cli/src/utils/plugin-issues.ts b/apps/cli/src/utils/plugin-issues.ts new file mode 100644 index 0000000000..5b233beea9 --- /dev/null +++ b/apps/cli/src/utils/plugin-issues.ts @@ -0,0 +1,14 @@ +import { formatSessionPluginIssue } from "@cline/core"; +import type { SessionPluginIssue } from "@cline/shared"; + +/** + * One warning line per plugin the session asked for but could not load. + * Plugins the user turned off (settings or session policy) are not warnings. + */ +export function formatPluginIssueWarnings( + issues: ReadonlyArray | undefined, +): string[] { + return (issues ?? []) + .filter((issue) => issue.state === "failed") + .map(formatSessionPluginIssue); +} diff --git a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts index 17f18e3b24..22b42db826 100644 --- a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts +++ b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts @@ -132,7 +132,10 @@ const main = async () => { fileURLToPath(new URL("../../../../", import.meta.url)), ); const targetTriple = await resolveTargetTriple(); - await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`; + await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers src-tauri/bin/extensions`; + // Plugins run in worker threads that load this self-contained bootstrap + // from disk; the app ships it as a resource (see plugin-bootstrap.ts). + await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js src-tauri/bin/extensions/plugin-sandbox-bootstrap.js`; if (targetTriple === "universal-apple-darwin") { await buildUniversalMacSidecar(); } else { diff --git a/apps/examples/desktop-app/sidecar/chat-session.ts b/apps/examples/desktop-app/sidecar/chat-session.ts index df2594b5e5..92e5f772af 100644 --- a/apps/examples/desktop-app/sidecar/chat-session.ts +++ b/apps/examples/desktop-app/sidecar/chat-session.ts @@ -10,6 +10,7 @@ import { createSessionCompactionState, createUserInstructionConfigService, findCheckpointForRun, + formatSessionPluginIssue, getCoreBuiltinToolCatalog, isSessionNotFoundError, isSkillsToolAvailable, @@ -36,6 +37,7 @@ import { type ConsecutiveMistakeLimitContext, type ConsecutiveMistakeLimitDecision, formatUserCommandBlock, + type SessionPluginIssue, } from "@cline/shared"; import { deleteMaterializedAttachments, @@ -1151,9 +1153,24 @@ async function handleStart( cwd, workspaceRoot, environmentId: binding.environmentId, + ...pluginWarningsOf(startResult.pluginIssues), }; } +/** + * Plugins the session asked for but could not load, as display lines. + * Failures that happen later (setup and hooks run on the first turn) arrive + * as notices on the session stream instead. + */ +function pluginWarningsOf( + issues: ReadonlyArray | undefined, +): { pluginWarnings?: string[] } { + const pluginWarnings = (issues ?? []) + .filter((issue) => issue.state === "failed") + .map(formatSessionPluginIssue); + return pluginWarnings.length > 0 ? { pluginWarnings } : {}; +} + async function handleAttach( ctx: SidecarContext, request: ChatSessionCommandRequest, diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index dcb65868cb..b18d906648 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -26,6 +26,7 @@ import { initializeSessionManager, } from "./context"; import { createDesktopObservability } from "./observability"; +import { configurePluginBootstrap } from "./plugin-bootstrap"; import { resolveWorkspaceRoot } from "./paths"; import { startServer } from "./server"; import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck"; @@ -243,6 +244,8 @@ async function runEntrypoint(): Promise { setClineClientIdentity(DESKTOP_CLIENT_CONTEXT); disableCurrentDirectoryExecutableSearch(); + // Before the Hub daemon (this binary, re-executed) loads any plugin. + configurePluginBootstrap(); // Before the Hub daemon and agent-spawned processes inherit this env. ensureLoopbackProxyBypass(); // Claim the Hub daemon sentinel here, not in the shared remote helper: its diff --git a/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts b/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts new file mode 100644 index 0000000000..0ef71d22c7 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts @@ -0,0 +1,65 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "@cline/core"; +import { expect, it } from "vitest"; +import { + configurePluginBootstrap, + resolveDesktopPluginBootstrap, +} from "./plugin-bootstrap"; + +function withLayout( + resourceDirectory: (root: string) => string, + execPath: (root: string) => string, + check: (bootstrap: string, execPath: string) => void, +): void { + const root = mkdtempSync(join(tmpdir(), "cline-plugin-bootstrap-")); + try { + const directory = join(resourceDirectory(root), "bin", "extensions"); + mkdirSync(directory, { recursive: true }); + const bootstrap = join(directory, "plugin-sandbox-bootstrap.js"); + writeFileSync(bootstrap, "// bootstrap"); + check(bootstrap, execPath(root)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +it("finds the bootstrap in the macOS app bundle resources", () => { + withLayout( + (root) => join(root, "Cline.app", "Contents", "Resources"), + (root) => join(root, "Cline.app", "Contents", "MacOS", "code-sidecar"), + (bootstrap, execPath) => { + expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( + bootstrap, + ); + }, + ); +}); + +it("finds the bootstrap in the Linux and Windows resource layouts", () => { + withLayout( + (root) => join(root, "usr", "lib", "Cline"), + (root) => join(root, "usr", "bin", "code-sidecar"), + (bootstrap, execPath) => { + expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( + bootstrap, + ); + }, + ); + withLayout( + (root) => root, + (root) => join(root, "code-sidecar.exe"), + (bootstrap, execPath) => { + expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( + bootstrap, + ); + }, + ); +}); + +it("keeps an explicitly configured bootstrap path", () => { + const env = { [CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]: "/custom/bootstrap.js" }; + configurePluginBootstrap(env); + expect(env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]).toBe("/custom/bootstrap.js"); +}); diff --git a/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts b/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts new file mode 100644 index 0000000000..872318642d --- /dev/null +++ b/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts @@ -0,0 +1,32 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "@cline/core"; +import { linuxResourceCandidates } from "./remote-helper"; + +const BUNDLED_BOOTSTRAP = join("bin", "extensions", "plugin-sandbox-bootstrap.js"); + +/** + * Finds the plugin worker bootstrap the app bundle ships as a Tauri resource. + * The compiled sidecar cannot locate it from its own module path (that points + * inside the binary), so the Hub daemon it runs would otherwise fall back to + * loading plugins in-process. + */ +export function resolveDesktopPluginBootstrap( + options: { execPath?: string; cwd?: string } = {}, +): string | undefined { + const executableDirectory = dirname(options.execPath ?? process.execPath); + const cwd = options.cwd ?? process.cwd(); + return [ + join(executableDirectory, BUNDLED_BOOTSTRAP), + join(executableDirectory, "..", "Resources", BUNDLED_BOOTSTRAP), + ...linuxResourceCandidates(executableDirectory, BUNDLED_BOOTSTRAP), + join(cwd, "src-tauri", BUNDLED_BOOTSTRAP), + ].find(existsSync); +} + +/** Exposes the bundled bootstrap to core and to the Hub daemon this spawns. */ +export function configurePluginBootstrap(env = process.env): void { + if (env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]) return; + const bootstrap = resolveDesktopPluginBootstrap(); + if (bootstrap) env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV] = bootstrap; +} diff --git a/apps/examples/desktop-app/sidecar/remote-helper.ts b/apps/examples/desktop-app/sidecar/remote-helper.ts index 6295a57958..2b8f90ef3b 100644 --- a/apps/examples/desktop-app/sidecar/remote-helper.ts +++ b/apps/examples/desktop-app/sidecar/remote-helper.ts @@ -14,7 +14,7 @@ import { // Tauri's Linux bundles (deb, rpm, AppImage) install binaries under `usr/bin` // and resources under `usr/lib/`. The product name differs per // release channel ("Cline", "Cline Beta"), so scan the sibling lib directory. -function linuxResourceCandidates( +export function linuxResourceCandidates( executableDirectory: string, relativePath: string, ): string[] { diff --git a/apps/examples/desktop-app/src-tauri/tauri.conf.json b/apps/examples/desktop-app/src-tauri/tauri.conf.json index de23e81d63..370c287e63 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.conf.json @@ -45,7 +45,8 @@ "resources": [ "icons/app/*.png", "icons/app/macos/*.png", - "bin/remote-helpers/*" + "bin/remote-helpers/*", + "bin/extensions/*" ], "icon": [ "icons/32x32.png", diff --git a/apps/examples/desktop-app/webview/hooks/chat-session/types.ts b/apps/examples/desktop-app/webview/hooks/chat-session/types.ts index 54faa41e18..f8f7398b36 100644 --- a/apps/examples/desktop-app/webview/hooks/chat-session/types.ts +++ b/apps/examples/desktop-app/webview/hooks/chat-session/types.ts @@ -137,6 +137,8 @@ export type ChatSessionCommandResponse = { queued?: boolean; /** A plugin slash command ran in the sidecar and no turn was started. */ commandHandled?: boolean; + /** One line per plugin the started session could not load. */ + pluginWarnings?: string[]; recoveredAfterDisconnect?: boolean; status?: string; promptsInQueue?: PromptInQueue[]; diff --git a/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx b/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx index 1fcdc043aa..49a2b699e0 100644 --- a/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx +++ b/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx @@ -5034,6 +5034,137 @@ describe("useChatSession", () => { expect(userMessages[0]?.content).toBe("First prompt"); }); + it("shows plugin failures from session start and later notices in the chat", async () => { + invokeMock.mockImplementation( + async (command: string, args?: Record) => { + if (command === "get_process_context") { + return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" }; + } + if (command === "chat_session_command") { + const request = args?.request as + | { action?: string; config?: { sessionId?: string } } + | undefined; + if (request?.action === "start") { + return { + sessionId: request.config?.sessionId, + pluginWarnings: [ + 'Plugin "broken-import" failed during import: boom (/p/broken.ts). Its tools and hooks are unavailable.', + ], + }; + } + if (request?.action === "send") { + return { ok: true }; + } + } + return []; + }, + ); + + await act(async () => { + await current.sendPrompt("First prompt"); + }); + const chatEventHandler = subscribeMock.mock.calls.find( + ([eventName]) => eventName === "chat_event", + )?.[1] as ((payload: unknown) => void) | undefined; + + await act(async () => { + chatEventHandler?.({ + sessionId: current.sessionId, + stream: "chat_core_log", + chunk: JSON.stringify({ + level: "info", + message: + 'Plugin "broken-setup" failed during setup: boom (/p/setup.ts). Its tools and hooks are unavailable.', + metadata: { pluginIssue: { name: "broken-setup", state: "failed" } }, + }), + ts: Date.now(), + index: 1, + }); + // Ordinary core logs stay out of the transcript. + chatEventHandler?.({ + sessionId: current.sessionId, + stream: "chat_core_log", + chunk: JSON.stringify({ level: "info", message: "compacting" }), + ts: Date.now(), + index: 2, + }); + }); + + const pluginMessages = current.messages.filter( + (message) => message.meta?.messageKind === "plugin_issue", + ); + expect(pluginMessages.map((message) => message.role)).toEqual([ + "status", + "status", + ]); + expect(pluginMessages[0]?.content).toContain('Plugin "broken-import"'); + expect(pluginMessages[1]?.content).toContain('Plugin "broken-setup"'); + expect( + current.messages.some((message) => message.content === "compacting"), + ).toBe(false); + }); + + it("re-keys the optimistic bubble past a start-time plugin warning", async () => { + invokeMock.mockImplementation( + async (command: string, args?: Record) => { + if (command === "get_process_context") { + return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" }; + } + if (command === "chat_session_command") { + const request = args?.request as + | { action?: string; config?: { sessionId?: string } } + | undefined; + if (request?.action === "start") { + return { + sessionId: request.config?.sessionId, + pluginWarnings: [ + 'Plugin "broken-import" failed during import: boom (/p/broken.ts). Its tools and hooks are unavailable.', + ], + }; + } + if (request?.action === "send") { + return { ok: true, result: {} }; + } + } + return []; + }, + ); + + await act(async () => { + await current.sendPrompt("hi there"); + }); + const chatEventHandler = subscribeMock.mock.calls.find( + ([eventName]) => eventName === "chat_event", + )?.[1] as ((payload: unknown) => void) | undefined; + + // The warning row now sits after the optimistic bubble; the queued + // start for the same prompt must still find and re-key that bubble. + await act(async () => { + chatEventHandler?.({ + sessionId: current.sessionId, + stream: "chat_queued_prompt_start", + chunk: JSON.stringify({ + promptId: "queued-prompt-1", + prompt: "hi there", + attachmentCount: 0, + }), + ts: Date.now(), + index: 1, + }); + }); + + const userMessages = current.messages.filter( + (message) => message.role === "user" && message.content === "hi there", + ); + expect(userMessages).toHaveLength(1); + expect(userMessages[0]?.id).toBe("queued_user_queued-prompt-1"); + expect( + current.messages.filter( + (message) => message.meta?.messageKind === "plugin_issue", + ), + ).toHaveLength(1); + }); + it("explains a failed turn with the latest core error log", async () => { invokeMock.mockImplementation( async (command: string, args?: Record) => { diff --git a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts index 55a32ddd8d..7cf78d4d75 100644 --- a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts +++ b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts @@ -139,6 +139,38 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** A visible status line for a plugin the session could not fully use. */ +function makePluginIssueMessage( + sid: string | null, + content: string, +): ChatMessage { + return { + id: makeId("plugin"), + sessionId: sid, + role: "status", + content, + createdAt: Date.now(), + meta: { messageKind: "plugin_issue" }, + }; +} + +/** The notice text when a core log chunk reports a plugin issue. */ +function readPluginIssueWarning(chunk: string): string | undefined { + try { + const parsed = JSON.parse(chunk) as { + message?: unknown; + metadata?: { pluginIssue?: unknown }; + }; + return parsed.metadata?.pluginIssue && + typeof parsed.message === "string" && + parsed.message.trim() + ? parsed.message.trim() + : undefined; + } catch { + return undefined; + } +} + function makeErrorChatMessage( sid: string | null, content: string, @@ -1932,6 +1964,11 @@ export function useChatSession(environmentId: string) { rekeyedOptimisticIdByMessageIdRef.current[userMessageId]; for (let i = prev.length - 1; i >= 0; i--) { const candidate = prev[i]; + // Session start appends plugin warnings after the optimistic + // bubble; look past them to the prompt. + if (candidate.meta?.messageKind === "plugin_issue") { + continue; + } if (candidate.role !== "user") { break; } @@ -1982,6 +2019,12 @@ export function useChatSession(environmentId: string) { // --- Core log --- if (payload.stream === "chat_core_log") { dispatchCoreLog(payload.chunk); + // Plugin failures after the session started (setup and hooks run + // on the first turn) are shown in the chat, not only logged. + const pluginWarning = readPluginIssueWarning(payload.chunk); + if (pluginWarning) { + addMessage(makePluginIssueMessage(payload.sessionId, pluginWarning)); + } // Remember the latest error so a failed turn can explain itself: // the runtime reports the underlying cause (e.g. an auth failure) // here rather than on the turn-completion event. @@ -2641,6 +2684,9 @@ export function useChatSession(environmentId: string) { } setSessionId(id); setIsCloudSessionExpired(false); + for (const warning of payload.pluginWarnings ?? []) { + addMessage(makePluginIssueMessage(id, warning)); + } // Mark idle — not running — so the first sendPrompt is not queued. // The status transitions to "starting"/"running" once a prompt is // actually dispatched. @@ -2656,7 +2702,7 @@ export function useChatSession(environmentId: string) { setHydratedHistorySessionId(null); return id; }, - [environmentId, postSession], + [addMessage, environmentId, postSession], ); // ---- Actions ---- diff --git a/apps/vscode/esbuild.mjs b/apps/vscode/esbuild.mjs index 1b4a178e53..1eb477c527 100644 --- a/apps/vscode/esbuild.mjs +++ b/apps/vscode/esbuild.mjs @@ -191,8 +191,25 @@ const e2eBuildConfig = { plugins: [aliasResolverPlugin, esbuildProblemMatcherPlugin], } +/** + * Plugins run in worker threads that load this self-contained bootstrap from + * disk, next to the bundle (core looks for it beside dist/extension.js). + * Shipped as .mjs because this package is not `"type": "module"`. Without it, + * core falls back to loading plugins inside the extension host. + */ +function copyPluginBootstrap() { + const source = path.resolve(__dirname, "../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js") + if (!fs.existsSync(source)) { + console.warn(`[esbuild] ${source} not found; plugins will load in-process`) + return + } + fs.mkdirSync(destDir, { recursive: true }) + fs.copyFileSync(source, path.join(destDir, "plugin-sandbox-bootstrap.mjs")) +} + async function main() { const config = standalone ? standaloneConfig : e2eBuild ? e2eBuildConfig : extensionConfig + if (!e2eBuild) copyPluginBootstrap() const extensionCtx = await esbuild.context(config) if (watch) { await extensionCtx.watch() diff --git a/docs/customization/plugins.mdx b/docs/customization/plugins.mdx index 5ab420c90b..5ab11bdf2d 100644 --- a/docs/customization/plugins.mdx +++ b/docs/customization/plugins.mdx @@ -142,6 +142,22 @@ Plugins are stored in the `plugins` directory at two levels: Global plugins (`~/.cline/plugins/`) are available across all sessions. Project plugins (`.cline/plugins/` in your repo) are available only when working in that project. +## Troubleshooting Plugins + +When a plugin fails to load, Cline tells you which plugin failed and why instead of quietly dropping its tools: + +- Starting a session prints a warning that names the plugin, the step that failed (import, setup, a hook, or a tool), and the error. +- `cline config tools` lists failed plugins under "Failed plugins". With `--json`, each one is an entry with `"type": "plugin_failure"` and an `error` object holding its `phase` and `message`. +- `cline hub plugins` shows the status of every plugin the hub loaded, its last error, and the sessions using it. + +After fixing a plugin, reload it without restarting the hub: + +```bash +cline hub plugins --reload my-plugin +``` + +A plugin that keeps failing in hooks or tools is turned off for the rest of the hub's life until you reload it or change its file. One broken plugin doesn't stop other plugins or sessions. + ## Writing Plugins For a guide on building plugins with the SDK, see [Writing Plugins](/sdk/guides/writing-plugins). For the plugin API reference, see [SDK Plugins](/sdk/plugins). diff --git a/docs/sdk/plugins.mdx b/docs/sdk/plugins.mdx index c47cd9fce7..362692f433 100644 --- a/docs/sdk/plugins.mdx +++ b/docs/sdk/plugins.mdx @@ -96,58 +96,120 @@ await cline.start({ Plugin files export an `AgentPlugin`. +## How Plugins Run + +By default each plugin runs in its own worker thread, one per session, inside the host's runtime. Module-level state is never shared between sessions, no separate `node` or `bun` process is started (plugins work in the compiled CLI, the hub, and the desktop app without a runtime on `PATH`), and a plugin can use the host runtime's globals, such as `Bun` under the Bun-built hub. + +A worker contains its plugin's failures: + +- A plugin stuck in a loop is stopped at its timeout; the next call starts a fresh worker. +- An uncaught error, an unhandled rejection, or `process.exit()` ends only that plugin's worker. The plugin is marked `failed` and turned off, like any other fatal plugin error, and the hub and other plugins keep running. +- Plugin `console.log` and stdout output are discarded, so they cannot corrupt `--json` output or a JSON-RPC stream. Use `ctx.logger`. + +Values passed to and returned from a plugin cross a thread boundary, so they must be plain data; functions are dropped. `process.cwd()` reports the session's directory, but relative file paths resolve against the host's, so use `ctx.cwd`. Workers share the host's memory limit, environment snapshot, and native addons: this is crash isolation, not a security boundary. + +Two other modes are available as opt-ins through `CLINE_PLUGIN_MODE` (or `mode` on `resolveAndLoadAgentPlugins`): + +- **`in_process`**: plugins are imported into the host process. Lowest overhead, but globals, `process.env`, and the working directory are shared with the host, a synchronous loop blocks it, and a stray error the host cannot attribute to the plugin can end it. +- **`sandbox`**: the previous per-session subprocess sandbox. It needs `node` or `bun` on `PATH` when the host isn't `node` or `bun` itself, and does not apply the per-session plugin policy or report plugin status. + +Hosts that do not ship the worker bootstrap fall back to `in_process`. The CLI, the hub, the desktop app, and the VS Code extension ship it; an SDK host that bundles `@cline/core` can copy `dist/extensions/plugin-sandbox-bootstrap.js` next to its bundle, or point `CLINE_PLUGIN_BOOTSTRAP_PATH` at it. + +Plugins are bound to a session when it starts. Adding, editing, or reloading a plugin affects only sessions started afterwards; running sessions keep the plugins and code they started with. In worker mode every new session imports the plugin fresh, so any change is picked up; `cline hub plugins --reload ` re-checks it and clears a failed status. In `in_process` mode, edits to the plugin's entry file, or to any file it reaches through a relative `import ... from` or `require()`, are picked up automatically; after changing anything else the plugin loads (a dependency in `node_modules`, a side-effect `import "./x"`, a dynamic import), run `cline hub plugins --reload `. + +## Choosing Plugins per Session + +Pass a `plugins` policy when you start a session. It's shaped like `toolPolicies`: `"*"` sets the default, and named entries override it for one plugin. Plugins are enabled unless a policy turns them off. + +```typescript +await cline.start({ + prompt: "Review this change", + config: { /* ...model/runtime config */ }, + plugins: { + "*": { enabled: false }, + "code-review": { enabled: true }, + }, +}) +``` + +A session only gets tools, hooks, rules, and providers from plugins it enabled. Plugin tools still go through `toolPolicies`. The policy applies in worker and `in_process` mode; the opt-in `sandbox` mode loads every discovered plugin. + +## Setup and Call Context + +`setup(api, ctx)` receives the session's context: + +| Field | Meaning | +|-------|---------| +| `ctx.cwd` | The session's working directory | +| `ctx.session?.sessionId` | The session that is setting the plugin up | +| `ctx.emitEvent(name, payload)` | Sends a plugin event, such as `steer_message`, to that session | +| `ctx.workspaceInfo` | Workspace root and git metadata | +| `ctx.onDispose(fn)` | Registers cleanup to run when that session ends | + +When a session ends, the host runs your `onDispose` callbacks. Plugins share the host process, so stop everything you start for a session there: timers, child processes, sockets, listeners on shared emitters. + +```typescript +setup(api, ctx) { + const child = spawn("my-watcher", { cwd: ctx.cwd }) + const timer = setInterval(poll, 5_000) + ctx.onDispose?.(() => { child.kill(); clearInterval(timer) }) +} +``` + +Plugin tools also receive `cwd` and `emitEvent` in their call context (`execute(input, context)`). `globalThis.__clinePluginHost.emitEvent` still works for plugins written for the sandbox; it routes to the session whose hook or tool is running. + +## Plugin Status and Errors + +The host wraps every plugin call and records what goes wrong, so a broken plugin never just loses its tools silently. + +| Call | Timeout | On failure | +|------|---------|------------| +| Import | 4 s (`CLINE_PLUGIN_IMPORT_TIMEOUT_MS`) | Plugin is `failed` | +| `setup()` | 4 s | That session loses the plugin (none of its registrations are kept) and the status shows `failed`; other sessions keep working, and the next session tries again | +| Hook | 3 s | Plugin is `degraded`; the run continues unless `hookErrorMode` is `"throw"` | +| Tool | 60 s, or the tool's `timeoutMs` | The error goes back to the model as a tool error; plugin is `degraded` | +| Command, rule content, message builder | 60 s | A command throws to its caller; a rule contributes no text; a message builder leaves messages unchanged; plugin is `degraded` | + +Five failures in a row turn a `degraded` plugin `failed`. An import failure, or five failed calls in a row, turns the plugin off for every session using it until it is reloaded or its source changes. A successful reload does not interrupt running sessions: they keep the copy they started with, and new sessions get the new code. A reload whose import fails turns the plugin off for running sessions too, and they are told why. In the hub, a stray exception or unhandled rejection whose stack points into a plugin marks that plugin `failed` instead of stopping the hub. + +Each plugin has a status of `loading`, `ready`, `degraded`, `failed`, or `disabled`. Its last error records the phase (`discover`, `import`, `setup`, `hook:`, `tool:`, `command:`, or `uncaught`), the message, the stack, the plugin path, and a timestamp. The status also counts errors and timeouts. + +To read status: + +- `StartSessionResult.pluginIssues` lists the plugins the session asked for but doesn't have. +- Plugins that fail later in the session (`setup()` and hooks run on the first turn) are reported to that session as a status notice event, with the issue in `metadata.pluginIssue`. +- Hub clients can send `plugins.status` (or `plugins.list`) for every plugin's status, last error, and the sessions using it. `plugins.reload` with `{ plugin: "" }` re-imports one plugin without restarting the hub. The hub broadcasts a `plugin.status_changed` event when a status changes. + ## Installing Plugins via CLI Plugins can also be installed from file URLs, npm, git, or local paths using `cline plugin install`. See [Plugins](/customization/plugins) for install commands, the manifest format, and directory layout. -## Hook Stages +## Hooks -Hook stages include: +Plugin hooks are functions in the plugin's `hooks` object. Each one receives a context with the current runtime `snapshot` (agent id, conversation id, iteration, messages, usage), and some can return a result that changes what happens next. -```txt -input -runtime_event -session_start -run_start -iteration_start -turn_start -before_agent_start -tool_call_before -tool_call_after -turn_end -stop_error -iteration_end -run_end -session_shutdown -error -``` +| Hook | When it runs | It can return | +|------|--------------|---------------| +| `beforeRun` | Before a run starts | `stop` and `reason` to end the run; `appendContext` to add text the model sees on its first request | +| `afterRun` | After a run ends, with its `result` | Nothing; use it for metrics, notifications, cleanup | +| `beforeModel` | Before each model request, with the `request` | `messages`, `tools`, or `options` to change the request; `stop` and `reason` to end the run | +| `afterModel` | After each model response, with the `assistantMessage` and `finishReason` | `stop` and `reason` | +| `beforeTool` | Before a tool runs, with the `tool`, `toolCall`, and `input` | `skip` and `reason` to block the call (the reason goes back to the model); `input` to replace the input; `policy` (for example `{ autoApprove: false }`) to require approval; `appendContext`; `stop` | +| `afterTool` | After a tool runs, with the `result` and timing | `result` to replace the result; `appendContext`; `stop` and `reason` | +| `onEvent` | For runtime events | Nothing | -Common stages: +To block dangerous tool calls, return `{ skip: true, reason }` from `beforeTool`, or require approval with `{ policy: { autoApprove: false } }`. -| Stage | Use for | -|-------|---------| -| `before_agent_start` | Inject context or modify prompt/messages | -| `run_start` | Logging, timers, rate limits | -| `tool_call_before` | Audit or block tool calls | -| `tool_call_after` | Log results, trigger side effects | -| `run_end` | Metrics, notifications, cleanup | -| `error` | Error reporting | +### When a hook fails -## Hook Policies +Each plugin hook call has a 3-second timeout. If a hook throws, rejects, or times out, the session's `hookErrorMode` decides what happens: -Hook policies control execution behavior: +| `hookErrorMode` | Effect | +|-----------------|--------| +| `"ignore"` (default) | The failure is recorded and reported, and the run continues as if the hook returned nothing | +| `"throw"` | The error is rethrown and the run fails | -| Field | Meaning | -|-------|---------| -| `mode` | `"blocking"` or `"async"` | -| `timeoutMs` | Hook timeout | -| `retries` | Retry count | -| `retryDelayMs` | Delay between retries | -| `failureMode` | `"fail_open"` or `"fail_closed"` | -| `maxConcurrency` | Concurrent hook executions | -| `queueLimit` | Queue size before dropping | - -Use `fail_closed` for policy-enforcement hooks where bypassing the hook is unsafe. +A hook that keeps failing marks its plugin `degraded`, then `failed` after five failures in a row. See [Plugin Status and Errors](#plugin-status-and-errors). ## Build a Plugin diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 99a1d70a16..3b7471fe09 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -605,7 +605,7 @@ Design implications: `packages/core/src/extensions` is split by concern: - `extensions/config`: config loaders, parsers, watchers, and watcher projections such as runtime slash-command expansion -- `extensions/plugin`: runtime plugin discovery, loading, and sandboxing +- `extensions/plugin`: runtime plugin discovery, loading, the plugin registry, the worker/subprocess sandbox transports, and their shared bootstrap - `extensions/context`: core-owned context/message pipeline concerns such as compaction Design implications: @@ -613,7 +613,169 @@ Design implications: - avoid mixing config discovery code into runtime/plugin code - avoid creating thin runtime wrapper files when a helper is fundamentally projecting watcher state -Sandboxed plugin subprocesses are session-local but lazily recreatable. Core +#### Plugin execution + +Every plugin a session uses goes through one `PluginRegistry` per process +(`getProcessPluginRegistry()`, `extensions/plugin/plugin-registry.ts`). The +registry owns status, failure counting, timeouts, the session policy, and +reload; the execution mode (`CLINE_PLUGIN_MODE`, `resolvePluginExecutionMode`) +only decides where a session's copy of a plugin runs: + +- `worker` (default): each plugin runs in its own worker thread per session + (`WorkerSandbox`), so the host never imports plugin code. The copy the + registry wraps is a proxy whose calls go to the worker over the sandbox RPC + protocol (`plugin-sandbox.ts` / `plugin-sandbox-bootstrap.ts`). One worker + per plugin means a worker that dies on its own (uncaught error, unhandled + rejection, `process.exit()`) is attributed to that plugin with no stack + inspection: the registry records an `uncaught` failure and turns the plugin + off, and the host and other plugins keep running. A call that times out + terminates its worker, which stops synchronous loops; the next call starts a + fresh worker. Released copies end their worker, which stops everything the + plugin started. +- `in_process`: the registry imports each copy into the host with jiti. The + first session claims the copy imported at startup, and later sessions + re-evaluate the module (reusing jiti's transform cache). +- `sandbox`: the previous per-session `node`/`bun` subprocess. It bypasses the + registry (no status, policy, or reload) and is kept as an escape hatch. + +Plugins written for the per-session sandbox keep state at module level, often +one plugin object per import that refuses a second `setup()`, so every mode +gives each session its own copy. The session bootstrap, plugin tool listing +(`listPluginToolsWithDiagnostics`), plugin MCP settings sync, and plugin slash +commands all load through the registry, so they see the same mode. Neither +`worker` nor `in_process` starts a child process, so plugins work in the +compiled CLI, the Hub it starts, the desktop app, and VS Code/Electron without +a runtime on `PATH`, and plugins can use the host runtime's globals (`Bun` +under the Bun-built Hub, including inside a Bun worker). + +In the Hub daemon the registry is Hub-owned: the daemon checks global and +workspace plugins at startup (in throwaway workers in `worker` mode, by +importing them in `in_process` mode) so status is known before any session +exists. + +Each session gets its own guarded view of the plugins it enabled: + +- `StartSessionInput.plugins` (`session.create` payload `plugins`) is shaped + like `toolPolicies`: `"*"` sets the default and named entries override it. + Plugins are enabled unless a policy turns them off. Tools, hooks, rules, and + providers come only from enabled plugins; plugin tools still go through + `toolPolicies`. +- `setup()` runs once per session with a context that carries the session's + `cwd`, `session.sessionId`, and `emitEvent`. Plugin tools also receive `cwd` + and `emitEvent` in their call context. `globalThis.__clinePluginHost.emitEvent` + remains as a shim for sandbox-era plugins and routes to the calling session + through async context. +- Every import (4 s), setup (4 s), hook (3 s), and tool, command, rule + content, and message-builder call (60 s, or a tool's own `timeoutMs`) is + wrapped with a timeout and error capture. `resolveAndLoadAgentPlugins` + overrides (`importTimeoutMs`, `hookTimeoutMs`, `contributionTimeoutMs`) + apply per session. A failing tool or command throws its error back to the + caller. A failing hook follows `hookErrorMode` (`"ignore"` by default; + `"throw"` rethrows). A failing rule contributes no text, and a failing + message builder leaves the messages unchanged. Calls into a copy that is + turned off, whose setup failed, or whose session ended are refused. +- Registrations are buffered (and capability-checked) during setup, so a + setup that throws contributes nothing. If the host rejects a registration + while committing, the copy's setup is recorded as failed and the error is + rethrown so the contribution registry discards what was already committed. +- Each copy owns its cleanup. `ctx.onDispose(fn)` registers cleanup that runs + when the session releases the copy (session stop, dispose, or a failed + start). Plugins must stop everything they start for a session there: timers, + child processes, sockets, listeners on shared emitters. The host does not + track or cancel them. + +The registry tracks one status per plugin: `loading`, `ready`, `degraded`, +`failed`, or `disabled`. It records the last error with its phase (`discover`, +`import`, `setup`, `dispose`, `hook:`, `tool:`, `command:`, +`rule:`, `messageBuilder:`, or `uncaught`), message, stack, plugin +path, timestamp, and session, plus error and timeout counts. + +Plugins are bound to a session when it starts. A plugin that is added, +changed, or reloaded reaches only sessions started afterwards; a running +session keeps the plugins, and the plugin code, it started with, even if its +`setup()` first runs on a later turn. Restarting a session (a CLI mode switch, +resuming it, or the Hub rebuilding a missing session) is a new start and gets +the current plugins. A change is detected by a content fingerprint of the +plugin's entry file and every file it reaches through relative static imports +(`fingerprintPluginSources`), so edits outside the entry file count too. +Files reached only through dynamic imports, `require()` of computed paths, or +`node_modules` are not fingerprinted; after changing those, use +`plugins.reload`. + +Failures are counted per plugin. Import failures, discovery failures, an +attributed uncaught error, and five consecutive call failures turn the +plugin off for every session using it; a re-import (`plugins.reload` or a +fingerprint change) starts over with a clean slate. Running sessions keep +the copy they set up, which stays callable unless the plugin is turned off +(a broken reload fails closed for them too). Each copy is stamped with the +import it came from; a failure in a copy from before a re-import is reported +to its own session but does not count against, or change the status of, the +current module. A `setup()` failure is per +session: that session loses its copy (no tools or hooks), the status shows +`failed` with the error, only that session is told, other sessions keep +their working copies, and the next session tries setup again; a successful +setup returns the status to `ready`. + +Status is surfaced, never only logged: + +- `plugins.list` / `plugins.status` return each plugin's status, last error, + and the sessions using it; `plugins.reload` re-imports one plugin by name or + path without restarting the Hub. `cline hub plugins [--reload ]` + wraps these. +- The Hub broadcasts `plugin.status_changed` to attached clients. +- `session.create` replies and `session.created` events carry `pluginIssues` + for plugins the session asked for but does not have: failed, disabled in + settings (`reason: "settings"`), or turned off by the session's policy + (`reason: "session_policy"`). + `StartSessionResult.pluginIssues` exposes the same list to SDK hosts. +- Because `setup()` and hooks run on the first turn, after the start reply, + later degraded/failed transitions reach each affected session as a status + notice (`AgentNoticeEvent` with `metadata.pluginIssue`). +- Registry logs are structured lines with `pluginName`, `phase`, and + `sessionId`; in the Hub they go through `logHubMessage`. +- Settings listings (`listPluginToolsWithDiagnostics`, `CoreSettingsService`) + report failed plugins with their state and `loadError`. Their inspection + cache is keyed on each plugin's registry status, so a plugin a running + session turned off is never answered from an earlier clean inspection. + +In `in_process` mode the daemon's `uncaughtException` / `unhandledRejection` +handlers first ask the registry to attribute the error: when its stack points +into a known plugin root (the package directory for package plugins, the file +itself for drop-in files), that plugin is marked `failed` and the Hub keeps +running. Other errors still go to `shutdownFatal`, which is the accepted risk of +that mode: `process.exit()` from a plugin, or a rejection raised by runtime +internals with no plugin frame (an un-caught `fetch()`), ends the Hub. Plugins +also share the process (globals, `process.env`, native resources), and a +plugin stuck in a synchronous loop freezes the Hub until it returns. Worker +mode exists to remove these risks. + +#### Worker and subprocess transports + +Both transports share one RPC protocol and one bootstrap +(`plugin-sandbox-bootstrap.ts`), bundled self-contained into +`dist/extensions/plugin-sandbox-bootstrap.js` (the core build fails if it +imports anything but Node builtins, because hosts ship it without +`node_modules`): + +- `WorkerSandbox` (worker mode): structured clone over the worker's message + port. A worker must not `chdir` (Bun would change the host's cwd), so the + bootstrap overrides `process.cwd()` inside the worker. The bootstrap posts + `ready` once it listens, and the host queues calls (as clones) until then, + because Bun drops messages that arrive earlier. Plugin stdout is discarded + inside the worker, since Bun workers write straight to the host's stdout. + Workers share the host's memory limit, env snapshot, and native addons: + crash isolation, not a security boundary. +- `SubprocessSandbox` (sandbox mode): JSON IPC to a `node`/`bun` child with + stdout ignored. + +Hosts find the bootstrap beside their bundle (`plugin-sandbox-bootstrap.js`, +or `.mjs` for the CommonJS VS Code bundle), beside the CLI binary +(`extensions/`), or through `CLINE_PLUGIN_BOOTSTRAP_PATH` (the desktop sidecar +sets it to the copy shipped as a Tauri resource). A host that ships none +cannot start workers, and `resolvePluginExecutionMode` falls back to +`in_process`. + +Sandboxed plugin subprocesses and workers are session-local but lazily recreatable. Core reclaims a sandbox after 30 minutes without an in-flight RPC call (configurable through `PluginSandboxOptions.idleTimeoutMs` or `CLINE_PLUGIN_IDLE_TIMEOUT_MS`), and the next plugin call starts and @@ -625,10 +787,10 @@ deadlines cannot terminate a child while the parent is dispatching new work. Design implications: -- sandbox process count scales with recently active sessions, not every session - observed since hub startup +- worker and subprocess counts scale with recently active sessions (workers: + sessions × plugins), not every session observed since hub startup - eviction never interrupts an in-flight plugin call -- in-process plugin state is ephemeral across idle eviction; durable plugin +- sandboxed plugin state is ephemeral across idle eviction; durable plugin state belongs in persistent storage - a sandbox must never outlive its owning hub process @@ -893,7 +1055,7 @@ completed turn. Report and cleanup failures are logged separately. **I want to understand the agent loop and tool execution:** - Start: `packages/agents/src/agent.ts` — the stateless runtime loop - Then: `packages/agents/src/agent-step.ts` — individual iteration steps -- Extensions: `packages/core/src/extensions/plugin/` — plugin discovery and sandboxing +- Extensions: `packages/core/src/extensions/plugin/` — plugin discovery, the in-process registry, and the opt-in sandbox **I want to understand session persistence and state:** - Start: `packages/core/src/runtime/host/local-runtime-host.ts` — local session lifecycle diff --git a/sdk/examples/plugins/automation-events.ts b/sdk/examples/plugins/automation-events.ts index 0401a6ecd1..41177465ba 100644 --- a/sdk/examples/plugins/automation-events.ts +++ b/sdk/examples/plugins/automation-events.ts @@ -14,12 +14,6 @@ import type { AgentPlugin } from "@cline/core"; -const stopLocalEmitters = new Map void>(); - -function emitterKey(sessionId: string | undefined): string | undefined { - return sessionId?.trim() || undefined; -} - export const plugin: AgentPlugin = { name: "local-automation-events", manifest: { @@ -59,16 +53,6 @@ export const plugin: AgentPlugin = { return; } - const key = emitterKey(ctx.session?.sessionId); - if (!key) { - ctx.logger?.log( - "local automation event emitter disabled; setup context has no session id", - { severity: "warn" }, - ); - return; - } - stopLocalEmitters.get(key)?.(); - const timer = setInterval(() => { void ctx.automation?.ingestEvent({ eventId: `local-plugin-${Date.now()}`, @@ -84,7 +68,8 @@ export const plugin: AgentPlugin = { }); }, intervalMs); - stopLocalEmitters.set(key, () => clearInterval(timer)); + // Stop emitting when the session that set this plugin up ends. + ctx.onDispose?.(() => clearInterval(timer)); }, }; diff --git a/sdk/packages/core/bun.mts b/sdk/packages/core/bun.mts index 4d2d306e51..4ee0b34f51 100644 --- a/sdk/packages/core/bun.mts +++ b/sdk/packages/core/bun.mts @@ -1,4 +1,5 @@ /// +import { builtinModules } from "node:module"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -92,12 +93,16 @@ const builds: Parameters[0][] = [ outdir: "./dist/services/feature-flags", ...buildConfig, }, - // The plugin sandbox bootstrap runs in an isolated child process via - // SubprocessSandbox and must be emitted as a separate executable entrypoint. + // The plugin sandbox bootstrap runs in an isolated child process + // (SubprocessSandbox) or worker thread (WorkerSandbox) and is emitted as a + // separate entrypoint. It bundles every package it uses: the CLI ships it + // beside the compiled binary with no node_modules to resolve from, so it + // may import only Node builtins. { entrypoints: ["./src/extensions/plugin/plugin-sandbox-bootstrap.ts"], outdir: "./dist/extensions", ...buildConfig, + external: [], }, ]; @@ -115,3 +120,19 @@ for (const config of builds) { } } } + +// Fail the build if the bootstrap still imports a package: it would load in +// dev and tests, then fail in a published CLI install. +const bootstrapPath = "./dist/extensions/plugin-sandbox-bootstrap.js"; +const bootstrapPackages = new Bun.Transpiler({ loader: "js" }) + .scanImports(await Bun.file(bootstrapPath).text()) + .map((entry) => entry.path) + .filter( + (path) => !path.startsWith("node:") && !builtinModules.includes(path), + ); +if (bootstrapPackages.length > 0) { + console.error( + `${bootstrapPath} must be self-contained but imports: ${[...new Set(bootstrapPackages)].join(", ")}`, + ); + process.exit(1); +} diff --git a/sdk/packages/core/src/extensions/index.ts b/sdk/packages/core/src/extensions/index.ts index aeb651f4cd..e987f32b0c 100644 --- a/sdk/packages/core/src/extensions/index.ts +++ b/sdk/packages/core/src/extensions/index.ts @@ -16,13 +16,19 @@ export { loadAgentPluginPackages, parseAgentSkillMarkdown, } from "./agent-plugin"; -export type { ResolveAgentPluginPathsOptions } from "./plugin/plugin-config-loader"; +export type { + PluginExecutionMode, + ResolveAgentPluginPathsOptions, +} from "./plugin/plugin-config-loader"; export { + CLINE_PLUGIN_MODE_ENV, discoverPluginModulePaths, getPluginDisplayName, resolveAgentPluginPaths, + resolveAgentPluginPathsWithDiagnostics, resolveAndLoadAgentPlugins, resolvePluginConfigSearchPaths, + resolvePluginExecutionMode, resolvePluginSkillDirectoriesFromPaths, } from "./plugin/plugin-config-loader"; export type { @@ -36,3 +42,22 @@ export { loadAgentPluginsFromPaths, loadAgentPluginsFromPathsWithDiagnostics, } from "./plugin/plugin-loader"; +export type { + PluginHookErrorMode, + PluginRegistryOptions, + PluginSessionLoadInput, + PluginSessionLoadResult, + PluginStatusListener, +} from "./plugin/plugin-registry"; +export { + DEFAULT_PLUGIN_FAILURE_THRESHOLD, + DEFAULT_PLUGIN_HOOK_TIMEOUT_MS, + DEFAULT_PLUGIN_IMPORT_TIMEOUT_MS, + DEFAULT_PLUGIN_SETUP_TIMEOUT_MS, + DEFAULT_PLUGIN_TOOL_TIMEOUT_MS, + formatSessionPluginIssue, + getProcessPluginRegistry, + PluginCallTimeoutError, + PluginRegistry, +} from "./plugin/plugin-registry"; +export { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "./plugin/plugin-sandbox"; diff --git a/sdk/packages/core/src/extensions/plugin/plugin-config-loader.test.ts b/sdk/packages/core/src/extensions/plugin/plugin-config-loader.test.ts index d8504b26ae..0757eee588 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-config-loader.test.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-config-loader.test.ts @@ -9,6 +9,7 @@ import { resolveAgentPluginSkillDirectories, resolveAndLoadAgentPlugins, resolvePluginConfigSearchPaths, + resolvePluginExecutionMode, resolvePluginSkillDirectoriesFromPaths, } from "./plugin-config-loader"; @@ -16,10 +17,16 @@ describe("plugin-config-loader", () => { const envSnapshot = { HOME: process.env.HOME, CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH, + CLINE_PLUGIN_MODE: process.env.CLINE_PLUGIN_MODE, }; afterEach(() => { process.env.HOME = envSnapshot.HOME; + if (envSnapshot.CLINE_PLUGIN_MODE === undefined) { + delete process.env.CLINE_PLUGIN_MODE; + } else { + process.env.CLINE_PLUGIN_MODE = envSnapshot.CLINE_PLUGIN_MODE; + } process.env.CLINE_GLOBAL_SETTINGS_PATH = envSnapshot.CLINE_GLOBAL_SETTINGS_PATH; setHomeDir(envSnapshot.HOME ?? "~"); @@ -408,8 +415,8 @@ describe("plugin-config-loader", () => { ); await writeFile(invalid, "export default { name: 'broken' };", "utf8"); + // No mode: plugins load in-process by default. const loaded = await resolveAndLoadAgentPlugins({ - mode: "in_process", pluginPaths: [first, invalid, second], cwd: root, }); @@ -422,6 +429,13 @@ describe("plugin-config-loader", () => { expect(testPlugins[0]?.manifest.capabilities).toEqual(["commands"]); expect(loaded.pluginPaths).toEqual([second]); expect(loaded.failures).toHaveLength(1); + expect(loaded.issues).toEqual([ + expect.objectContaining({ + pluginPath: invalid, + state: "failed", + lastError: expect.objectContaining({ phase: "import" }), + }), + ]); expect(loaded.warnings).toHaveLength(1); expect(loaded.warnings[0]?.overriddenPluginPath).toBe(first); } finally { @@ -462,7 +476,6 @@ describe("plugin-config-loader", () => { ); const loaded = await resolveAndLoadAgentPlugins({ - mode: "in_process", pluginPaths: [compatible, incompatible], cwd: root, providerId: "cline", @@ -477,4 +490,56 @@ describe("plugin-config-loader", () => { await rm(root, { recursive: true, force: true }); } }); + + it("runs plugins in workers by default and keeps the other modes opt-in", () => { + delete process.env.CLINE_PLUGIN_MODE; + expect(resolvePluginExecutionMode()).toBe("worker"); + expect(resolvePluginExecutionMode("sandbox")).toBe("sandbox"); + process.env.CLINE_PLUGIN_MODE = "sandbox"; + expect(resolvePluginExecutionMode()).toBe("sandbox"); + process.env.CLINE_PLUGIN_MODE = "in_process"; + expect(resolvePluginExecutionMode()).toBe("in_process"); + process.env.CLINE_PLUGIN_MODE = "unknown"; + expect(resolvePluginExecutionMode()).toBe("worker"); + expect(resolvePluginExecutionMode("in_process")).toBe("in_process"); + }); + + it("applies the session plugin policy", async () => { + const root = await mkdtemp(join(tmpdir(), "core-plugin-config-loader-")); + try { + process.env.HOME = root; + setHomeDir(root); + const kept = join(root, "kept.js"); + const dropped = join(root, "dropped.js"); + await writeFile( + kept, + "export default { name: 'kept-plugin', manifest: { capabilities: ['tools'] } };", + "utf8", + ); + await writeFile( + dropped, + "export default { name: 'dropped-plugin', manifest: { capabilities: ['tools'] } };", + "utf8", + ); + + const loaded = await resolveAndLoadAgentPlugins({ + pluginPaths: [kept, dropped], + cwd: root, + policy: { "dropped-plugin": { enabled: false } }, + }); + + expect(loaded.extensions.map((plugin) => plugin.name)).toEqual([ + "kept-plugin", + ]); + expect(loaded.issues).toEqual([ + expect.objectContaining({ + name: "dropped-plugin", + state: "disabled", + reason: "session_policy", + }), + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/sdk/packages/core/src/extensions/plugin/plugin-config-loader.ts b/sdk/packages/core/src/extensions/plugin/plugin-config-loader.ts index 3583b802ec..ff01d2fcd1 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-config-loader.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-config-loader.ts @@ -1,23 +1,33 @@ import { existsSync, readFileSync, statSync } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, extname, join, resolve } from "node:path"; import type { AgentConfig, + PluginPolicies, PluginSetupContext, + SessionPluginIssue, WorkspaceInfo, } from "@cline/shared"; import { discoverPluginModulePaths as discoverPluginModulePathsFromShared, + getPluginDisplayName, resolveConfiguredPluginModulePaths, resolvePluginConfigSearchPaths as resolvePluginConfigSearchPathsFromShared, SKILLS_CONFIG_DIRECTORY_NAME, } from "@cline/shared/storage"; import { filterDisabledPluginPaths } from "../../services/global-settings"; import type { PluginLoadDiagnostics } from "./plugin-load-report"; -import { loadAgentPluginsFromPathsWithDiagnostics } from "./plugin-loader"; -import { loadSandboxedPlugins } from "./plugin-sandbox"; +import { + getProcessPluginRegistry, + type PluginHookErrorMode, + type PluginRegistry, +} from "./plugin-registry"; +import { + isPluginSandboxBootstrapAvailable, + loadSandboxedPlugins, +} from "./plugin-sandbox"; import type { PluginTargeting } from "./plugin-targeting"; -export { getPluginDisplayName } from "@cline/shared/storage"; +export { getPluginDisplayName }; type AgentPlugin = NonNullable[number]; @@ -178,6 +188,46 @@ function collectPluginSkillRootCandidates(entryPath: string): string[] { return dedupePaths(candidates); } +/** + * Like {@link resolveAgentPluginPaths}, but never throws: configured paths + * that cannot be resolved come back as `discoveryFailures`, and paths turned + * off in settings come back as `disabledPaths`, so callers can report both. + */ +export function resolveAgentPluginPathsWithDiagnostics( + options: ResolveAgentPluginPathsOptions = {}, +): { + paths: string[]; + disabledPaths: string[]; + discoveryFailures: Array<{ pluginPath: string; error: unknown }>; +} { + const cwd = options.cwd ?? process.cwd(); + const discoveryFailures: Array<{ pluginPath: string; error: unknown }> = []; + const configuredPaths: string[] = []; + for (const pluginPath of options.pluginPaths ?? []) { + try { + configuredPaths.push( + ...resolveConfiguredPluginModulePaths([pluginPath], cwd), + ); + } catch (error) { + discoveryFailures.push({ pluginPath: resolve(cwd, pluginPath), error }); + } + } + const all = dedupePaths([ + ...configuredPaths, + ...resolveDiscoveredPluginPaths(options.workspacePath), + ]); + if (options.includeDisabled) { + return { paths: all, disabledPaths: [], discoveryFailures }; + } + const enabled = filterDisabledPluginPaths(all); + const enabledSet = new Set(enabled); + return { + paths: enabled, + disabledPaths: all.filter((path) => !enabledSet.has(path)), + discoveryFailures, + }; +} + export function resolveAgentPluginPaths( options: ResolveAgentPluginPathsOptions = {}, ): string[] { @@ -232,10 +282,40 @@ export function resolveAgentPluginSkillDirectories( ); } +export type PluginExecutionMode = "sandbox" | "worker" | "in_process"; + +export const CLINE_PLUGIN_MODE_ENV = "CLINE_PLUGIN_MODE"; + +/** + * Where plugins run (`CLINE_PLUGIN_MODE`, or `mode`): + * - `worker` (default): each plugin runs in its own worker thread per + * session, inside the host runtime. A plugin crash, `process.exit()`, or + * hung loop ends only its worker, and no `node`/`bun` is needed on PATH. + * - `in_process`: plugins are imported into the host process. + * - `sandbox`: the previous per-session `node`/`bun` subprocess. + * + * Builds that do not ship the sandbox bootstrap (a host bundle without it) + * cannot start workers and fall back to `in_process`. + */ +export function resolvePluginExecutionMode( + mode?: PluginExecutionMode, +): PluginExecutionMode { + const configured = process.env[CLINE_PLUGIN_MODE_ENV]?.trim(); + const requested: PluginExecutionMode = + mode ?? + (configured === "sandbox" || configured === "in_process" + ? configured + : "worker"); + if (requested === "worker" && !isPluginSandboxBootstrapAvailable()) { + return "in_process"; + } + return requested; +} + export interface ResolveAndLoadAgentPluginsOptions extends ResolveAgentPluginPathsOptions, PluginTargeting { - mode?: "sandbox" | "in_process"; + mode?: PluginExecutionMode; exportName?: string; importTimeoutMs?: number; hookTimeoutMs?: number; @@ -253,6 +333,17 @@ export interface ResolveAndLoadAgentPluginsOptions automation?: PluginSetupContext["automation"]; logger?: PluginSetupContext["logger"]; telemetry?: PluginSetupContext["telemetry"]; + /** + * Per-session plugin selection (`"*"` default plus per-plugin overrides). + * In-process only; the sandbox loads every discovered plugin. + */ + policy?: PluginPolicies; + /** How a failing plugin hook affects the run. Defaults to `"ignore"`. */ + hookErrorMode?: PluginHookErrorMode; + /** In-process registry. Defaults to the process-wide registry. */ + registry?: PluginRegistry; + /** In-process only: a plugin this session uses became degraded or failed. */ + onIssue?: (issue: SessionPluginIssue) => void; } export async function resolveAndLoadAgentPlugins( @@ -261,38 +352,80 @@ export async function resolveAndLoadAgentPlugins( { extensions: AgentPlugin[]; pluginPaths: string[]; + /** Plugins the session asked for but does not have (failed, disabled). */ + issues: SessionPluginIssue[]; shutdown?: () => Promise; } & PluginLoadDiagnostics > { - const paths = resolveAgentPluginPaths(options); - if (paths.length === 0) { - return { extensions: [], failures: [], warnings: [], pluginPaths: [] }; - } - - if (options.mode === "in_process") { - const report = await loadAgentPluginsFromPathsWithDiagnostics(paths, { - cwd: options.cwd, + const mode = resolvePluginExecutionMode(options.mode); + if (mode === "in_process" || mode === "worker") { + const resolved = resolveAgentPluginPathsWithDiagnostics(options); + if ( + resolved.paths.length === 0 && + resolved.disabledPaths.length === 0 && + resolved.discoveryFailures.length === 0 + ) { + return { + extensions: [], + failures: [], + warnings: [], + pluginPaths: [], + issues: [], + }; + } + const registry = options.registry ?? getProcessPluginRegistry(); + const loaded = await registry.loadForSession({ + isolation: mode, + sessionId: options.session?.sessionId, + pluginPaths: resolved.paths, + disabledPluginPaths: resolved.disabledPaths, + discoveryFailures: resolved.discoveryFailures, + policy: options.policy, exportName: options.exportName, providerId: options.providerId, modelId: options.modelId, - session: options.session, - client: options.client, - user: options.user, - workspaceInfo: options.workspaceInfo, - automation: options.automation, - logger: options.logger, - telemetry: options.telemetry, + cwd: options.cwd, + hookErrorMode: options.hookErrorMode, + emitEvent: options.onEvent, + onIssue: options.onIssue, + importTimeoutMs: options.importTimeoutMs, + hookTimeoutMs: options.hookTimeoutMs, + callTimeoutMs: options.contributionTimeoutMs, + setupContext: { + session: options.session, + client: options.client, + user: options.user, + workspaceInfo: options.workspaceInfo, + automation: options.automation, + logger: options.logger, + telemetry: options.telemetry, + }, }); return { - extensions: report.plugins, - failures: report.failures, - pluginPaths: report.pluginPaths, - warnings: report.warnings, + extensions: loaded.extensions, + failures: loaded.failures, + pluginPaths: loaded.pluginPaths, + warnings: loaded.warnings, + issues: loaded.issues, + shutdown: async () => loaded.release(), }; } + // The subprocess sandbox does not apply the per-session `policy`; it is + // an opt-in escape hatch and loads every discovered plugin. + const paths = resolveAgentPluginPaths(options); + if (paths.length === 0) { + return { + extensions: [], + failures: [], + warnings: [], + pluginPaths: [], + issues: [], + }; + } const sandboxed = await loadSandboxedPlugins({ pluginPaths: paths, + transport: "subprocess", exportName: options.exportName, importTimeoutMs: options.importTimeoutMs, hookTimeoutMs: options.hookTimeoutMs, @@ -314,5 +447,22 @@ export async function resolveAndLoadAgentPlugins( failures: sandboxed.failures, pluginPaths: sandboxed.pluginPaths, warnings: sandboxed.warnings, + issues: sandboxed.failures.map( + (failure): SessionPluginIssue => ({ + name: + failure.pluginName ?? + basename(failure.pluginPath, extname(failure.pluginPath)), + pluginPath: failure.pluginPath, + state: "failed", + reason: "error", + lastError: { + phase: failure.phase === "setup" ? "setup" : "import", + message: failure.message, + stack: failure.stack, + pluginPath: failure.pluginPath, + timestamp: Date.now(), + }, + }), + ), }; } diff --git a/sdk/packages/core/src/extensions/plugin/plugin-contributions.ts b/sdk/packages/core/src/extensions/plugin/plugin-contributions.ts new file mode 100644 index 0000000000..39f29f3c6b --- /dev/null +++ b/sdk/packages/core/src/extensions/plugin/plugin-contributions.ts @@ -0,0 +1,243 @@ +import type { + AgentExtension, + AgentExtensionApi, + AgentExtensionCommand, + AgentExtensionMcpServer, + AgentExtensionProvider, + AgentExtensionRule, + AgentTool, + Message, + PluginStatusRecord, + SessionPluginIssue, +} from "@cline/shared"; +import { + type PluginExecutionMode, + resolvePluginExecutionMode, +} from "./plugin-config-loader"; +import type { + PluginInitializationFailure, + PluginInitializationWarning, +} from "./plugin-load-report"; +import { + derivePluginNameFromPath, + getProcessPluginRegistry, + type PluginRegistry, +} from "./plugin-registry"; +import { loadSandboxedPlugins } from "./plugin-sandbox"; +import type { PluginTargeting } from "./plugin-targeting"; + +type AgentExtensionWithPath = AgentExtension & { __clinePluginPath?: string }; + +export interface CollectedPluginContributions { + pluginName: string; + pluginPath: string; + extension: AgentExtension; + tools: AgentTool[]; + rules: AgentExtensionRule[]; + commands: AgentExtensionCommand[]; + mcpServers: AgentExtensionMcpServer[]; + providers: AgentExtensionProvider[]; +} + +export interface CollectPluginContributionsInput extends PluginTargeting { + pluginPaths: ReadonlyArray; + disabledPluginPaths?: ReadonlyArray; + discoveryFailures?: ReadonlyArray<{ pluginPath: string; error: unknown }>; + cwd?: string; + workspacePath?: string; + registry?: PluginRegistry; + /** Defaults to the configured execution mode (`CLINE_PLUGIN_MODE`). */ + mode?: PluginExecutionMode; +} + +export interface CollectPluginContributionsResult { + plugins: CollectedPluginContributions[]; + /** Status of every plugin that failed to import or set up. */ + failed: PluginStatusRecord[]; + failures: PluginInitializationFailure[]; + warnings: PluginInitializationWarning[]; +} + +function collectingApi(collected: CollectedPluginContributions): { + api: AgentExtensionApi; +} { + return { + api: { + registerTool: (tool) => collected.tools.push(tool), + registerCommand: (command) => collected.commands.push(command), + registerMessageBuilder: () => {}, + registerRule: (rule) => collected.rules.push(rule), + registerProvider: (provider) => collected.providers.push(provider), + registerAutomationEventType: () => {}, + registerMcpServer: (server) => collected.mcpServers.push(server), + }, + }; +} + +function emptyContributions( + extension: AgentExtension, + pluginPath: string, +): CollectedPluginContributions { + return { + pluginName: extension.name, + pluginPath, + extension, + tools: [], + rules: [], + commands: [], + mcpServers: [], + providers: [], + }; +} + +/** + * Runs each plugin's `setup` with a collecting API, for settings surfaces + * that list what plugins contribute without starting a session. Plugins load + * the same way sessions load them: through the shared registry (in workers + * by default, or in-process), so failures are recorded there like any + * other, or through the subprocess sandbox when `CLINE_PLUGIN_MODE=sandbox`. + */ +export async function collectPluginContributions( + input: CollectPluginContributionsInput, +): Promise { + const mode = resolvePluginExecutionMode(input.mode); + if (mode === "sandbox") { + return collectSandboxedContributions(input); + } + const registry = input.registry ?? getProcessPluginRegistry(); + const workspaceInfo = input.workspacePath + ? { rootPath: input.workspacePath } + : undefined; + const setupFailures = new Map(); + const loaded = await registry.loadForSession({ + isolation: mode, + pluginPaths: input.pluginPaths, + disabledPluginPaths: input.disabledPluginPaths, + discoveryFailures: input.discoveryFailures, + providerId: input.providerId, + modelId: input.modelId, + cwd: input.cwd, + setupContext: { workspaceInfo }, + // Setup failures are reported to the copy that failed, so this listing + // knows about its own failure even when another session's setup of + // the same plugin succeeds concurrently. + onIssue: (issue) => { + if (issue.lastError?.phase === "setup") { + setupFailures.set(issue.pluginPath, issue); + } + }, + }); + const plugins: CollectedPluginContributions[] = []; + const failures = [...loaded.failures]; + const failed: PluginStatusRecord[] = loaded.issues.flatMap((issue) => + issue.state === "failed" ? registry.get(issue.pluginPath) : [], + ); + try { + for (const extension of loaded.extensions) { + const pluginPath = (extension as AgentExtensionWithPath) + .__clinePluginPath; + if (!pluginPath) continue; + const collected = emptyContributions(extension, pluginPath); + try { + await extension.setup?.(collectingApi(collected).api, { + workspaceInfo, + }); + } catch { + // Registration errors are recorded as setup failures below. + } + const issue = setupFailures.get(pluginPath); + if (issue) { + failed.push({ + ...(registry.get(pluginPath)[0] ?? { + name: issue.name, + pluginPath, + errorCount: 1, + timeoutCount: 0, + sessionIds: [], + updatedAt: Date.now(), + }), + state: "failed", + ...(issue.lastError ? { lastError: issue.lastError } : {}), + }); + failures.push({ + pluginPath, + pluginName: extension.name, + phase: "setup", + message: issue.lastError?.message ?? "Plugin setup failed", + stack: issue.lastError?.stack, + }); + continue; + } + plugins.push(collected); + } + } finally { + // Runs onDispose cleanup and clears timers the setups started. + await loaded.release(); + } + return { plugins, failed, failures, warnings: loaded.warnings }; +} + +async function collectSandboxedContributions( + input: CollectPluginContributionsInput, +): Promise { + if (input.pluginPaths.length === 0) { + return { plugins: [], failed: [], failures: [], warnings: [] }; + } + const workspaceInfo = input.workspacePath + ? { rootPath: input.workspacePath } + : undefined; + const sandboxed = await loadSandboxedPlugins({ + pluginPaths: [...input.pluginPaths], + transport: "subprocess", + cwd: input.cwd, + providerId: input.providerId, + modelId: input.modelId, + workspaceInfo, + }); + const failures: PluginInitializationFailure[] = [...sandboxed.failures]; + const plugins: CollectedPluginContributions[] = []; + try { + for (const extension of sandboxed.extensions ?? []) { + const pluginPath = (extension as AgentExtensionWithPath) + .__clinePluginPath; + if (!pluginPath) continue; + const collected = emptyContributions(extension, pluginPath); + try { + await extension.setup?.(collectingApi(collected).api, { + workspaceInfo, + }); + } catch (error) { + failures.push({ + pluginPath, + pluginName: extension.name, + phase: "setup", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + continue; + } + plugins.push(collected); + } + } finally { + await sandboxed.shutdown().catch(() => { + // Best effort cleanup after contribution discovery. + }); + } + const failed: PluginStatusRecord[] = failures.map((failure) => ({ + name: failure.pluginName ?? derivePluginNameFromPath(failure.pluginPath), + pluginPath: failure.pluginPath, + state: "failed", + lastError: { + phase: failure.phase === "setup" ? "setup" : "import", + message: failure.message, + ...(failure.stack ? { stack: failure.stack } : {}), + pluginPath: failure.pluginPath, + timestamp: Date.now(), + }, + errorCount: 1, + timeoutCount: 0, + sessionIds: [], + updatedAt: Date.now(), + })); + return { plugins, failed, failures, warnings: sandboxed.warnings }; +} diff --git a/sdk/packages/core/src/extensions/plugin/plugin-loader.ts b/sdk/packages/core/src/extensions/plugin/plugin-loader.ts index 9b2a2614ba..35a4a34a3b 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-loader.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-loader.ts @@ -24,6 +24,8 @@ export interface LoadAgentPluginFromPathOptions { exportName?: string; cwd?: string; useCache?: boolean; + /** Fresh module evaluation that still reuses the transform cache. */ + freshModule?: boolean; session?: PluginSetupContext["session"]; client?: PluginSetupContext["client"]; user?: PluginSetupContext["user"]; @@ -109,6 +111,7 @@ export async function loadAgentPluginFromPath( const absolutePath = resolve(options.cwd ?? process.cwd(), pluginPath); const moduleExports = await importPluginModule(absolutePath, { useCache: options.useCache, + freshModule: options.freshModule, }); const exportName = options.exportName ?? "plugin"; const plugin = (moduleExports.default ?? @@ -135,7 +138,7 @@ export async function loadAgentPluginFromPath( logger: options.logger ?? _ctx.logger, telemetry: options.telemetry ?? _ctx.telemetry, }; - return originalSetup(api, ctx); + return originalSetup.call(pluginTyped, api, ctx); } : undefined; diff --git a/sdk/packages/core/src/extensions/plugin/plugin-module-import.ts b/sdk/packages/core/src/extensions/plugin/plugin-module-import.ts index 34d3c01bc2..34eb9494f5 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-module-import.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-module-import.ts @@ -40,6 +40,11 @@ const WORKSPACE_EXPORT_CONDITIONS = [ export interface ImportPluginModuleOptions { useCache?: boolean; + /** + * Evaluate the module again (no module cache) but reuse jiti's on-disk + * transform cache, so a fresh per-session copy skips the babel transform. + */ + freshModule?: boolean; } function collectWorkspaceAliases(root: string): Record { @@ -478,6 +483,54 @@ function assertPluginDependenciesInstalled( } } +/** + * Content fingerprint of a plugin's own source: the entry file plus every + * file it reaches through relative static imports (`node_modules` and other + * bare imports are not included). Used to tell whether a plugin changed since + * it was last imported, including edits outside the entry file and edits that + * keep the same size within one clock tick. + */ +export function fingerprintPluginSources(pluginPath: string): string { + const parts: string[] = []; + const visit = (filePath: string, seen: Set) => { + if (seen.has(filePath)) return; + seen.add(filePath); + let source: string; + try { + source = readFileSync(filePath, "utf8"); + } catch { + parts.push(`${filePath}:missing`); + return; + } + parts.push(`${filePath}:${source.length}:${checksum(source)}`); + if (!SUPPORTED_PLUGIN_EXTENSIONS.has(extname(filePath))) return; + for (const specifier of collectStaticModuleSpecifiers(source)) { + if (isBareSpecifier(specifier)) continue; + const resolvedPath = resolveRelativeImportPath(filePath, specifier); + if (resolvedPath) visit(resolvedPath, seen); + } + }; + visit(pluginPath, new Set()); + return parts.join("|"); +} + +/** + * Non-cryptographic change detector (two seeded 32-bit FNV-1a passes plus + * the length above). It only has to notice edits to plugin source, so a + * cryptographic digest is unnecessary. + */ +function checksum(text: string): string { + let first = 0x811c9dc5; + let second = 0x01000193 ^ text.length; + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + first = Math.imul(first ^ code, 0x01000193); + second = Math.imul(second ^ code, 0x5bd1e995); + second ^= second >>> 15; + } + return `${(first >>> 0).toString(36)}${(second >>> 0).toString(36)}`; +} + function collectPluginStaticModuleSpecifiers( pluginPath: string, seen = new Set(), @@ -619,6 +672,38 @@ function loadJitiBabelTransform(): JitiTransform | null { return cachedJitiTransform; } +let cachedStaticJitiTransform: Promise | undefined; + +/** + * Fallback for compiled binaries run without an install layout next to them + * (no wrapper `node_modules/jiti` on disk). `jiti/static` imports babel + * statically, so `bun build --compile` bundles it into the binary. Its + * instance `transform` forwards our options last, so the `interopDefault` + * override below still reaches babel. + */ +function loadStaticJitiTransform(): Promise { + cachedStaticJitiTransform ??= (async () => { + try { + const staticModule = (await import("jiti/static")) as unknown as { + createJiti?: ( + id: string, + opts?: Record, + ) => { transform: (opts: Parameters[0]) => string }; + }; + const createStaticJiti = staticModule.createJiti; + if (typeof createStaticJiti !== "function") return null; + const instance = createStaticJiti(MODULE_DIR, { + cache: false, + interopDefault: false, + }); + return (opts) => ({ code: instance.transform(opts) }); + } catch { + return null; + } + })(); + return cachedStaticJitiTransform; +} + export async function importPluginModule( pluginPath: string, options: ImportPluginModuleOptions = {}, @@ -658,14 +743,15 @@ export async function importPluginModule( // plugins). Pin `interopDefault: true` going into babel by overriding it // in the transform call, while keeping `interopDefault: false` on the jiti // instance so the loader sees raw exports. - const baseBabelTransform = loadJitiBabelTransform(); + const baseBabelTransform = + loadJitiBabelTransform() ?? (await loadStaticJitiTransform()); const babelTransform: JitiTransform | undefined = baseBabelTransform ? (opts) => baseBabelTransform({ ...opts, interopDefault: true }) : undefined; const jiti = createJiti(pluginPath, { alias: sortedAliases, - cache: options.useCache, - requireCache: options.useCache, + cache: options.freshModule ? true : options.useCache, + requireCache: options.freshModule ? false : options.useCache, esmResolve: true, interopDefault: false, nativeModules: [...BUILTIN_MODULES], diff --git a/sdk/packages/core/src/extensions/plugin/plugin-registry-worker.test.ts b/sdk/packages/core/src/extensions/plugin/plugin-registry-worker.test.ts new file mode 100644 index 0000000000..6ef0699f74 --- /dev/null +++ b/sdk/packages/core/src/extensions/plugin/plugin-registry-worker.test.ts @@ -0,0 +1,591 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + AgentExtension, + AgentExtensionApi, + AgentTool, + AgentToolContext, + Message, + PluginStatusRecord, + SessionPluginIssue, +} from "@cline/shared"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + PluginRegistry, + type PluginSessionLoadInput, + type PluginSessionLoadResult, +} from "./plugin-registry"; + +async function toolsOf(extension: AgentExtension | undefined) { + const tools: AgentTool[] = []; + const api = { + registerTool: (tool: AgentTool) => tools.push(tool), + registerCommand: () => {}, + registerRule: () => {}, + registerMessageBuilder: () => {}, + registerProvider: () => {}, + registerAutomationEventType: () => {}, + registerMcpServer: () => {}, + } as AgentExtensionApi; + await extension?.setup?.(api, {}); + return tools; +} + +const toolContext: AgentToolContext = { agentId: "agent", iteration: 1 }; + +/** A plugin whose tool runs `body` (an async function body taking `input`). */ +function toolPlugin(name: string, body = `return "ok from ${name}";`) { + return `import { isMainThread } from "node:worker_threads"; +export default { + name: ${JSON.stringify(name)}, + manifest: { capabilities: ["tools", "hooks"] }, + hooks: { beforeRun() {} }, + setup(api) { + api.registerTool({ + name: ${JSON.stringify(`${name}_tool`)}, + description: "test tool", + inputSchema: { type: "object" }, + execute: async (input) => { ${body} }, + }); + }, +}; +`; +} + +describe("PluginRegistry (worker isolation)", () => { + let root: string; + let registry: PluginRegistry; + let statusEvents: PluginStatusRecord[]; + const sessions: PluginSessionLoadResult[] = []; + + const write = async (file: string, source: string) => { + const path = join(root, file); + await writeFile(path, source, "utf8"); + return path; + }; + + const load = async ( + input: Omit, + ): Promise => { + const loaded = await registry.loadForSession({ + ...input, + isolation: "worker", + }); + sessions.push(loaded); + return loaded; + }; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "core-plugin-registry-worker-")); + registry = new PluginRegistry({ + hookTimeoutMs: 2_000, + toolTimeoutMs: 2_000, + importTimeoutMs: 15_000, + failureThreshold: 3, + }); + statusEvents = []; + registry.subscribe((status) => statusEvents.push(status)); + }); + + afterEach(async () => { + await Promise.all(sessions.splice(0).map((session) => session.release())); + await rm(root, { recursive: true, force: true }); + }); + + it("runs the plugin off the main thread and reports it ready", async () => { + const path = await write( + "thread.mjs", + toolPlugin("thread", "return { isMainThread };"), + ); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + const [tool] = await toolsOf(session.extensions[0]); + await expect(tool?.execute({}, toolContext)).resolves.toEqual({ + isMainThread: false, + }); + const [status] = registry.get("thread"); + expect(status).toMatchObject({ + name: "thread", + state: "ready", + sessionIds: ["s1"], + hooks: ["beforeRun"], + }); + expect(status?.capabilities).toContain("tools"); + }, 30_000); + + it("gives each session its own worker", async () => { + const path = await write( + "counter.mjs", + toolPlugin( + "counter", + "globalThis.n = (globalThis.n ?? 0) + 1; return globalThis.n;", + ), + ); + const first = await load({ sessionId: "a", pluginPaths: [path] }); + const second = await load({ sessionId: "b", pluginPaths: [path] }); + const [toolA] = await toolsOf(first.extensions[0]); + const [toolB] = await toolsOf(second.extensions[0]); + await toolA?.execute({}, toolContext); + await expect(toolA?.execute({}, toolContext)).resolves.toBe(2); + await expect(toolB?.execute({}, toolContext)).resolves.toBe(1); + }, 30_000); + + it("applies the session policy", async () => { + const kept = await write("kept.mjs", toolPlugin("kept")); + const dropped = await write("dropped.mjs", toolPlugin("dropped")); + const session = await load({ + sessionId: "s1", + pluginPaths: [kept, dropped], + policy: { dropped: { enabled: false } }, + }); + expect(session.extensions.map((extension) => extension.name)).toEqual([ + "kept", + ]); + expect(session.issues).toContainEqual( + expect.objectContaining({ + name: "dropped", + state: "disabled", + reason: "session_policy", + }), + ); + }, 30_000); + + it("marks a plugin that fails to import failed for every session", async () => { + const broken = await write( + "broken.mjs", + "throw new Error('boom at import');\nexport default {};", + ); + const good = await write("good.mjs", toolPlugin("good")); + const session = await load({ + sessionId: "s1", + pluginPaths: [broken, good], + }); + expect(session.extensions.map((extension) => extension.name)).toEqual([ + "good", + ]); + expect(session.issues).toContainEqual( + expect.objectContaining({ name: "broken", state: "failed" }), + ); + expect(registry.get("broken")[0]?.lastError?.message).toMatch( + /boom at import/, + ); + const next = await load({ sessionId: "s2", pluginPaths: [broken] }); + expect(next.extensions).toHaveLength(0); + expect(next.issues[0]).toMatchObject({ name: "broken", state: "failed" }); + }, 30_000); + + it("fails only the session whose setup threw", async () => { + const path = await write( + "flaky.mjs", + `export default { + name: "flaky", + manifest: { capabilities: ["tools"] }, + setup() { if (process.env.FLAKY_FAIL === "1") throw new Error("setup boom"); }, +};`, + ); + process.env.FLAKY_FAIL = "1"; + try { + const failing = await load({ sessionId: "s1", pluginPaths: [path] }); + expect(failing.issues).toContainEqual( + expect.objectContaining({ name: "flaky", state: "failed" }), + ); + expect(registry.get("flaky")[0]?.lastError?.phase).toBe("setup"); + } finally { + delete process.env.FLAKY_FAIL; + } + // Setup failure does not block the plugin: the next session tries again. + const working = await load({ sessionId: "s2", pluginPaths: [path] }); + expect(working.extensions.map((extension) => extension.name)).toEqual([ + "flaky", + ]); + expect(registry.get("flaky")[0]?.state).toBe("ready"); + }, 30_000); + + it("degrades on tool failures and turns the plugin off at the threshold", async () => { + const path = await write( + "thrower.mjs", + toolPlugin("thrower", "throw new Error('tool boom');"), + ); + const issues: SessionPluginIssue[] = []; + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + onIssue: (issue) => issues.push(issue), + }); + const [tool] = await toolsOf(session.extensions[0]); + for (let attempt = 0; attempt < 3; attempt++) { + await expect(tool?.execute({}, toolContext)).rejects.toThrow(/tool boom/); + } + expect(registry.get("thrower")[0]?.state).toBe("failed"); + expect(issues.map((issue) => issue.state)).toEqual(["degraded", "failed"]); + await expect(tool?.execute({}, toolContext)).rejects.toThrow( + /is unavailable/, + ); + const next = await load({ sessionId: "s2", pluginPaths: [path] }); + expect(next.extensions).toHaveLength(0); + }, 30_000); + + it("turns a plugin off when its worker dies, and the host keeps running", async () => { + const path = await write( + "crasher.mjs", + toolPlugin( + "crasher", + "setTimeout(() => { Promise.reject(new Error('stray rejection')); }, 0); return 'scheduled';", + ), + ); + const issues: SessionPluginIssue[] = []; + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + onIssue: (issue) => issues.push(issue), + }); + const [tool] = await toolsOf(session.extensions[0]); + await expect(tool?.execute({}, toolContext)).resolves.toBe("scheduled"); + await expect + .poll(() => registry.get("crasher")[0]?.state, { timeout: 10_000 }) + .toBe("failed"); + expect(registry.get("crasher")[0]?.lastError).toMatchObject({ + phase: "uncaught", + }); + expect(issues.at(-1)).toMatchObject({ name: "crasher", state: "failed" }); + }, 30_000); + + it("reports process.exit during setup as a failed plugin", async () => { + const path = await write( + "exiter.mjs", + `export default { + name: "exiter", + manifest: { capabilities: ["tools"] }, + setup() { process.exit(1); }, +};`, + ); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + expect(session.extensions).toHaveLength(0); + expect(registry.get("exiter")[0]?.state).toBe("failed"); + expect(registry.get("exiter")[0]?.lastError).toMatchObject({ + phase: "setup", + message: expect.stringMatching(/exited \(code=1\)/), + }); + }, 30_000); + + it("stops a hung tool at its timeout without failing the host", async () => { + const path = await write( + "spinner.mjs", + toolPlugin("spinner", "if (input.spin) { for (;;) {} } return 'ok';"), + ); + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + callTimeoutMs: 500, + }); + const [tool] = await toolsOf(session.extensions[0]); + await expect(tool?.execute({ spin: true }, toolContext)).rejects.toThrow( + /timed out/, + ); + expect(registry.get("spinner")[0]).toMatchObject({ + state: "degraded", + timeoutCount: 1, + }); + }, 30_000); + + it("reloads a fixed plugin and keeps stale copies from turning it off", async () => { + const path = await write( + "fixme.mjs", + toolPlugin("fixme", "throw new Error('old code');"), + ); + const old = await load({ sessionId: "old", pluginPaths: [path] }); + const [oldTool] = await toolsOf(old.extensions[0]); + await expect(oldTool?.execute({}, toolContext)).rejects.toThrow(/old code/); + + await write("fixme.mjs", toolPlugin("fixme", "return 'new code';")); + const [reloaded] = await registry.reload("fixme"); + expect(reloaded).toMatchObject({ state: "ready", errorCount: 0 }); + + // The old session's copy keeps failing; that must not count. + for (let attempt = 0; attempt < 3; attempt++) { + await expect(oldTool?.execute({}, toolContext)).rejects.toThrow( + /old code/, + ); + } + expect(registry.get("fixme")[0]?.state).toBe("ready"); + const fresh = await load({ sessionId: "new", pluginPaths: [path] }); + const [freshTool] = await toolsOf(fresh.extensions[0]); + await expect(freshTool?.execute({}, toolContext)).resolves.toBe("new code"); + }, 30_000); + + it("preloads status in throwaway workers", async () => { + const good = await write("pre-good.mjs", toolPlugin("pre-good")); + const bad = await write( + "pre-bad.mjs", + "throw new Error('nope');\nexport default {};", + ); + const statuses = await registry.preload({ + pluginPaths: [good, bad], + isolation: "worker", + }); + expect( + statuses.map(({ name, state, sessionIds }) => ({ + name, + state, + sessionIds, + })), + ).toEqual([ + { name: "pre-bad", state: "failed", sessionIds: [] }, + { name: "pre-good", state: "ready", sessionIds: [] }, + ]); + }, 30_000); + + it("gives setup the session cwd, events, and onDispose cleanup", async () => { + const marker = join(root, "disposed.txt"); + const path = await write( + "ctx.mjs", + `import { writeFileSync } from "node:fs"; +export default { + name: "ctx", + manifest: { capabilities: ["tools"] }, + setup(api, ctx) { + ctx.emitEvent("setup_ctx", { cwd: ctx.cwd }); + ctx.onDispose(() => writeFileSync(${JSON.stringify(marker)}, "done")); + api.registerTool({ + name: "ctx_tool", + description: "test tool", + inputSchema: { type: "object" }, + execute: async () => ctx.cwd, + }); + }, +};`, + ); + const events: Array<{ name: string; payload?: unknown }> = []; + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + cwd: root, + emitEvent: (event) => events.push(event), + }); + const [tool] = await toolsOf(session.extensions[0]); + await expect(tool?.execute({}, toolContext)).resolves.toBe(root); + expect(events).toContainEqual({ + name: "setup_ctx", + payload: { cwd: root }, + }); + await session.release(); + await expect(readFile(marker, "utf8")).resolves.toBe("done"); + }, 30_000); + + it("bridges abort, progress updates, and events in the tool context", async () => { + const path = await write( + "live.mjs", + toolPlugin( + "live", + `context.emitUpdate({ step: 1 }); + context.emitEvent("tool_event", { from: "tool" }); + if (!input.wait) return "done"; + return await new Promise((resolve) => { + context.signal.addEventListener("abort", () => resolve("aborted")); + });`, + ).replace( + "execute: async (input) =>", + "execute: async (input, context) =>", + ), + ); + const events: Array<{ name: string; payload?: unknown }> = []; + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + emitEvent: (event) => events.push(event), + }); + const [tool] = await toolsOf(session.extensions[0]); + const updates: unknown[] = []; + const controller = new AbortController(); + const running = tool?.execute( + { wait: true }, + { + ...toolContext, + signal: controller.signal, + emitUpdate: (update) => updates.push(update), + }, + ); + await expect.poll(() => updates.length).toBe(1); + controller.abort(); + await expect(running).resolves.toBe("aborted"); + expect(updates).toEqual([{ step: 1 }]); + expect(events).toContainEqual({ + name: "tool_event", + payload: { from: "tool" }, + }); + }, 30_000); + + it("keeps the tool definition fields the plugin declared", async () => { + const path = await write( + "definition.mjs", + `export default { + name: "definition", + manifest: { capabilities: ["tools"] }, + setup(api) { + api.registerTool({ + name: "finish", + description: "ends the run", + inputSchema: { type: "object" }, + lifecycle: { completesRun: true }, + executionMode: "sequential", + resultPolicy: "cache-oversized", + maxRetries: 2, + execute: async () => "ok", + }); + }, +};`, + ); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + const [tool] = await toolsOf(session.extensions[0]); + expect(tool).toMatchObject({ + name: "finish", + lifecycle: { completesRun: true }, + executionMode: "sequential", + resultPolicy: "cache-oversized", + maxRetries: 2, + }); + }, 30_000); + + it("fails only the session whose setup is too slow", async () => { + const path = await write( + "slow.mjs", + `export default { + name: "slow", + manifest: { capabilities: ["tools"] }, + async setup() { + if (process.env.SLOW_SETUP === "1") await new Promise((r) => setTimeout(r, 5000)); + }, +};`, + ); + const slowRegistry = new PluginRegistry({ + setupTimeoutMs: 300, + importTimeoutMs: 15_000, + }); + process.env.SLOW_SETUP = "1"; + let slow: PluginSessionLoadResult | undefined; + try { + slow = await slowRegistry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + isolation: "worker", + }); + } finally { + delete process.env.SLOW_SETUP; + } + expect(slow.extensions).toHaveLength(0); + expect(slowRegistry.get("slow")[0]?.lastError).toMatchObject({ + phase: "setup", + message: expect.stringMatching(/timed out/), + }); + const next = await slowRegistry.loadForSession({ + sessionId: "s2", + pluginPaths: [path], + isolation: "worker", + }); + expect(next.extensions.map((extension) => extension.name)).toEqual([ + "slow", + ]); + await Promise.all([slow.release(), next.release()]); + }, 30_000); + + it("treats a setup that hangs the worker as a setup failure", async () => { + const path = await write( + "hang.mjs", + `export default { + name: "hang", + manifest: { capabilities: ["tools"] }, + setup() { if (process.env.HANG_SETUP === "1") { for (;;) {} } }, +};`, + ); + // A synchronous loop blocks the in-worker setup timer, so the host's + // import + setup deadline is what stops it. + const hangRegistry = new PluginRegistry({ + setupTimeoutMs: 300, + importTimeoutMs: 3_000, + }); + process.env.HANG_SETUP = "1"; + try { + const hung = await hangRegistry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + isolation: "worker", + }); + expect(hung.extensions).toHaveLength(0); + } finally { + delete process.env.HANG_SETUP; + } + expect(hangRegistry.get("hang")[0]?.lastError?.phase).toBe("setup"); + const next = await hangRegistry.loadForSession({ + sessionId: "s2", + pluginPaths: [path], + isolation: "worker", + }); + expect(next.extensions).toHaveLength(1); + await next.release(); + }, 60_000); + + it("applies a policy that names the plugin by its exported name", async () => { + // The file name (kept.mjs) differs from the plugin's own name. + const path = await write("kept.mjs", toolPlugin("kept-plugin")); + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + policy: { "*": { enabled: false }, "kept-plugin": { enabled: true } }, + }); + expect(session.extensions.map((extension) => extension.name)).toEqual([ + "kept-plugin", + ]); + }, 30_000); + + it("does not run setup for a plugin the policy turns off by exported name", async () => { + const marker = join(root, "setup-ran.txt"); + const path = await write( + "off.mjs", + `import { writeFileSync } from "node:fs"; +export default { + name: "off-plugin", + manifest: { capabilities: ["tools"] }, + setup() { writeFileSync(${JSON.stringify(marker)}, "ran"); }, +};`, + ); + const policy = { "off-plugin": { enabled: false } }; + const first = await load({ sessionId: "s1", pluginPaths: [path], policy }); + expect(first.extensions).toHaveLength(0); + expect(first.issues).toContainEqual( + expect.objectContaining({ + name: "off-plugin", + state: "disabled", + reason: "session_policy", + }), + ); + await expect(readFile(marker, "utf8")).rejects.toThrow(); + // Once the name is known, later sessions skip the worker entirely. + const second = await load({ sessionId: "s2", pluginPaths: [path], policy }); + expect(second.issues[0]).toMatchObject({ + name: "off-plugin", + reason: "session_policy", + }); + }, 30_000); + + it("runs a failed setup's onDispose cleanup", async () => { + const marker = join(root, "cleaned-after-failure.txt"); + const path = await write( + "fails-after-start.mjs", + `import { writeFileSync } from "node:fs"; +export default { + name: "fails-after-start", + manifest: { capabilities: ["tools"] }, + setup(_api, ctx) { + ctx.onDispose(() => writeFileSync(${JSON.stringify(marker)}, "cleaned")); + throw new Error("setup failed after starting work"); + }, +};`, + ); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + expect(session.extensions).toHaveLength(0); + expect(registry.get("fails-after-start")[0]?.lastError?.phase).toBe( + "setup", + ); + await expect(readFile(marker, "utf8")).resolves.toBe("cleaned"); + }, 30_000); +}); diff --git a/sdk/packages/core/src/extensions/plugin/plugin-registry.test.ts b/sdk/packages/core/src/extensions/plugin/plugin-registry.test.ts new file mode 100644 index 0000000000..e31ee78422 --- /dev/null +++ b/sdk/packages/core/src/extensions/plugin/plugin-registry.test.ts @@ -0,0 +1,1061 @@ +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + AgentExtension, + AgentExtensionApi, + AgentTool, + AgentToolContext, + Message, + PluginStatusRecord, +} from "@cline/shared"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { PluginCallTimeoutError, PluginRegistry } from "./plugin-registry"; + +type Collected = { tools: AgentTool[]; rules: unknown[] }; + +function collectingApi(): { + api: AgentExtensionApi; + collected: Collected; +} { + const collected: Collected = { tools: [], rules: [] }; + return { + collected, + api: { + registerTool: (tool) => collected.tools.push(tool), + registerCommand: () => {}, + registerRule: (rule) => collected.rules.push(rule), + registerMessageBuilder: () => {}, + registerProvider: () => {}, + registerAutomationEventType: () => {}, + registerMcpServer: () => {}, + }, + }; +} + +async function setUp( + extension: AgentExtension | undefined, +): Promise { + const { api, collected } = collectingApi(); + await extension?.setup?.(api, {}); + return collected; +} + +const toolContext: AgentToolContext = { agentId: "agent", iteration: 1 }; + +function toolPlugin(name: string, toolName = `${name}_tool`): string { + return `export default { + name: ${JSON.stringify(name)}, + manifest: { capabilities: ["tools"] }, + setup(api) { + api.registerTool({ + name: ${JSON.stringify(toolName)}, + description: "test tool", + inputSchema: { type: "object" }, + execute: async () => "ok from ${name}", + }); + }, +}; +`; +} + +describe("PluginRegistry", () => { + let root: string; + let registry: PluginRegistry; + let statusEvents: PluginStatusRecord[]; + + const write = async (file: string, source: string) => { + const path = join(root, file); + await writeFile(path, source, "utf8"); + return path; + }; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "core-plugin-registry-")); + registry = new PluginRegistry({ + hookTimeoutMs: 100, + toolTimeoutMs: 100, + setupTimeoutMs: 500, + failureThreshold: 3, + }); + statusEvents = []; + registry.subscribe((status) => statusEvents.push(status)); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("loads a plugin once and reports it ready with its sessions", async () => { + const path = await write("good.js", toolPlugin("good")); + const first = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + }); + const second = await registry.loadForSession({ + sessionId: "s2", + pluginPaths: [path], + }); + + const collected = await setUp(first.extensions[0]); + expect(collected.tools.map((tool) => tool.name)).toEqual(["good_tool"]); + await expect(collected.tools[0]?.execute({}, toolContext)).resolves.toBe( + "ok from good", + ); + expect(second.extensions).toHaveLength(1); + + const [status] = registry.list(); + expect(status).toMatchObject({ + name: "good", + state: "ready", + errorCount: 0, + sessionIds: ["s1", "s2"], + }); + expect(statusEvents.map((event) => event.state)).toEqual([ + "loading", + "ready", + ]); + + first.release(); + expect(registry.list()[0]?.sessionIds).toEqual(["s2"]); + }); + + it("marks a plugin that throws on import failed and keeps the others", async () => { + const good = await write("good.js", toolPlugin("good")); + const broken = await write( + "throws-on-import.js", + `throw new Error("import exploded");\nexport default {};\n`, + ); + + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [broken, good], + }); + + expect(loaded.extensions.map((extension) => extension.name)).toEqual([ + "good", + ]); + const status = registry.get(broken)[0]; + expect(status?.state).toBe("failed"); + expect(status?.lastError?.phase).toBe("import"); + expect(status?.lastError?.message).toContain("import exploded"); + expect(loaded.failures).toEqual([ + expect.objectContaining({ pluginPath: broken, phase: "load" }), + ]); + expect(loaded.issues).toEqual([ + expect.objectContaining({ + pluginPath: broken, + state: "failed", + reason: "error", + }), + ]); + }); + + it("marks a plugin that throws in setup failed without partial registrations", async () => { + const good = await write("good.js", toolPlugin("good")); + const broken = await write( + "throws-in-setup.js", + `export default { + name: "throws-in-setup", + manifest: { capabilities: ["tools"] }, + setup(api) { + api.registerTool({ name: "half", description: "", inputSchema: {}, execute: () => "" }); + throw new Error("setup exploded"); + }, +}; +`, + ); + + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [broken, good], + }); + const [brokenExtension, goodExtension] = loaded.extensions; + expect((await setUp(brokenExtension)).tools).toEqual([]); + expect((await setUp(goodExtension)).tools).toHaveLength(1); + + const status = registry.get("throws-in-setup")[0]; + expect(status?.state).toBe("failed"); + expect(status?.lastError).toMatchObject({ + phase: "setup", + message: "setup exploded", + sessionId: "s1", + }); + + // Setup failure is per session: the next session gets a fresh copy, + // tries again, and is told about its own failure. + const told: string[] = []; + const next = await registry.loadForSession({ + sessionId: "s2", + pluginPaths: [broken, good], + onIssue: (issue) => told.push(`${issue.name}:${issue.state}`), + }); + expect(next.extensions.map((extension) => extension.name)).toEqual([ + "throws-in-setup", + "good", + ]); + expect((await setUp(next.extensions[0])).tools).toEqual([]); + expect(told).toEqual(["throws-in-setup:failed"]); + }); + + it("gives each session its own copy of the plugin module", async () => { + // Plugins written for the per-session sandbox build one plugin object + // per import and refuse a second setup(). + const path = await write( + "one-setup-per-module.js", + `let setupDone = false; +export default { + name: "one-setup", + manifest: { capabilities: ["tools"] }, + setup(api, ctx) { + if (setupDone) throw new Error("Create a separate handle for each session"); + setupDone = true; + const owner = ctx.session?.sessionId; + api.registerTool({ name: "owner", description: "", inputSchema: {}, execute: () => owner }); + }, +}; +`, + ); + const tools: AgentTool[] = []; + for (const sessionId of ["s1", "s2", "s3"]) { + const loaded = await registry.loadForSession({ + sessionId, + pluginPaths: [path], + setupContext: { session: { sessionId } }, + }); + tools.push(...(await setUp(loaded.extensions[0])).tools); + } + + expect(registry.get(path)[0]).toMatchObject({ + state: "ready", + errorCount: 0, + sessionIds: ["s1", "s2", "s3"], + }); + await expect( + Promise.all(tools.map((tool) => tool.execute({}, toolContext))), + ).resolves.toEqual(["s1", "s2", "s3"]); + }); + + it("keeps other sessions working when one session's setup fails", async () => { + const path = await write( + "session-picky.js", + `export default { + name: "session-picky", + manifest: { capabilities: ["tools"] }, + setup(api, ctx) { + if (ctx.session?.sessionId === "bad") throw new Error("not for this session"); + api.registerTool({ name: "picky", description: "", inputSchema: {}, execute: () => "ok" }); + }, +}; +`, + ); + const told: string[] = []; + const load = (sessionId: string) => + registry.loadForSession({ + sessionId, + pluginPaths: [path], + setupContext: { session: { sessionId } }, + onIssue: (issue) => told.push(`${sessionId}:${issue.state}`), + }); + const [tool] = (await setUp((await load("good")).extensions[0])).tools; + await setUp((await load("bad")).extensions[0]); + expect(registry.get(path)[0]?.lastError?.phase).toBe("setup"); + // Only the session whose setup failed is told; "good" still works. + expect(told).toEqual(["bad:failed"]); + + await expect(tool?.execute({}, toolContext)).resolves.toBe("ok"); + const later = await setUp((await load("later")).extensions[0]); + expect(later.tools).toHaveLength(1); + expect(registry.get(path)[0]?.state).toBe("ready"); + }); + + it("notifies every session using a plugin when it degrades or fails", async () => { + const path = await write( + "flaky-hook.js", + `export default { + name: "flaky-hook", + manifest: { capabilities: ["hooks"] }, + hooks: { beforeRun: async () => { throw new Error("flaky"); } }, +}; +`, + ); + const seen: Array<{ session: string; state: string }> = []; + const first = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + onIssue: (issue) => seen.push({ session: "s1", state: issue.state }), + }); + await registry.loadForSession({ + sessionId: "s2", + pluginPaths: [path], + onIssue: (issue) => seen.push({ session: "s2", state: issue.state }), + }); + const beforeRun = first.extensions[0]?.hooks?.beforeRun; + for (let attempt = 0; attempt < 3; attempt++) { + await beforeRun?.({ snapshot: {} } as never); + } + + // One notice per state change per session, not one per failure. + expect(seen).toEqual([ + { session: "s1", state: "degraded" }, + { session: "s2", state: "degraded" }, + { session: "s1", state: "failed" }, + { session: "s2", state: "failed" }, + ]); + }); + + it("rejects registrations that need a missing capability", async () => { + const path = await write( + "no-rules-capability.js", + `export default { + name: "no-rules", + manifest: { capabilities: ["tools"] }, + setup(api) { api.registerRule({ id: "r", content: "x" }); }, +}; +`, + ); + const loaded = await registry.loadForSession({ pluginPaths: [path] }); + expect((await setUp(loaded.extensions[0])).rules).toEqual([]); + expect(registry.get(path)[0]?.lastError?.message).toContain( + 'requires the "rules" capability', + ); + }); + + it("records a rejecting hook, follows hookErrorMode, and stops after repeated failures", async () => { + const path = await write( + "rejecting-hook.js", + `export default { + name: "rejecting-hook", + manifest: { capabilities: ["hooks"] }, + hooks: { + async beforeRun() { + globalThis.__rejectingHookCalls = (globalThis.__rejectingHookCalls ?? 0) + 1; + throw new Error("hook rejected"); + }, + }, +}; +`, + ); + const ignore = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + }); + const strict = await registry.loadForSession({ + sessionId: "s2", + pluginPaths: [path], + hookErrorMode: "throw", + }); + const beforeRun = ignore.extensions[0]?.hooks?.beforeRun; + const strictBeforeRun = strict.extensions[0]?.hooks?.beforeRun; + const context = { snapshot: {} } as never; + + await expect(beforeRun?.(context)).resolves.toBeUndefined(); + expect(registry.get(path)[0]).toMatchObject({ + state: "degraded", + errorCount: 1, + lastError: { phase: "hook:beforeRun", message: "hook rejected" }, + }); + await expect(strictBeforeRun?.(context)).rejects.toThrow("hook rejected"); + await beforeRun?.(context); + expect(registry.get(path)[0]?.state).toBe("failed"); + + // A failed plugin is no longer called. + const hookCalls = () => + (globalThis as unknown as Record).__rejectingHookCalls; + const calls = hookCalls(); + await beforeRun?.(context); + expect(hookCalls()).toBe(calls); + expect(statusEvents.map((event) => event.state)).toContain("degraded"); + }); + + it("times out a hook that never resolves and lets the run continue", async () => { + const path = await write( + "hanging-hook.js", + `export default { + name: "hanging-hook", + manifest: { capabilities: ["hooks"] }, + hooks: { beforeRun: () => new Promise(() => {}) }, +}; +`, + ); + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + }); + await expect( + loaded.extensions[0]?.hooks?.beforeRun?.({ snapshot: {} } as never), + ).resolves.toBeUndefined(); + expect(registry.get(path)[0]).toMatchObject({ + state: "degraded", + timeoutCount: 1, + lastError: { phase: "hook:beforeRun", timedOut: true }, + }); + }); + + it("returns tool failures to the model and blocks tools of failed plugins", async () => { + const path = await write( + "bad-tools.js", + `export default { + name: "bad-tools", + manifest: { capabilities: ["tools"] }, + setup(api) { + api.registerTool({ name: "throws", description: "", inputSchema: {}, execute: () => { throw new Error("tool broke"); } }); + api.registerTool({ name: "hangs", description: "", inputSchema: {}, execute: () => new Promise(() => {}) }); + }, +}; +`, + ); + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + }); + const [throws, hangs] = (await setUp(loaded.extensions[0])).tools; + + await expect(throws?.execute({}, toolContext)).rejects.toThrow( + "tool broke", + ); + expect(registry.get(path)[0]?.lastError?.phase).toBe("tool:throws"); + await expect(hangs?.execute({}, toolContext)).rejects.toBeInstanceOf( + PluginCallTimeoutError, + ); + expect(registry.get(path)[0]?.timeoutCount).toBe(1); + await expect(throws?.execute({}, toolContext)).rejects.toThrow(); + expect(registry.get(path)[0]?.state).toBe("failed"); + await expect(throws?.execute({}, toolContext)).rejects.toThrow( + /bad-tools" tool "throws" is unavailable/, + ); + }); + + it("gives each session only the plugins its policy enables", async () => { + const alpha = await write("alpha.js", toolPlugin("alpha")); + const beta = await write("beta.js", toolPlugin("beta")); + const onlyAlpha = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [alpha, beta], + policy: { "*": { enabled: false }, alpha: { enabled: true } }, + }); + const noAlpha = await registry.loadForSession({ + sessionId: "s2", + pluginPaths: [alpha, beta], + policy: { alpha: { enabled: false } }, + }); + + expect(onlyAlpha.extensions.map((extension) => extension.name)).toEqual([ + "alpha", + ]); + expect(noAlpha.extensions.map((extension) => extension.name)).toEqual([ + "beta", + ]); + expect(noAlpha.issues).toEqual([ + expect.objectContaining({ + name: "alpha", + state: "disabled", + reason: "session_policy", + }), + ]); + expect(registry.get("alpha")[0]?.sessionIds).toEqual(["s1"]); + + // A broken plugin the session turned off is reported as disabled. + const broken = await write("broken.js", `throw new Error("x");`); + const off = await registry.loadForSession({ + sessionId: "s3", + pluginPaths: [broken], + policy: { "*": { enabled: false } }, + }); + expect(off.failures).toEqual([]); + expect(off.issues).toEqual([ + expect.objectContaining({ name: "broken", state: "disabled" }), + ]); + expect(registry.get("beta")[0]?.sessionIds).toEqual(["s2"]); + }); + + it("reports plugins disabled in settings and paths that could not be resolved", async () => { + const disabled = await write("off.js", toolPlugin("off")); + const loaded = await registry.loadForSession({ + pluginPaths: [], + disabledPluginPaths: [disabled], + discoveryFailures: [ + { pluginPath: join(root, "missing"), error: new Error("not found") }, + ], + }); + expect(registry.get(disabled)[0]?.state).toBe("disabled"); + expect(registry.get(join(root, "missing"))[0]?.lastError).toMatchObject({ + phase: "discover", + message: "not found", + }); + // A session that would have used the settings-disabled plugin is told + // why it is missing. + expect(loaded.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "off", reason: "settings" }), + expect.objectContaining({ state: "failed", reason: "error" }), + ]), + ); + const optedOut = await registry.loadForSession({ + pluginPaths: [], + disabledPluginPaths: [disabled], + policy: { off: { enabled: false } }, + }); + expect(optedOut.issues).toEqual([]); + }); + + it("attributes a stray error to the plugin whose file is in its stack", async () => { + const path = await write( + "stray.js", + `export function makeError() { return new Error("stray rejection"); } +export default { name: "stray", manifest: { capabilities: ["tools"] } }; +`, + ); + const other = await write("other.js", toolPlugin("other")); + await registry.loadForSession({ pluginPaths: [path, other] }); + + const { importPluginModule } = await import("./plugin-module-import"); + const module = await importPluginModule(path, { useCache: false }); + const error = (module.makeError as () => Error)(); + + expect(registry.attributeUncaughtError(new Error("hub bug"))).toBe( + undefined, + ); + const status = registry.attributeUncaughtError(error, "unhandledRejection"); + expect(status).toMatchObject({ + name: "stray", + state: "failed", + lastError: { + phase: "uncaught", + message: "unhandledRejection: stray rejection", + }, + }); + expect(registry.get(other)[0]?.state).toBe("ready"); + }); + + it("attributes a stray error whose stack names the plugin's real path", async () => { + const realDir = join(root, "real"); + const linkDir = join(root, "link"); + await mkdir(realDir); + // A junction works without elevated rights on Windows. + await symlink(realDir, linkDir, "junction"); + await writeFile(join(realDir, "linked.js"), toolPlugin("linked"), "utf8"); + const viaLink = join(linkDir, "linked.js"); + await registry.loadForSession({ pluginPaths: [viaLink] }); + + // Both realpath flavours: on Windows only the native one expands 8.3 + // short names, and resolvers differ in which they use. + for (const realpath of [realpathSync, realpathSync.native]) { + const error = new Error("from the real path"); + error.stack = `Error: from the real path\n at run (${realpath(viaLink)}:3:5)`; + expect(registry.attributeUncaughtError(error)?.name).toBe("linked"); + } + }); + + it("reloads a plugin after it is fixed", async () => { + const path = await write( + "fixable.js", + `throw new Error("not yet");\nexport default {};\n`, + ); + await registry.loadForSession({ pluginPaths: [path] }); + expect(registry.get(path)[0]?.state).toBe("failed"); + + await writeFile(path, toolPlugin("fixable"), "utf8"); + const [status] = await registry.reload(path); + expect(status).toMatchObject({ + name: "fixable", + state: "ready", + errorCount: 0, + }); + expect(status?.lastError).toBeUndefined(); + const loaded = await registry.loadForSession({ pluginPaths: [path] }); + expect(loaded.extensions).toHaveLength(1); + }); + + it("passes cwd, session id, and emitEvent to setup, tools, and the legacy host shim", async () => { + const path = await write( + "context.js", + `export default { + name: "context", + manifest: { capabilities: ["tools"] }, + setup(api, ctx) { + globalThis.__contextPluginSetup = { cwd: ctx.cwd, sessionId: ctx.session?.sessionId }; + ctx.emitEvent?.("from_setup", { ok: true }); + api.registerTool({ + name: "ctx_tool", + description: "", + inputSchema: {}, + execute: async (_input, context) => { + globalThis.__clinePluginHost?.emitEvent?.("from_shim", { cwd: context.cwd }); + return context.cwd; + }, + }); + }, +}; +`, + ); + const events: Array<{ name: string; payload?: unknown }> = []; + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + cwd: "/work/project", + setupContext: { session: { sessionId: "s1" } }, + emitEvent: (event) => events.push(event), + }); + const [tool] = (await setUp(loaded.extensions[0])).tools; + await expect(tool?.execute({}, toolContext)).resolves.toBe("/work/project"); + + expect( + (globalThis as Record).__contextPluginSetup, + ).toEqual({ cwd: "/work/project", sessionId: "s1" }); + expect(events).toEqual([ + { name: "from_setup", payload: { ok: true } }, + { name: "from_shim", payload: { cwd: "/work/project" } }, + ]); + }); + + it("keeps running sessions' copies usable across a reload", async () => { + const path = await write("versioned.js", toolPlugin("versioned")); + const before = await registry.loadForSession({ + sessionId: "old", + pluginPaths: [path], + }); + const [oldTool] = (await setUp(before.extensions[0])).tools; + + await writeFile(path, toolPlugin("versioned", "versioned_v2"), "utf8"); + await registry.reload(path); + const after = await registry.loadForSession({ + sessionId: "new", + pluginPaths: [path], + }); + const [newTool] = (await setUp(after.extensions[0])).tools; + + await expect(oldTool?.execute({}, toolContext)).resolves.toBe( + "ok from versioned", + ); + expect(newTool?.name).toBe("versioned_v2"); + expect(registry.get(path)[0]?.sessionIds).toEqual(["new", "old"]); + }); + + it("does not let a stale copy's failures turn off a reloaded plugin", async () => { + const path = await write( + "stale.js", + `export default { + name: "stale", + manifest: { capabilities: ["hooks"] }, + hooks: { beforeRun: async () => { throw new Error("old copy broke"); } }, +}; +`, + ); + const told: string[] = []; + const old = await registry.loadForSession({ + sessionId: "old", + pluginPaths: [path], + onIssue: (issue) => told.push(`${issue.state}:${issue.lastError?.phase}`), + }); + // Fix the file and reload; the old session keeps its broken copy. + await writeFile(path, toolPlugin("stale"), "utf8"); + await registry.reload(path); + + // Well past the failure threshold (3). + for (let index = 0; index < 5; index++) { + await old.extensions[0]?.hooks?.beforeRun?.({ snapshot: {} } as never); + } + + // Its own session hears about it; the fixed plugin is unaffected. + expect(told).toEqual(Array(5).fill("degraded:hook:beforeRun")); + expect(registry.get(path)[0]).toMatchObject({ + state: "ready", + errorCount: 0, + }); + const fresh = await registry.loadForSession({ + sessionId: "fresh", + pluginPaths: [path], + }); + expect((await setUp(fresh.extensions[0])).tools.map((t) => t.name)).toEqual( + ["stale_tool"], + ); + }); + + it("does not let a session import that fails after a reload turn off the reloaded plugin", async () => { + const path = await write( + "slow-import.js", + `const gate = globalThis.__slowImportGate; +if (gate) { + await gate; + throw new Error("old copy failed to import"); +} +${toolPlugin("slow-import").replace("export default", "const plugin =")} +export default plugin; +`, + ); + const globals = globalThis as Record; + // The first session claims the spare copy, so the next one imports its own. + await registry.loadForSession({ sessionId: "first", pluginPaths: [path] }); + + let releaseGate: () => void = () => {}; + globals.__slowImportGate = new Promise((resolve) => { + releaseGate = resolve; + }); + const pending = registry.loadForSession({ + sessionId: "slow", + pluginPaths: [path], + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // The plugin is reloaded (fixed) while that copy is still importing. + delete globals.__slowImportGate; + await registry.reload(path); + expect(registry.get(path)[0]?.state).toBe("ready"); + + releaseGate(); + const slow = await pending; + expect(slow.extensions).toHaveLength(0); + expect(slow.issues).toEqual([ + expect.objectContaining({ + name: "slow-import", + state: "failed", + lastError: expect.objectContaining({ + phase: "import", + message: "old copy failed to import", + }), + }), + ]); + + // The reloaded plugin stays on for everyone else. + expect(registry.get(path)[0]).toMatchObject({ + state: "ready", + errorCount: 0, + }); + const fresh = await registry.loadForSession({ + sessionId: "fresh", + pluginPaths: [path], + }); + expect((await setUp(fresh.extensions[0])).tools.map((t) => t.name)).toEqual( + ["slow-import_tool"], + ); + }); + + it("lets a successful reload clear a block recorded while it was importing", async () => { + const path = await write( + "flaky.js", + `export default { + name: "flaky", + manifest: { capabilities: ["hooks"] }, + hooks: { beforeRun: async () => { throw new Error("still broken"); } }, +}; +`, + ); + const running = await registry.loadForSession({ + sessionId: "running", + pluginPaths: [path], + }); + const hook = running.extensions[0]?.hooks?.beforeRun; + // Reaches the failure threshold (3) while the reload's import is in + // flight, which blocks the plugin mid-import. + const reloading = registry.reload(path); + for (let index = 0; index < 3; index++) { + await hook?.({ snapshot: {} } as never); + } + await reloading; + + expect(registry.get(path)[0]?.state).toBe("ready"); + const next = await registry.loadForSession({ + sessionId: "next", + pluginPaths: [path], + }); + expect(next.extensions.map((extension) => extension.name)).toEqual([ + "flaky", + ]); + }); + + it("tells working sessions when an uncaught error turns off a plugin another session's setup already failed", async () => { + const path = await write( + "setup-then-crash.js", + `export default { + name: "setup-then-crash", + manifest: { capabilities: ["tools"] }, + setup() { if (globalThis.__failNextSetup) { globalThis.__failNextSetup = false; throw new Error("setup broke"); } }, +}; +`, + ); + const told: string[] = []; + const healthy = await registry.loadForSession({ + sessionId: "healthy", + pluginPaths: [path], + onIssue: (issue) => told.push(`healthy:${issue.lastError?.phase}`), + }); + await setUp(healthy.extensions[0]); + (globalThis as Record).__failNextSetup = true; + const unlucky = await registry.loadForSession({ + sessionId: "unlucky", + pluginPaths: [path], + onIssue: (issue) => told.push(`unlucky:${issue.lastError?.phase}`), + }); + await setUp(unlucky.extensions[0]); + expect(registry.get(path)[0]?.state).toBe("failed"); + expect(told).toEqual(["unlucky:setup"]); + + const stray = new Error("boom"); + stray.stack = `Error: boom\n at run (${path}:3:5)`; + registry.attributeUncaughtError(stray); + expect(told).toEqual([ + "unlucky:setup", + "healthy:uncaught", + "unlucky:uncaught", + ]); + }); + + it("times out and stops commands, rule content, and message builders", async () => { + const path = await write( + "callbacks.js", + `export default { + name: "callbacks", + manifest: { capabilities: ["commands", "rules", "messageBuilders"] }, + setup(api) { + api.registerCommand({ name: "hang", handler: () => new Promise(() => {}) }); + api.registerCommand({ name: "boom", handler: () => { throw new Error("command broke"); } }); + api.registerRule({ id: "slow-rule", content: () => new Promise(() => {}) }); + api.registerMessageBuilder({ name: "bad-builder", build: () => { throw new Error("builder broke"); } }); + }, +}; +`, + ); + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + callTimeoutMs: 50, + }); + const commands: Array<{ + name: string; + handler?: (input: string) => unknown; + }> = []; + const rules: Array<{ content: unknown }> = []; + const builders: Array<{ build: (messages: Message[]) => unknown }> = []; + await loaded.extensions[0]?.setup?.( + { + ...collectingApi().api, + registerCommand: (command) => commands.push(command), + registerRule: (rule) => rules.push(rule), + registerMessageBuilder: (builder) => builders.push(builder), + }, + {}, + ); + + const hang = commands.find((command) => command.name === "hang"); + await expect(hang?.handler?.("")).rejects.toBeInstanceOf( + PluginCallTimeoutError, + ); + const content = rules[0]?.content as () => Promise; + await expect(content()).resolves.toBe(""); + const messages = [{ role: "user", content: "hi" }] as Message[]; + await expect(builders[0]?.build(messages)).resolves.toBe(messages); + expect(registry.get(path)[0]).toMatchObject({ + state: "failed", + timeoutCount: 2, + }); + + // Turned off after repeated failures: commands are refused. + const boom = commands.find((command) => command.name === "boom"); + await expect(boom?.handler?.("")).rejects.toThrow(/is unavailable/); + }); + + it("runs onDispose cleanup when the session ends", async () => { + const path = await write( + "dispose.js", + `export default { + name: "dispose", + manifest: { capabilities: ["tools"] }, + setup(_api, ctx) { + ctx.onDispose?.(() => { globalThis.__disposedSession = ctx.session?.sessionId; }); + }, +}; +`, + ); + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + setupContext: { session: { sessionId: "s1" } }, + }); + await setUp(loaded.extensions[0]); + const globals = globalThis as unknown as Record; + expect(globals.__disposedSession).toBeUndefined(); + await loaded.release(); + expect(globals.__disposedSession).toBe("s1"); + }); + + it("treats a registration the host rejects as a setup failure", async () => { + const path = await write( + "bad-registration.js", + `export default { + name: "bad-registration", + manifest: { capabilities: ["tools"] }, + setup(api) { + api.registerTool({ name: "kept_out", description: "", inputSchema: {}, execute: () => "" }); + api.registerProvider({ name: "rejected" }); + }, +}; +`, + ); + const told: string[] = []; + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + onIssue: (issue) => + told.push(`${issue.state}:${issue.lastError?.message}`), + }); + const { api } = collectingApi(); + await expect( + loaded.extensions[0]?.setup?.( + { + ...api, + registerProvider: () => { + throw new Error("provider rejected by host"); + }, + }, + {}, + ), + ).rejects.toThrow("provider rejected by host"); + expect(told).toEqual(["failed:provider rejected by host"]); + expect(registry.get(path)[0]?.lastError?.phase).toBe("setup"); + }); + + it("applies per-session timeout overrides", async () => { + const path = await write( + "slow-hook.js", + `export default { + name: "slow-hook", + manifest: { capabilities: ["hooks"] }, + hooks: { beforeRun: () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 150)) }, +}; +`, + ); + const patient = await registry.loadForSession({ + sessionId: "patient", + pluginPaths: [path], + hookTimeoutMs: 1_000, + }); + await expect( + patient.extensions[0]?.hooks?.beforeRun?.({ snapshot: {} } as never), + ).resolves.toEqual({ ok: true }); + expect(registry.get(path)[0]?.timeoutCount).toBe(0); + }); + + it("keeps the module copy imported by preload for the first session", async () => { + const path = await write( + "counted.js", + `globalThis.__countedImports = (globalThis.__countedImports ?? 0) + 1; +export default { name: "counted", manifest: { capabilities: ["tools"] } }; +`, + ); + const globals = globalThis as unknown as Record; + globals.__countedImports = 0; + await registry.preload({ pluginPaths: [path] }); + expect(globals.__countedImports).toBe(1); + await registry.loadForSession({ sessionId: "first", pluginPaths: [path] }); + expect(globals.__countedImports).toBe(1); + await registry.loadForSession({ sessionId: "second", pluginPaths: [path] }); + expect(globals.__countedImports).toBe(2); + }); + + it("keeps the plugin's this for setup and its registered methods", async () => { + const path = await write( + "this-binding.js", + `export default { + name: "this-binding", + label: "plugin-object", + manifest: { capabilities: ["commands", "rules", "messageBuilders"] }, + setup(api) { + globalThis.__setupThis = this?.label; + api.registerRule({ id: "self-rule", content() { return this.id; } }); + api.registerCommand({ name: "who", handler(input) { return this.name + ":" + input; } }); + api.registerMessageBuilder({ name: "tagger", build(messages) { return [...messages, { role: "user", content: this.name }]; } }); + }, +}; +`, + ); + const loaded = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + }); + const rules: Array<{ content: unknown }> = []; + const commands: Array<{ handler?: (input: string) => unknown }> = []; + const builders: Array<{ build: (messages: Message[]) => unknown }> = []; + await loaded.extensions[0]?.setup?.( + { + ...collectingApi().api, + registerRule: (rule) => rules.push(rule), + registerCommand: (command) => commands.push(command), + registerMessageBuilder: (builder) => builders.push(builder), + }, + {}, + ); + const globals = globalThis as unknown as Record; + + expect(globals.__setupThis).toBe("plugin-object"); + await expect((rules[0]?.content as () => Promise)()).resolves.toBe( + "self-rule", + ); + await expect(commands[0]?.handler?.("me")).resolves.toBe("who:me"); + await expect(builders[0]?.build([])).resolves.toEqual([ + { role: "user", content: "tagger" }, + ]); + expect(registry.get(path)[0]?.errorCount).toBe(0); + }); + + it("routes events from work the module started at import to the session that claimed it", async () => { + const path = await write( + "import-time-work.js", + `setTimeout(() => { + globalThis.__clinePluginHost?.emitEvent?.("steer_message", { prompt: "from import" }); +}, 40); +export default { name: "import-time-work", manifest: { capabilities: ["tools"] } }; +`, + ); + const events: Array<{ name: string; payload?: unknown }> = []; + await registry.loadForSession({ + sessionId: "claimer", + pluginPaths: [path], + emitEvent: (event) => events.push(event), + }); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(events).toEqual([ + { name: "steer_message", payload: { prompt: "from import" } }, + ]); + }); + + it("gives new sessions changes made outside the entry file, and only new sessions", async () => { + const helper = await write( + "helper.js", + `export const toolName = "helper_v1";\n`, + ); + const path = await write( + "uses-helper.js", + `import { toolName } from "./helper.js"; +export default { + name: "uses-helper", + manifest: { capabilities: ["tools"] }, + setup(api) { + api.registerTool({ name: toolName, description: "", inputSchema: {}, execute: () => toolName }); + }, +}; +`, + ); + const before = await registry.loadForSession({ + sessionId: "before", + pluginPaths: [path], + }); + // Leaves an imported copy waiting for the next session to claim. + await registry.reload(path); + // Same length as before, written immediately, and only in a file the + // entry imports: the waiting copy is now stale. + await writeFile(helper, `export const toolName = "helper_v2";\n`, "utf8"); + const after = await registry.loadForSession({ + sessionId: "after", + pluginPaths: [path], + }); + + expect( + (await setUp(before.extensions[0])).tools.map((t) => t.name), + ).toEqual(["helper_v1"]); + expect((await setUp(after.extensions[0])).tools.map((t) => t.name)).toEqual( + ["helper_v2"], + ); + }); +}); diff --git a/sdk/packages/core/src/extensions/plugin/plugin-registry.ts b/sdk/packages/core/src/extensions/plugin/plugin-registry.ts new file mode 100644 index 0000000000..62a37cb23f --- /dev/null +++ b/sdk/packages/core/src/extensions/plugin/plugin-registry.ts @@ -0,0 +1,1881 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { basename, dirname, extname, join, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { + AgentExtension, + AgentExtensionApi, + AgentExtensionCommand, + AgentExtensionMessageBuilder, + AgentExtensionRule, + AgentTool, + AgentToolContext, + BasicLogger, + Message, + PluginErrorPhase, + PluginErrorRecord, + PluginPolicies, + PluginRuntimeState, + PluginSetupContext, + PluginStatusRecord, + SessionPluginIssue, +} from "@cline/shared"; +import { getPluginDisplayName } from "@cline/shared/storage"; +import type { + PluginInitializationFailure, + PluginInitializationWarning, +} from "./plugin-load-report"; +import { loadAgentPluginFromPath } from "./plugin-loader"; +import { fingerprintPluginSources } from "./plugin-module-import"; +import { loadSandboxedPlugins } from "./plugin-sandbox"; +import { + matchesPluginManifestTargeting, + type PluginTargeting, +} from "./plugin-targeting"; + +export const DEFAULT_PLUGIN_IMPORT_TIMEOUT_MS = 4_000; +export const DEFAULT_PLUGIN_SETUP_TIMEOUT_MS = 4_000; +export const DEFAULT_PLUGIN_HOOK_TIMEOUT_MS = 3_000; +/** Tools, commands, rule content, and message builders. */ +export const DEFAULT_PLUGIN_TOOL_TIMEOUT_MS = 60_000; +/** Consecutive call failures after which a plugin is turned off. */ +export const DEFAULT_PLUGIN_FAILURE_THRESHOLD = 5; + +const PLUGIN_IMPORT_TIMEOUT_ENV = "CLINE_PLUGIN_IMPORT_TIMEOUT_MS"; + +export type PluginHookErrorMode = "ignore" | "throw"; + +export class PluginCallTimeoutError extends Error { + constructor( + readonly label: string, + readonly timeoutMs: number, + ) { + super(`${label} timed out after ${timeoutMs}ms`); + this.name = "PluginCallTimeoutError"; + } +} + +type ExtensionHooks = NonNullable; +type HookFn = (...args: unknown[]) => unknown; +type SetupApi = AgentExtensionApi; + +interface PluginEntry { + pluginPath: string; + /** Directory (package plugins) or file path used to attribute stack frames. */ + attributionRoot: string; + name: string; + state: PluginRuntimeState; + /** + * Set when the plugin is turned off for every session: import or discovery + * failed, a stray error was attributed to it, or calls kept failing. A + * `setup()` failure alone is per session and does not set this. + */ + blocked: boolean; + consecutiveFailures: number; + /** + * Bumped on every (re)import of the entry. Copies stamped with an older + * value are stale: their failures are reported to their own session but + * do not count against the current module. + */ + importId: number; + /** The imported module, read for metadata (name, manifest). */ + extension?: AgentExtension; + /** + * An imported copy no session has used yet. The first session claims it; + * later sessions import their own copy so module state is per session. + */ + spare?: PluginInstance; + fingerprint?: string; + lastError?: PluginErrorRecord; + errorCount: number; + timeoutCount: number; + /** Sessions holding a copy, with their `onIssue` callback. */ + sessions: Map void) | undefined>; + /** + * Set once the plugin has loaded in a worker. Its copies then run in + * workers and the host never imports the module, so a reload validates + * the new code in a worker too. + */ + isolated?: { exportName?: string }; + /** + * Worker mode: the plugin's own (exported) name has been seen for its + * current code. Until then `name` is derived from the path, so the host + * cannot apply a policy that names the plugin and leaves it to the worker. + */ + nameResolved?: boolean; + loading?: Promise; + updatedAt: number; +} + +/** One session's copy of a plugin module. */ +interface PluginInstance { + extension: AgentExtension; + /** The entry's `importId` when this copy was imported. */ + importId: number; + setupFailed: boolean; + closed: boolean; + /** Cleanup registered through `ctx.onDispose`. */ + disposers: Array<() => void | Promise>; + /** Worker copies: stops the worker the copy runs in. */ + shutdown?: () => Promise; + /** + * The async scope every call into this copy runs in, created at import. + * Work the module starts at import time keeps this scope, so claiming the + * copy for a session fills in its session id and event callback here + * rather than in a new scope that work would never see. + */ + scope: PluginCallScope; +} + +interface PluginCallScope { + sessionId?: string; + pluginName: string; + emitEvent?: (name: string, payload?: unknown) => void; +} + +interface PluginTimeouts { + import: number; + setup: number; + hook: number; + call: number; +} + +export interface PluginRegistryOptions { + logger?: BasicLogger; + importTimeoutMs?: number; + setupTimeoutMs?: number; + hookTimeoutMs?: number; + toolTimeoutMs?: number; + failureThreshold?: number; +} + +/** + * Where a session's plugin copies run. `in_process` imports them into the + * host. `worker` runs each plugin in its own worker thread per session: the + * host never loads plugin code, and a crash, `process.exit()`, or hung loop + * ends only that worker. + */ +export type PluginIsolation = "in_process" | "worker"; + +export interface PluginSessionLoadInput extends PluginTargeting { + sessionId?: string; + /** Defaults to `in_process`. */ + isolation?: PluginIsolation; + /** Resolved, enabled plugin module paths, in load order. */ + pluginPaths: ReadonlyArray; + /** Plugin paths turned off in settings; reported as `disabled`. */ + disabledPluginPaths?: ReadonlyArray; + /** Configured paths that could not be resolved to a module. */ + discoveryFailures?: ReadonlyArray<{ pluginPath: string; error: unknown }>; + policy?: PluginPolicies; + exportName?: string; + cwd?: string; + hookErrorMode?: PluginHookErrorMode; + setupContext?: PluginSetupContext; + emitEvent?: (event: { name: string; payload?: unknown }) => void; + /** + * Called when a call made for this session moves a plugin to `degraded` + * or `failed`. Setup runs lazily on the first turn, after the session + * start payload went out, so this is how those failures reach the user. + */ + onIssue?: (issue: SessionPluginIssue) => void; + /** Per-session overrides of the registry's default timeouts. */ + importTimeoutMs?: number; + hookTimeoutMs?: number; + /** Tools (without their own `timeoutMs`), commands, rules, builders. */ + callTimeoutMs?: number; +} + +export interface PluginSessionLoadResult { + extensions: AgentExtension[]; + pluginPaths: string[]; + failures: PluginInitializationFailure[]; + warnings: PluginInitializationWarning[]; + issues: SessionPluginIssue[]; + /** + * Ends the session's use of its plugin copies: runs `ctx.onDispose` + * cleanup and stops further calls. + */ + release: () => Promise; +} + +export type PluginStatusListener = (status: PluginStatusRecord) => void; + +function readTimeoutEnv(name: string): number | undefined { + const raw = process.env[name]?.trim(); + if (!raw) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function toErrorParts(error: unknown): { message: string; stack?: string } { + if (error instanceof Error) { + return { message: error.message, stack: error.stack }; + } + return { message: String(error) }; +} + +/** + * Package plugins own their whole package directory; a single-file plugin + * in a shared `plugins/` folder owns only its file, so a stray error from one + * drop-in file is never blamed on its neighbours. + */ +function resolveAttributionRoot(pluginPath: string): string { + let current = dirname(pluginPath); + for (let depth = 0; depth < 4; depth++) { + const manifestPath = join(current, "package.json"); + if (existsSync(manifestPath)) { + try { + const pkg = JSON.parse(readFileSync(manifestPath, "utf8")); + if (pkg && typeof pkg === "object" && "cline" in pkg) { + return current; + } + } catch { + // Malformed manifest: fall back to the file itself. + } + return pluginPath; + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return pluginPath; +} + +/** Name used before the module is imported (or when it never imports). */ +function derivePluginName(pluginPath: string, attributionRoot: string): string { + return attributionRoot === pluginPath + ? basename(pluginPath, extname(pluginPath)) + : getPluginDisplayName(pluginPath, attributionRoot); +} + +/** The name a plugin is known by before its module is imported. */ +export function derivePluginNameFromPath(pluginPath: string): string { + const absolute = resolve(pluginPath); + return derivePluginName(absolute, resolveAttributionRoot(absolute)); +} + +/** + * Windows stack frames may spell a path differently from how it was + * configured (case, separators, 8.3 short names such as `RUNNER~1`), so + * compare a normalized form there. + */ +function normalizeStackPath(value: string): string { + return process.platform === "win32" + ? value.replace(/\\/g, "/").toLowerCase() + : value; +} + +function stackMentionsPath(stack: string, root: string): boolean { + const roots = new Set([root]); + // Module resolution follows symlinks, and on Windows may or may not + // expand 8.3 short names depending on the resolver, so the stack can name + // the real path in either spelling. + for (const realpath of [realpathSync, realpathSync.native]) { + try { + roots.add(realpath(root)); + } catch { + // A missing root can only match by its configured spelling. + } + } + const haystack = normalizeStackPath(stack); + const candidates = [...roots].flatMap((candidate) => [ + normalizeStackPath(candidate), + normalizeStackPath(pathToFileURL(candidate).href), + ]); + return candidates.some((candidate) => { + let index = haystack.indexOf(candidate); + while (index !== -1) { + const next = haystack[index + candidate.length]; + // Require a boundary so /plugins/foo does not match /plugins/foobar. + if ( + next === undefined || + next === sep || + next === "/" || + next === ":" || + next === ")" || + next === "?" || + /\s/.test(next) + ) { + return true; + } + index = haystack.indexOf(candidate, index + 1); + } + return false; + }); +} + +function runWithTimeout( + fn: () => T | Promise, + timeoutMs: number, + label: string, +): Promise { + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + const timer = setTimeout(() => { + settled = true; + rejectPromise(new PluginCallTimeoutError(label, timeoutMs)); + }, timeoutMs); + timer.unref?.(); + Promise.resolve() + .then(fn) + .then( + (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolvePromise(value); + }, + (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + rejectPromise(error); + }, + ); + }); +} + +/** Applies a `toolPolicies`-shaped plugin policy to a plugin's names. */ +function isPluginEnabledByPolicy( + policy: PluginPolicies | undefined, + names: ReadonlyArray, +): boolean { + if (!policy) return true; + for (const name of names) { + const enabled = policy[name]?.enabled; + if (typeof enabled === "boolean") return enabled; + } + return policy["*"]?.enabled ?? true; +} + +const pluginCallScope = new AsyncLocalStorage(); +let lastPluginCallScope: PluginCallScope | undefined; + +function runInPluginScope(scope: PluginCallScope, fn: () => T): T { + lastPluginCallScope = scope; + return pluginCallScope.run(scope, fn); +} + +/** + * Plugins written for the subprocess sandbox emit events through + * `globalThis.__clinePluginHost.emitEvent`. In-process, route those calls to + * the session whose hook or tool is running (async context follows timers and + * promises the call started), else to the session that last called a plugin. + */ +export function installPluginHostShim(): void { + const globals = globalThis as Record; + if (globals.__clinePluginHost) return; + globals.__clinePluginHost = { + emitEvent: (name: string, payload?: unknown) => { + const scope = pluginCallScope.getStore() ?? lastPluginCallScope; + scope?.emitEvent?.(name, payload); + }, + }; +} + +function newInstance( + extension: AgentExtension | undefined, + pluginName: string, + importId: number, +): PluginInstance { + return { + // Assigned as soon as the import resolves; never read before that. + extension: extension as AgentExtension, + importId, + setupFailed: false, + closed: false, + disposers: [], + scope: { pluginName }, + }; +} + +/** + * Tracks plugins for the whole process and hands each session a guarded copy + * of the plugins it enabled. Each session gets its own import of the module, + * matching the per-session sandbox plugins were written for. Every import, + * setup, hook, tool, command, rule, and message-builder call is wrapped with + * a timeout and error capture, and the result is recorded per plugin so + * failures are visible instead of silently dropping the plugin's tools. + */ +export class PluginRegistry { + private readonly entries = new Map(); + private readonly listeners = new Set(); + private logger: BasicLogger | undefined; + private readonly defaults: PluginTimeouts; + private readonly failureThreshold: number; + + constructor(options: PluginRegistryOptions = {}) { + this.logger = options.logger; + this.defaults = { + import: + options.importTimeoutMs ?? + readTimeoutEnv(PLUGIN_IMPORT_TIMEOUT_ENV) ?? + DEFAULT_PLUGIN_IMPORT_TIMEOUT_MS, + setup: options.setupTimeoutMs ?? DEFAULT_PLUGIN_SETUP_TIMEOUT_MS, + hook: options.hookTimeoutMs ?? DEFAULT_PLUGIN_HOOK_TIMEOUT_MS, + call: options.toolTimeoutMs ?? DEFAULT_PLUGIN_TOOL_TIMEOUT_MS, + }; + this.failureThreshold = + options.failureThreshold ?? DEFAULT_PLUGIN_FAILURE_THRESHOLD; + installPluginHostShim(); + } + + setLogger(logger: BasicLogger | undefined): void { + this.logger = logger; + } + + subscribe(listener: PluginStatusListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + list(): PluginStatusRecord[] { + return [...this.entries.values()] + .map((entry) => this.toStatus(entry)) + .sort((left, right) => left.name.localeCompare(right.name)); + } + + get(nameOrPath: string): PluginStatusRecord[] { + return this.match(nameOrPath).map((entry) => this.toStatus(entry)); + } + + /** + * Re-imports the matching plugins and resets their status. Sessions + * started afterwards use the new module; running sessions keep the copy + * they set up, which stays callable unless the re-import fails (the plugin + * is then turned off for every session, failing closed). + */ + async reload(nameOrPath: string): Promise { + const matches = this.match(nameOrPath); + for (const entry of matches) { + entry.fingerprint = undefined; + entry.lastError = undefined; + entry.errorCount = 0; + entry.timeoutCount = 0; + this.log("info", "plugin.reload", entry, {}); + if (entry.isolated) { + await this.reloadIsolated(entry); + continue; + } + await this.ensureLoaded(entry.pluginPath, { force: true }); + } + return matches.map((entry) => this.toStatus(entry)); + } + + /** + * Finds the plugin whose module root appears in `error`'s stack and turns + * it off for every session. Used by the Hub daemon so a stray plugin error + * does not take the process down. + */ + attributeUncaughtError( + error: unknown, + label = "uncaught", + ): PluginStatusRecord | undefined { + const stack = error instanceof Error ? error.stack : undefined; + if (!stack) return undefined; + // Prefer the most specific root when roots nest. + const entry = [...this.entries.values()] + .filter((candidate) => + stackMentionsPath(stack, candidate.attributionRoot), + ) + .sort( + (left, right) => + right.attributionRoot.length - left.attributionRoot.length, + )[0]; + if (!entry) return undefined; + this.recordFailure(entry, "uncaught", error, { + fatal: true, + messagePrefix: `${label}: `, + }); + return this.toStatus(entry); + } + + /** + * Imports plugins without attaching them to a session, so the Hub can + * discover them at startup and report status before any session exists. + * The imported copy is kept for the first session to claim. + */ + async preload( + input: Pick< + PluginSessionLoadInput, + | "pluginPaths" + | "disabledPluginPaths" + | "discoveryFailures" + | "exportName" + | "importTimeoutMs" + | "isolation" + >, + ): Promise { + if (input.isolation === "worker") { + // Load each plugin in a throwaway worker so status (and import + // errors) are known before any session starts. + const loaded = await this.loadIsolatedForSession(input); + await loaded.release(); + return this.list(); + } + this.recordDiscovery(input, []); + for (const rawPath of input.pluginPaths) { + await this.ensureLoaded(resolve(rawPath), { + exportName: input.exportName, + importTimeoutMs: input.importTimeoutMs, + }); + } + return this.list(); + } + + async loadForSession( + input: PluginSessionLoadInput, + ): Promise { + if (input.isolation === "worker") { + return this.loadIsolatedForSession(input); + } + const failures: PluginInitializationFailure[] = []; + const warnings: PluginInitializationWarning[] = []; + const issues: SessionPluginIssue[] = []; + const sessionId = input.sessionId; + const timeouts = this.resolveTimeouts(input); + + for (const entry of this.recordDiscovery(input, issues)) { + failures.push(this.toInitializationFailure(entry)); + } + + const loadedByName = new Map< + string, + { entry: PluginEntry; order: number } + >(); + let order = 0; + for (const rawPath of input.pluginPaths) { + const pluginPath = resolve(rawPath); + const entry = await this.ensureLoaded(pluginPath, { + exportName: input.exportName, + importTimeoutMs: timeouts.import, + }); + const enabledByPolicy = this.isEnabledForSession(entry, input); + if (entry.blocked || entry.state === "disabled" || !entry.extension) { + // A broken plugin the session turned off is not this session's + // problem; report it as disabled rather than as a failure. + if (!enabledByPolicy) { + issues.push(this.policyIssue(entry)); + continue; + } + if (entry.state === "failed") { + failures.push(this.toInitializationFailure(entry)); + } + issues.push( + this.toIssue( + entry, + entry.state === "disabled" ? "settings" : "error", + ), + ); + continue; + } + if (!matchesPluginManifestTargeting(entry.extension.manifest, input)) { + continue; + } + if (!enabledByPolicy) { + issues.push(this.policyIssue(entry)); + continue; + } + const existing = loadedByName.get(entry.name); + if (existing) { + warnings.push({ + type: "duplicate_plugin_override", + pluginName: entry.name, + pluginPath, + overriddenPluginPath: existing.entry.pluginPath, + message: `Plugin "${entry.name}" from ${pluginPath} overrides ${existing.entry.pluginPath}`, + }); + } + loadedByName.set(entry.name, { entry, order: order++ }); + } + + const ordered = [...loadedByName.values()].sort( + (left, right) => left.order - right.order, + ); + const selected: Array<{ entry: PluginEntry; instance: PluginInstance }> = + []; + for (const { entry } of ordered) { + const importId = entry.importId; + try { + const instance = await this.claimInstance( + entry, + input.exportName, + timeouts, + sessionId, + ); + selected.push({ entry, instance }); + } catch (error) { + if (entry.importId !== importId) { + // A reload or source change re-imported the plugin while this + // copy was loading. The failure describes the old module: tell + // this session, but leave the current module's status alone. + const { message, stack } = toErrorParts(error); + issues.push({ + name: entry.name, + pluginPath: entry.pluginPath, + state: "failed", + reason: "error", + lastError: { + phase: "import", + message, + ...(stack ? { stack } : {}), + pluginPath: entry.pluginPath, + timestamp: Date.now(), + ...(sessionId ? { sessionId } : {}), + }, + }); + continue; + } + this.recordFailure(entry, "import", error, { + fatal: true, + sessionId, + }); + failures.push(this.toInitializationFailure(entry)); + issues.push(this.toIssue(entry, "error")); + } + } + if (sessionId) { + for (const { entry } of selected) { + entry.sessions.set(sessionId, input.onIssue); + } + } + const extensions = selected.map(({ entry, instance }) => + this.wrapForSession(entry, instance, input, timeouts), + ); + let released: Promise | undefined; + return { + extensions, + pluginPaths: selected.map(({ entry }) => entry.pluginPath), + failures, + warnings, + issues, + release: () => { + released ??= Promise.all( + selected.map(({ entry, instance }) => + this.releaseInstance(entry, instance, sessionId, timeouts), + ), + ).then(() => undefined); + return released; + }, + }; + } + + /** + * Worker isolation: each plugin loads in its own worker for this session, + * and the copy handed to the session is a proxy whose calls go to that + * worker. The copy goes through the same wrapping as an in-process one, so + * status, timeouts, failure counting, policy, and notices are unchanged. + * What differs is where the code runs: the host never imports it. + */ + private async loadIsolatedForSession( + input: PluginSessionLoadInput, + ): Promise { + const failures: PluginInitializationFailure[] = []; + const warnings: PluginInitializationWarning[] = []; + const issues: SessionPluginIssue[] = []; + const sessionId = input.sessionId; + const timeouts = this.resolveTimeouts(input); + + for (const entry of this.recordDiscovery(input, issues)) { + failures.push(this.toInitializationFailure(entry)); + } + + const candidates: PluginEntry[] = []; + for (const rawPath of input.pluginPaths) { + const entry = this.ensureEntry(resolve(rawPath)); + entry.isolated = { exportName: input.exportName }; + this.refreshIsolatedEntry(entry); + // Skip the worker only when the plugin's own name is known; else the + // worker applies the policy after import, before setup. + if (entry.nameResolved && !this.isEnabledForSession(entry, input)) { + issues.push(this.policyIssue(entry)); + continue; + } + if (entry.blocked) { + failures.push(this.toInitializationFailure(entry)); + issues.push(this.toIssue(entry, "error")); + continue; + } + candidates.push(entry); + } + + const started = await Promise.all( + candidates.map((entry) => + this.startWorkerCopy(entry, input, timeouts, sessionId), + ), + ); + + const loadedByName = new Map< + string, + { entry: PluginEntry; instance: PluginInstance; order: number } + >(); + let order = 0; + for (const result of started) { + const { entry } = result; + if ("error" in result) { + if (result.importId !== entry.importId) { + // The plugin changed while this copy was loading; the failure + // describes the old code, so only this session hears of it. + issues.push( + this.staleIssue(entry, result.phase, result.error, sessionId), + ); + continue; + } + if (result.phase === "setup") { + // As in-process: a setup failure costs only this session. + this.recordFailure(entry, "setup", result.error, { + sessionId, + state: "failed", + }); + } else { + this.recordFailure(entry, "import", result.error, { + fatal: true, + sessionId, + }); + } + failures.push(this.toInitializationFailure(entry)); + issues.push(this.toIssue(entry, "error")); + continue; + } + if ("policyDisabled" in result) { + issues.push(this.policyIssue(entry)); + continue; + } + if (!result.instance) continue; // Manifest targeting excluded it. + const { instance } = result; + // The module's own name is known now; the policy may name it. + if (!this.isEnabledForSession(entry, input)) { + issues.push(this.policyIssue(entry)); + await instance.shutdown?.().catch(() => {}); + continue; + } + const existing = loadedByName.get(entry.name); + if (existing) { + warnings.push({ + type: "duplicate_plugin_override", + pluginName: entry.name, + pluginPath: entry.pluginPath, + overriddenPluginPath: existing.entry.pluginPath, + message: `Plugin "${entry.name}" from ${entry.pluginPath} overrides ${existing.entry.pluginPath}`, + }); + await existing.instance.shutdown?.().catch(() => {}); + } + loadedByName.set(entry.name, { entry, instance, order: order++ }); + } + + const selected = [...loadedByName.values()].sort( + (left, right) => left.order - right.order, + ); + if (sessionId) { + for (const { entry } of selected) { + entry.sessions.set(sessionId, input.onIssue); + } + } + const extensions = selected.map(({ entry, instance }) => + this.wrapForSession(entry, instance, input, timeouts), + ); + let released: Promise | undefined; + return { + extensions, + pluginPaths: selected.map(({ entry }) => entry.pluginPath), + failures, + warnings, + issues, + release: () => { + released ??= Promise.all( + selected.map(({ entry, instance }) => + this.releaseInstance(entry, instance, sessionId, timeouts), + ), + ).then(() => undefined); + return released; + }, + }; + } + + /** + * Every worker imports the module fresh, so new code needs no host + * re-import. A source change (or re-enabling) still starts a new import + * id: the plugin gets a clean slate, and copies of the old code go stale. + */ + private refreshIsolatedEntry(entry: PluginEntry): void { + const fingerprint = fingerprintPluginSources(entry.pluginPath); + if (entry.fingerprint === fingerprint && entry.state !== "disabled") { + return; + } + entry.fingerprint = fingerprint; + entry.importId += 1; + entry.blocked = false; + entry.consecutiveFailures = 0; + entry.extension = undefined; + // New code may export a different name. + entry.nameResolved = false; + this.setState(entry, "loading", true); + } + + private async startWorkerCopy( + entry: PluginEntry, + input: PluginSessionLoadInput, + timeouts: PluginTimeouts, + sessionId: string | undefined, + ): Promise< + | { entry: PluginEntry; instance?: PluginInstance } + | { entry: PluginEntry; policyDisabled: true } + | { + entry: PluginEntry; + error: unknown; + phase: "import" | "setup"; + importId: number; + } + > { + const importId = entry.importId; + const instance = newInstance(undefined, entry.name, importId); + instance.scope.sessionId = sessionId; + let ready = false; + // Set once the import finished and setup() began, so a failure after + // that (setup threw, timed out, or crashed the worker) costs only this + // session's copy, as in-process, instead of turning the plugin off. + let setupStarted = false; + const setup = input.setupContext; + const startedAt = Date.now(); + let loaded: Awaited>; + try { + loaded = await loadSandboxedPlugins({ + transport: "worker", + pluginPaths: [entry.pluginPath], + exportName: input.exportName, + importTimeoutMs: timeouts.import, + setupTimeoutMs: timeouts.setup, + hookTimeoutMs: timeouts.hook, + contributionTimeoutMs: timeouts.call, + onEvent: input.emitEvent, + onSetupStarted: () => { + setupStarted = true; + }, + policy: input.policy, + policyNames: { + [entry.pluginPath]: [ + derivePluginName(entry.pluginPath, entry.attributionRoot), + ], + }, + // A worker that dies on its own (stray rejection, process.exit) + // is this plugin's fault: the worker runs nothing else. + onUnexpectedExit: (error) => { + if (!ready || instance.closed) return; + this.recordFailure(entry, "uncaught", error, { + fatal: true, + instance, + sessionId, + }); + }, + providerId: input.providerId, + modelId: input.modelId, + cwd: input.cwd, + workspaceInfo: setup?.workspaceInfo, + session: setup?.session, + client: setup?.client, + user: setup?.user, + logger: setup?.logger, + telemetryAvailable: Boolean(setup?.telemetry), + }); + } catch (error) { + // The worker failed before reporting back: it timed out, crashed, or + // exited during import or setup. + return { + entry, + error, + phase: setupStarted ? "setup" : "import", + importId, + }; + } + const disabled = loaded.disabledByPolicy[0]; + if (disabled) { + await loaded.shutdown().catch(() => {}); + if (importId === entry.importId) { + entry.name = disabled.name; + entry.nameResolved = true; + } + return { entry, policyDisabled: true }; + } + const failure = loaded.failures[0]; + const extension = loaded.extensions?.[0]; + if (failure || !extension) { + await loaded.shutdown().catch(() => {}); + if (!failure) return { entry }; + const error = new Error(failure.message); + if (failure.stack) error.stack = failure.stack; + return { + entry, + error, + phase: failure.phase === "setup" ? "setup" : "import", + importId, + }; + } + ready = true; + instance.extension = extension; + instance.shutdown = loaded.shutdown; + if (importId === entry.importId) { + entry.name = extension.name; + entry.nameResolved = true; + instance.scope.pluginName = extension.name; + entry.extension = extension; + if ( + !entry.blocked && + (entry.state === "loading" || entry.state === "failed") + ) { + this.setState(entry, "ready"); + } + this.log("info", "plugin.import.ready", entry, { + elapsedMs: Date.now() - startedAt, + isolation: "worker", + }); + } + return { entry, instance }; + } + + /** Re-validates a worker plugin's current code in a throwaway worker. */ + private async reloadIsolated(entry: PluginEntry): Promise { + entry.fingerprint = undefined; + this.refreshIsolatedEntry(entry); + const input: PluginSessionLoadInput = { + pluginPaths: [entry.pluginPath], + exportName: entry.isolated?.exportName, + isolation: "worker", + }; + const result = await this.startWorkerCopy( + entry, + input, + this.resolveTimeouts(input), + undefined, + ); + if ("error" in result) { + if (result.importId !== entry.importId) return; + this.recordFailure(entry, result.phase, result.error, { + fatal: result.phase === "import", + ...(result.phase === "setup" ? { state: "failed" as const } : {}), + }); + return; + } + if ("instance" in result) { + await result.instance?.shutdown?.().catch(() => {}); + } + } + + private staleIssue( + entry: PluginEntry, + phase: PluginErrorPhase, + error: unknown, + sessionId: string | undefined, + ): SessionPluginIssue { + const { message, stack } = toErrorParts(error); + return { + name: entry.name, + pluginPath: entry.pluginPath, + state: "failed", + reason: "error", + lastError: { + phase, + message, + ...(stack ? { stack } : {}), + pluginPath: entry.pluginPath, + timestamp: Date.now(), + ...(sessionId ? { sessionId } : {}), + }, + }; + } + + /** + * Records discovery failures and settings-disabled plugins. Settings- + * disabled plugins the session would otherwise use are reported as issues + * with `reason: "settings"`. Returns the entries that failed discovery. + */ + private recordDiscovery( + input: Pick< + PluginSessionLoadInput, + "disabledPluginPaths" | "discoveryFailures" | "policy" | "sessionId" + >, + issues: SessionPluginIssue[], + ): PluginEntry[] { + const failed: PluginEntry[] = []; + for (const { pluginPath, error } of input.discoveryFailures ?? []) { + const entry = this.ensureEntry(pluginPath); + this.recordFailure(entry, "discover", error, { + fatal: true, + sessionId: input.sessionId, + }); + failed.push(entry); + issues.push(this.toIssue(entry, "error")); + } + for (const pluginPath of input.disabledPluginPaths ?? []) { + const entry = this.ensureEntry(pluginPath); + if (entry.state !== "disabled") { + entry.extension = undefined; + entry.spare = undefined; + this.setState(entry, "disabled"); + } + if (this.isEnabledForSession(entry, input)) { + issues.push(this.toIssue(entry, "settings")); + } + } + return failed; + } + + private isEnabledForSession( + entry: PluginEntry, + input: Pick, + ): boolean { + return isPluginEnabledByPolicy(input.policy, [ + entry.name, + derivePluginName(entry.pluginPath, entry.attributionRoot), + ]); + } + + private policyIssue(entry: PluginEntry): SessionPluginIssue { + return { + name: entry.name, + pluginPath: entry.pluginPath, + state: "disabled", + reason: "session_policy", + }; + } + + private resolveTimeouts(input: PluginSessionLoadInput): PluginTimeouts { + return { + import: input.importTimeoutMs ?? this.defaults.import, + setup: this.defaults.setup, + hook: input.hookTimeoutMs ?? this.defaults.hook, + call: input.callTimeoutMs ?? this.defaults.call, + }; + } + + private match(nameOrPath: string): PluginEntry[] { + const target = nameOrPath.trim(); + if (!target) return []; + const absolute = resolve(target); + return [...this.entries.values()].filter( + (entry) => + entry.pluginPath === absolute || + entry.name === target || + derivePluginName(entry.pluginPath, entry.attributionRoot) === target, + ); + } + + private ensureEntry(pluginPath: string): PluginEntry { + const absolute = resolve(pluginPath); + let entry = this.entries.get(absolute); + if (!entry) { + const attributionRoot = resolveAttributionRoot(absolute); + entry = { + pluginPath: absolute, + attributionRoot, + name: derivePluginName(absolute, attributionRoot), + state: "loading", + blocked: false, + consecutiveFailures: 0, + importId: 0, + errorCount: 0, + timeoutCount: 0, + sessions: new Map(), + updatedAt: Date.now(), + }; + this.entries.set(absolute, entry); + } + return entry; + } + + private async ensureLoaded( + pluginPath: string, + options: { force?: boolean; exportName?: string; importTimeoutMs?: number }, + ): Promise { + const entry = this.ensureEntry(pluginPath); + if (entry.loading) { + await entry.loading; + return entry; + } + const fingerprint = fingerprintPluginSources(entry.pluginPath); + const upToDate = + !options.force && + entry.fingerprint === fingerprint && + entry.state !== "disabled" && + entry.state !== "loading"; + if (upToDate) return entry; + + entry.loading = this.importEntry( + entry, + fingerprint, + options.exportName, + options.importTimeoutMs ?? this.defaults.import, + ); + try { + await entry.loading; + } finally { + entry.loading = undefined; + } + return entry; + } + + /** Imports one copy of the module in its own call scope. */ + private async importCopy( + entry: PluginEntry, + exportName: string | undefined, + timeoutMs: number, + sessionId: string | undefined, + ): Promise { + const instance = newInstance(undefined, entry.name, entry.importId); + instance.scope.sessionId = sessionId; + instance.extension = await runWithTimeout( + () => + runInPluginScope(instance.scope, () => + loadAgentPluginFromPath(entry.pluginPath, { + exportName, + freshModule: true, + }), + ), + timeoutMs, + `Plugin import of ${entry.pluginPath}`, + ); + return instance; + } + + /** + * Hands a session its own copy of the plugin module. Plugins written for + * the per-session sandbox keep state at module level (one plugin object + * per import), so sharing one copy across sessions would break them. + */ + private async claimInstance( + entry: PluginEntry, + exportName: string | undefined, + timeouts: PluginTimeouts, + sessionId: string | undefined, + ): Promise { + const spare = entry.spare; + if (spare) { + entry.spare = undefined; + return spare; + } + return this.importCopy(entry, exportName, timeouts.import, sessionId); + } + + private async importEntry( + entry: PluginEntry, + fingerprint: string, + exportName: string | undefined, + importTimeoutMs: number, + ): Promise { + entry.fingerprint = fingerprint; + entry.extension = undefined; + entry.spare = undefined; + // Copies from before this import are stale from here on: what they + // report no longer describes the module being imported. + entry.importId += 1; + this.setState(entry, "loading", true); + const startedAt = Date.now(); + try { + const instance = await this.importCopy( + entry, + exportName, + importTimeoutMs, + undefined, + ); + // A successful re-import (reload or source change) gives the plugin + // a clean slate. + entry.blocked = false; + entry.consecutiveFailures = 0; + entry.extension = instance.extension; + entry.spare = instance; + entry.name = instance.extension.name; + this.setState(entry, "ready"); + this.log("info", "plugin.import.ready", entry, { + elapsedMs: Date.now() - startedAt, + }); + } catch (error) { + this.recordFailure(entry, "import", error, { fatal: true }); + } + } + + private wrapForSession( + entry: PluginEntry, + instance: PluginInstance, + input: PluginSessionLoadInput, + timeouts: PluginTimeouts, + ): AgentExtension { + const extension = instance.extension; + const sessionId = input.sessionId; + const emitEvent = input.emitEvent + ? (name: string, payload?: unknown) => + input.emitEvent?.({ name, payload }) + : undefined; + // Connect the copy's own scope (the one its import-time work holds) to + // this session. + const scope = instance.scope; + scope.sessionId = sessionId; + scope.pluginName = entry.name; + scope.emitEvent = emitEvent; + const originalSetup = extension.setup; + const wrapped: AgentExtension & { __clinePluginPath?: string } = { + ...extension, + __clinePluginPath: entry.pluginPath, + hooks: this.wrapHooks( + entry, + instance, + extension.hooks, + scope, + input, + timeouts, + ), + setup: originalSetup + ? async (api, ctx) => { + if (!this.isUsable(entry, instance)) return; + const sessionContext = { + ...(input.setupContext?.session ?? {}), + ...(ctx.session ?? {}), + }; + const setupContext: PluginSetupContext = { + ...(input.setupContext ?? {}), + ...ctx, + session: + Object.keys(sessionContext).length > 0 + ? sessionContext + : undefined, + cwd: input.cwd ?? ctx.cwd, + emitEvent, + onDispose: (cleanup) => { + if (typeof cleanup === "function") { + instance.disposers.push(cleanup); + } + }, + }; + await this.runSetup( + entry, + instance, + (bufferedApi) => + originalSetup.call(extension, bufferedApi, setupContext), + api, + scope, + input, + timeouts, + ); + } + : undefined, + }; + return wrapped; + } + + private async runSetup( + entry: PluginEntry, + instance: PluginInstance, + invoke: (api: SetupApi) => void | Promise, + api: SetupApi, + scope: PluginCallScope, + input: PluginSessionLoadInput, + timeouts: PluginTimeouts, + ): Promise { + const pending = { + tools: [] as AgentTool[], + commands: [] as AgentExtensionCommand[], + calls: [] as Array<(target: SetupApi) => void>, + }; + const capabilities = new Set( + instance.extension.manifest.capabilities, + ); + const requireCapability = (capability: string, method: string) => { + if (!capabilities.has(capability)) { + throw new Error(`${method} requires the "${capability}" capability`); + } + }; + // Buffer registrations so a setup that throws halfway contributes + // nothing, instead of leaving the session with half a plugin. + const bufferedApi: SetupApi = { + registerTool: (tool) => { + pending.tools.push(tool); + }, + registerCommand: (command) => { + pending.commands.push(command); + }, + registerRule: (rule) => { + requireCapability("rules", "registerRule"); + const wrappedRule = this.wrapRule( + entry, + instance, + rule, + scope, + input, + timeouts, + ); + pending.calls.push((target) => target.registerRule(wrappedRule)); + }, + registerMessageBuilder: (builder) => { + const wrappedBuilder = this.wrapMessageBuilder( + entry, + instance, + builder, + scope, + input, + timeouts, + ); + pending.calls.push((target) => + target.registerMessageBuilder(wrappedBuilder), + ); + }, + registerProvider: (provider) => { + pending.calls.push((target) => target.registerProvider(provider)); + }, + registerAutomationEventType: (eventType) => { + requireCapability("automationEvents", "registerAutomationEventType"); + pending.calls.push((target) => + target.registerAutomationEventType(eventType), + ); + }, + registerMcpServer: (server) => { + requireCapability("mcp", "registerMcpServer"); + pending.calls.push((target) => target.registerMcpServer(server)); + }, + }; + const failSetup = (error: unknown) => { + // Setup failure only costs this session its copy of the plugin; + // other sessions keep theirs, and the next session tries again. + instance.setupFailed = true; + this.recordFailure(entry, "setup", error, { + instance, + sessionId: input.sessionId, + state: "failed", + notify: input.onIssue, + }); + }; + const startedAt = Date.now(); + try { + await runWithTimeout( + () => runInPluginScope(scope, () => invoke(bufferedApi)), + timeouts.setup, + `Plugin "${entry.name}" setup`, + ); + } catch (error) { + failSetup(error); + return; + } + this.warnIfSlow(entry, "setup", startedAt, timeouts.setup, input); + try { + for (const tool of pending.tools) { + api.registerTool( + this.wrapTool(entry, instance, tool, scope, input, timeouts), + ); + } + for (const command of pending.commands) { + api.registerCommand( + this.wrapCommand(entry, instance, command, scope, input, timeouts), + ); + } + for (const call of pending.calls) { + call(api); + } + } catch (error) { + // The host rejected a registration. Rethrow so the contribution + // registry discards what this plugin already committed. + failSetup(error); + throw error; + } + if ( + instance.importId === entry.importId && + !entry.blocked && + entry.state === "failed" + ) { + this.setState(entry, "ready"); + } + } + + private wrapHooks( + entry: PluginEntry, + instance: PluginInstance, + hooks: ExtensionHooks | undefined, + scope: PluginCallScope, + input: PluginSessionLoadInput, + timeouts: PluginTimeouts, + ): ExtensionHooks | undefined { + if (!hooks) return undefined; + const wrapped: Record = {}; + for (const [hookName, hook] of Object.entries(hooks)) { + if (typeof hook !== "function") continue; + const phase: PluginErrorPhase = `hook:${hookName}`; + wrapped[hookName] = async (...args: unknown[]) => { + if (!this.isUsable(entry, instance)) return undefined; + try { + return await this.guardedCall( + entry, + instance, + phase, + timeouts.hook, + scope, + input, + () => (hook as HookFn).apply(hooks, args), + ); + } catch (error) { + if (input.hookErrorMode === "throw") throw error; + return undefined; + } + }; + } + return wrapped as ExtensionHooks; + } + + private wrapTool( + entry: PluginEntry, + instance: PluginInstance, + tool: AgentTool, + scope: PluginCallScope, + input: PluginSessionLoadInput, + timeouts: PluginTimeouts, + ): AgentTool { + const phase: PluginErrorPhase = `tool:${tool.name}`; + return { + ...tool, + execute: async (toolInput: unknown, context: AgentToolContext) => { + this.assertUsable(entry, instance, `tool "${tool.name}"`); + return this.guardedCall( + entry, + instance, + phase, + tool.timeoutMs ?? timeouts.call, + scope, + input, + () => + tool.execute(toolInput, { + ...context, + cwd: context.cwd ?? input.cwd, + emitEvent: context.emitEvent ?? scope.emitEvent, + }), + // A cancelled run is not the plugin's fault. + () => context.signal?.aborted === true, + ); + }, + }; + } + + private wrapCommand( + entry: PluginEntry, + instance: PluginInstance, + command: AgentExtensionCommand, + scope: PluginCallScope, + input: PluginSessionLoadInput, + timeouts: PluginTimeouts, + ): AgentExtensionCommand { + const handler = command.handler; + if (typeof handler !== "function") return command; + return { + ...command, + handler: async (commandInput: string) => { + this.assertUsable(entry, instance, `command "/${command.name}"`); + return this.guardedCall( + entry, + instance, + `command:${command.name}`, + timeouts.call, + scope, + input, + () => handler.call(command, commandInput), + ); + }, + }; + } + + /** A failing rule contributes no text rather than breaking the prompt. */ + private wrapRule( + entry: PluginEntry, + instance: PluginInstance, + rule: AgentExtensionRule, + scope: PluginCallScope, + input: PluginSessionLoadInput, + timeouts: PluginTimeouts, + ): AgentExtensionRule { + const content = rule.content; + if (typeof content !== "function") return rule; + return { + ...rule, + content: async () => { + if (!this.isUsable(entry, instance)) return ""; + try { + return await this.guardedCall( + entry, + instance, + `rule:${rule.id}`, + timeouts.call, + scope, + input, + () => content.call(rule), + ); + } catch { + return ""; + } + }, + }; + } + + /** A failing builder leaves the message unchanged. */ + private wrapMessageBuilder( + entry: PluginEntry, + instance: PluginInstance, + builder: AgentExtensionMessageBuilder, + scope: PluginCallScope, + input: PluginSessionLoadInput, + timeouts: PluginTimeouts, + ): AgentExtensionMessageBuilder { + return { + ...builder, + build: async (messages) => { + if (!this.isUsable(entry, instance)) return messages; + try { + return await this.guardedCall( + entry, + instance, + `messageBuilder:${builder.name}`, + timeouts.call, + scope, + input, + () => builder.build.call(builder, messages), + ); + } catch { + return messages; + } + }, + }; + } + + /** + * Runs one plugin call in its session scope with a timeout, records the + * outcome against the plugin, and rethrows failures. + */ + private async guardedCall( + entry: PluginEntry, + instance: PluginInstance, + phase: PluginErrorPhase, + timeoutMs: number, + scope: PluginCallScope, + input: PluginSessionLoadInput, + call: () => T | Promise, + isCancelled?: () => boolean, + ): Promise { + const startedAt = Date.now(); + try { + const result = await runWithTimeout( + () => runInPluginScope(scope, call), + timeoutMs, + `Plugin "${entry.name}" ${phase}`, + ); + if (instance.importId === entry.importId) entry.consecutiveFailures = 0; + this.warnIfSlow(entry, phase, startedAt, timeoutMs, input); + return result; + } catch (error) { + if (!isCancelled?.()) { + this.recordFailure(entry, phase, error, { + instance, + sessionId: input.sessionId, + }); + } + throw error; + } + } + + /** + * Whether a session's copy may still be called: the plugin is not turned + * off, its setup did not fail, and its session has not ended. + */ + private isUsable(entry: PluginEntry, instance: PluginInstance): boolean { + return !entry.blocked && !instance.setupFailed && !instance.closed; + } + + private assertUsable( + entry: PluginEntry, + instance: PluginInstance, + what: string, + ): void { + if (this.isUsable(entry, instance)) return; + const reason = instance.closed + ? "its session ended" + : entry.lastError + ? `${entry.lastError.phase}: ${entry.lastError.message}` + : "it is turned off"; + throw new Error( + `Plugin "${entry.name}" ${what} is unavailable (${reason})`, + ); + } + + private async releaseInstance( + entry: PluginEntry, + instance: PluginInstance, + sessionId: string | undefined, + timeouts: PluginTimeouts, + ): Promise { + if (instance.closed) return; + instance.closed = true; + if (sessionId) entry.sessions.delete(sessionId); + const scope: PluginCallScope = { sessionId, pluginName: entry.name }; + for (const cleanup of instance.disposers.splice(0).reverse()) { + try { + await runWithTimeout( + () => runInPluginScope(scope, cleanup), + timeouts.hook, + `Plugin "${entry.name}" dispose`, + ); + } catch (error) { + // Log only: a cleanup failure must not change the status that + // other sessions rely on. + const { message, stack } = toErrorParts(error); + this.log("warn", "plugin.error", entry, { + phase: "dispose", + sessionId, + errorMessage: message, + stack, + }); + } + } + // Ending the worker stops everything the copy started (timers, + // sockets, pending work), which in-process cleanup cannot guarantee. + await instance.shutdown?.().catch(() => { + // Best-effort: the worker may already be gone. + }); + } + + private recordFailure( + entry: PluginEntry, + phase: PluginErrorPhase, + error: unknown, + options: { + /** Turn the plugin off for every session. */ + fatal?: boolean; + /** Force this state without blocking (per-session setup failure). */ + state?: PluginRuntimeState; + sessionId?: string; + messagePrefix?: string; + /** Tell the calling session even if the state did not change. */ + notify?: (issue: SessionPluginIssue) => void; + /** The copy the failure came from, when it came from one. */ + instance?: PluginInstance; + }, + ): void { + const { message, stack } = toErrorParts(error); + const timedOut = error instanceof PluginCallTimeoutError; + const stale = + options.instance !== undefined && + options.instance.importId !== entry.importId; + this.log("warn", "plugin.error", entry, { + phase, + sessionId: options.sessionId, + errorMessage: message, + timedOut, + stale, + stack, + }); + const record: PluginErrorRecord = { + phase, + message: `${options.messagePrefix ?? ""}${message}`, + ...(stack ? { stack } : {}), + pluginPath: entry.pluginPath, + timestamp: Date.now(), + ...(options.sessionId ? { sessionId: options.sessionId } : {}), + ...(timedOut ? { timedOut } : {}), + }; + if (stale) { + // A copy from before a reload says nothing about the current module: + // tell its own session, but leave the status and counts alone. + const onIssue = + options.notify ?? + (options.sessionId ? entry.sessions.get(options.sessionId) : undefined); + try { + onIssue?.({ + name: entry.name, + pluginPath: entry.pluginPath, + state: options.state ?? "degraded", + reason: "error", + lastError: record, + }); + } catch { + // Reporting must never turn into a second failure. + } + return; + } + entry.errorCount += 1; + if (timedOut) entry.timeoutCount += 1; + entry.consecutiveFailures += 1; + entry.lastError = record; + const block = + options.fatal === true || + (!options.state && entry.consecutiveFailures >= this.failureThreshold); + const next: PluginRuntimeState = block + ? "failed" + : (options.state ?? "degraded"); + // Sessions are told when the plugin is turned off even if the state + // already read `failed` from a per-session setup failure. + const changed = entry.state !== next || (block && !entry.blocked); + if (block) { + entry.blocked = true; + entry.extension = undefined; + entry.spare = undefined; + } + this.setState(entry, next, true); + const issue = this.toIssue(entry, "error"); + // A per-session failure (setup) concerns only the calling session; + // other sessions' copies still work, so do not tell them otherwise. + const notified: Array<(issue: SessionPluginIssue) => void> = []; + if (changed && !options.state) { + for (const onIssue of entry.sessions.values()) { + if (onIssue) notified.push(onIssue); + } + } + if (options.notify && !notified.includes(options.notify)) { + notified.push(options.notify); + } + for (const onIssue of notified) { + try { + onIssue(issue); + } catch { + // Reporting must never turn into a second failure. + } + } + } + + private setState( + entry: PluginEntry, + state: PluginRuntimeState, + forceNotify = false, + ): void { + const changed = entry.state !== state; + entry.state = state; + entry.updatedAt = Date.now(); + if (!changed && !forceNotify) return; + const status = this.toStatus(entry); + for (const listener of this.listeners) { + try { + listener(status); + } catch { + // A broken listener must not break plugin status tracking. + } + } + } + + private warnIfSlow( + entry: PluginEntry, + phase: string, + startedAt: number, + limitMs: number, + input: PluginSessionLoadInput, + ): void { + // A synchronous plugin call blocks the event loop, so its timeout can + // only be observed after it returns. Log it once it does. + const elapsedMs = Date.now() - startedAt; + if (elapsedMs <= limitMs) return; + this.log("warn", "plugin.call.slow", entry, { + phase, + sessionId: input.sessionId, + elapsedMs, + limitMs, + }); + } + + private log( + level: "info" | "warn", + event: string, + entry: PluginEntry, + fields: Record, + ): void { + const metadata = { + event, + pluginName: entry.name, + pluginPath: entry.pluginPath, + state: entry.state, + ...fields, + }; + if (level === "info") { + this.logger?.debug?.(`[plugins] ${event} ${entry.name}`, metadata); + return; + } + const detail = typeof fields.phase === "string" ? ` (${fields.phase})` : ""; + const message = + typeof fields.errorMessage === "string" ? `: ${fields.errorMessage}` : ""; + this.logger?.log(`[plugins] ${event} ${entry.name}${detail}${message}`, { + severity: "warn", + ...metadata, + }); + } + + private toStatus(entry: PluginEntry): PluginStatusRecord { + return { + name: entry.name, + pluginPath: entry.pluginPath, + state: entry.state, + ...(entry.lastError ? { lastError: { ...entry.lastError } } : {}), + errorCount: entry.errorCount, + timeoutCount: entry.timeoutCount, + sessionIds: [...entry.sessions.keys()].sort(), + ...(entry.extension + ? { + capabilities: [...entry.extension.manifest.capabilities].sort(), + hooks: Object.keys(entry.extension.hooks ?? {}).sort(), + } + : {}), + updatedAt: entry.updatedAt, + }; + } + + private toIssue( + entry: PluginEntry, + reason: SessionPluginIssue["reason"], + ): SessionPluginIssue { + return { + name: entry.name, + pluginPath: entry.pluginPath, + state: entry.state, + reason, + ...(entry.lastError ? { lastError: { ...entry.lastError } } : {}), + }; + } + + private toInitializationFailure( + entry: PluginEntry, + ): PluginInitializationFailure { + return { + pluginPath: entry.pluginPath, + pluginName: entry.name, + phase: entry.lastError?.phase === "setup" ? "setup" : "load", + message: entry.lastError?.message ?? `Plugin is ${entry.state}`, + stack: entry.lastError?.stack, + }; + } +} + +/** One-line, user-facing description of a plugin a session cannot fully use. */ +export function formatSessionPluginIssue(issue: SessionPluginIssue): string { + const phase = issue.lastError?.phase; + const message = issue.lastError?.message ?? "unknown error"; + if (issue.state === "degraded") { + return `Plugin "${issue.name}" ${phase ?? "call"} failed: ${message}. The plugin is still active; repeated failures will turn it off.`; + } + if (issue.state === "disabled") { + return `Plugin "${issue.name}" is disabled${issue.reason === "session_policy" ? " for this session" : " in settings"}.`; + } + const where = + phase === "uncaught" + ? " with an uncaught error" + : phase + ? ` during ${phase}` + : ""; + return `Plugin "${issue.name}" failed${where}: ${message} (${issue.pluginPath}). Its tools and hooks are unavailable.`; +} + +let processPluginRegistry: PluginRegistry | undefined; + +/** + * The registry shared by every session in this process. The Hub daemon reads + * it for `plugins.*` requests and to attribute stray plugin errors. + */ +export function getProcessPluginRegistry(): PluginRegistry { + processPluginRegistry ??= new PluginRegistry(); + return processPluginRegistry; +} + +/** @internal Test hook. */ +export function resetProcessPluginRegistryForTests( + registry?: PluginRegistry, +): void { + processPluginRegistry = registry; +} diff --git a/sdk/packages/core/src/extensions/plugin/plugin-sandbox-bootstrap.ts b/sdk/packages/core/src/extensions/plugin/plugin-sandbox-bootstrap.ts index e4c2ed32b4..91ce629dce 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-sandbox-bootstrap.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-sandbox-bootstrap.ts @@ -1,8 +1,10 @@ /** - * Bootstrap script for the plugin sandbox subprocess. + * Bootstrap script for the plugin sandbox. * - * This file runs inside an isolated Node.js child process spawned by - * {@link SubprocessSandbox}. It receives RPC calls over IPC and dynamically + * This file runs either inside an isolated child process spawned by + * {@link SubprocessSandbox} or inside a worker thread started by + * {@link WorkerSandbox}. It receives RPC calls over IPC (or the worker's + * message port) and dynamically * imports plugin modules, wiring up their contributions (tools, commands, * message builders, providers) and lifecycle hooks. * @@ -10,6 +12,7 @@ * depend on local helpers that can be inlined into the sandbox build. */ +import { isMainThread, parentPort } from "node:worker_threads"; import { type AgentExtensionMcpServer, type AutomationEventEnvelope, @@ -34,6 +37,8 @@ interface PluginTool { timeoutMs?: number; retryable?: boolean; execute: (input: unknown, context: unknown) => Promise; + /** Other definition fields (lifecycle, executionMode, ...) pass through. */ + [field: string]: unknown; } interface PluginCommand { @@ -95,6 +100,9 @@ interface PluginSetupCtx { client?: unknown; user?: unknown; workspaceInfo?: unknown; + cwd?: string; + emitEvent?: (name: string, payload?: unknown) => void; + onDispose?: (cleanup: () => void | Promise) => void; automation?: { ingestEvent(event: AutomationEventEnvelope): void | Promise; }; @@ -161,6 +169,8 @@ interface ContributionDescriptor { inputSchema?: unknown; timeoutMs?: number; retryable?: boolean; + /** Tools: the rest of the tool definition, without functions. */ + definition?: Record; value?: string; defaultValue?: boolean | string | number; metadata?: Record; @@ -224,10 +234,32 @@ interface InitializeResult { plugins: PluginDescriptor[]; failures: PluginInitializationFailure[]; warnings: PluginInitializationWarning[]; + /** Plugins the session policy turned off once their own name was known. */ + disabledByPolicy: Array<{ pluginPath: string; name: string }>; +} + +/** + * The session plugin policy, shaped like `toolPolicies` (`"*"` default plus + * per-plugin overrides). Mirrors `isPluginEnabledByPolicy` in the registry. + */ +type PluginPolicy = Record; + +function isEnabledByPolicy( + policy: PluginPolicy | undefined, + names: ReadonlyArray, +): boolean { + if (!policy) return true; + for (const name of names) { + const enabled = policy[name]?.enabled; + if (typeof enabled === "boolean") return enabled; + } + return policy["*"]?.enabled ?? true; } interface PluginState { plugin: PluginModule; + /** Cleanup registered through `ctx.onDispose`, run by `dispose`. */ + disposers: Array<() => void | Promise>; handlers: { tools: Map; commands: Map>; @@ -245,6 +277,10 @@ type LoadedPluginResult = | { type: "skipped"; } + | { + type: "policy_disabled"; + name: string; + } | { type: "failure"; failure: PluginInitializationFailure; @@ -339,19 +375,105 @@ function toErrorPayload(error: unknown): { message: string; stack?: string } { return { message, stack }; } +/** + * The host link: the worker's message port when running as a worker thread, + * otherwise the child process IPC channel. + */ +const port = isMainThread ? undefined : parentPort; + +if (port) { + // Plugin stdout must never reach the host's stdout, which may carry + // `--json` output or a JSON-RPC stream. The subprocess sandbox discards + // it (stdio "ignore"), and Node workers pipe it to the host, but Bun + // workers write straight to the host's stdout. Discard it here too. + const discard = () => {}; + console.log = discard; + console.info = discard; + console.debug = discard; + console.dir = discard; + console.table = discard; + // write(chunk, cb) and write(chunk, encoding, cb): still complete the + // callback, asynchronously as a real stream does, so a plugin awaiting + // its write does not hang. + process.stdout.write = ((_chunk: unknown, ...rest: unknown[]) => { + const callback = rest.find((arg) => typeof arg === "function") as + | ((error?: Error | null) => void) + | undefined; + if (callback) process.nextTick(callback); + return true; + }) as typeof process.stdout.write; +} + +function isDataCloneError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "DataCloneError" || + /could not be cloned/i.test(error.message)) + ); +} + +function postToHost(message: Record): void { + if (port) { + try { + port.postMessage(message); + } catch (error) { + if (!isDataCloneError(error)) throw error; + // Structured clone rejects functions that JSON IPC silently drops. + // Match the subprocess behavior so plugins act the same in both. + port.postMessage(JSON.parse(JSON.stringify(message))); + } + return; + } + process.send?.(message); +} + function sendResponse( id: string, ok: boolean, result: unknown, error?: { message: string; stack?: string }, ): void { - if (!process.send) return; - process.send({ type: "response", id, ok, result, error }); + postToHost({ type: "response", id, ok, result, error }); } function emitEvent(name: string, payload?: unknown): void { - if (!process.send) return; - process.send({ type: "event", name, payload }); + postToHost({ type: "event", name, payload }); +} + +/** + * Internal events (never forwarded to the session): progress updates for a + * tool call, and the end of a plugin's import so the host can tell an import + * failure from a setup failure. Keep in sync with plugin-sandbox.ts. + */ +const TOOL_UPDATE_EVENT = "__cline_tool_update"; +const SETUP_STARTED_EVENT = "__cline_setup_started"; + +/** A tool's definition fields as plain data (functions dropped). */ +function toolDefinitionData(tool: PluginTool): Record { + const definition: Record = {}; + for (const [key, value] of Object.entries(tool)) { + if (key === "execute" || typeof value === "function") continue; + definition[key] = value; + } + return JSON.parse(JSON.stringify(definition)) as Record; +} + +function withTimeout( + fn: () => T | Promise, + timeoutMs: number | undefined, + label: string, +): Promise { + if (!timeoutMs || timeoutMs <= 0) return Promise.resolve().then(fn); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + Promise.resolve() + .then(fn) + .then(resolve, reject) + .finally(() => clearTimeout(timer)); + }); } // Expose event emitter to plugins. @@ -523,12 +645,17 @@ async function loadPluginDescriptor(args: { targeting: PluginTargeting; setupCtxBase: Pick< PluginSetupCtx, - "session" | "client" | "user" | "workspaceInfo" + "session" | "client" | "user" | "workspaceInfo" | "cwd" >; + setupTimeoutMs?: number; loggerEnabled?: boolean; telemetryEnabled?: boolean; + policy?: PluginPolicy; + /** Names the policy may use besides the plugin's own (its file name). */ + policyNames?: string[]; }): Promise { let plugin: PluginModule | undefined; + const disposers: PluginState["disposers"] = []; try { const moduleExports = await importPluginModule(args.pluginPath); plugin = (moduleExports.default ?? @@ -538,6 +665,16 @@ async function loadPluginDescriptor(args: { if (!matchesPluginManifestTargeting(plugin.manifest, args.targeting)) { return { type: "skipped" }; } + // The plugin's own name is only known after import; decide before + // setup() runs so a plugin the session turned off has no side effects. + if ( + !isEnabledByPolicy(args.policy, [ + plugin.name, + ...(args.policyNames ?? []), + ]) + ) { + return { type: "policy_disabled", name: plugin.name }; + } const contributions: PluginDescriptor["contributions"] = { tools: [], @@ -568,6 +705,7 @@ async function loadPluginDescriptor(args: { inputSchema: tool.inputSchema, timeoutMs: tool.timeoutMs, retryable: tool.retryable, + definition: toolDefinitionData(tool), }); }, registerCommand: (command) => { @@ -625,9 +763,16 @@ async function loadPluginDescriptor(args: { }; if (typeof plugin.setup === "function") { + // Tell the host the import finished, so a setup that hangs or + // crashes the sandbox is reported as setup, not import. + emitEvent(SETUP_STARTED_EVENT, { pluginPath: args.pluginPath }); try { const setupCtx = { ...args.setupCtxBase, + emitEvent, + onDispose: (cleanup: () => void | Promise) => { + if (typeof cleanup === "function") disposers.push(cleanup); + }, // Only offered when the host actually has a telemetry service: // the feature-detection contract (`ctx.telemetry?.capture`) must // mean "someone is listening", matching in-process behavior. @@ -648,8 +793,23 @@ async function loadPluginDescriptor(args: { : {}), }; assertValidPluginSetupCtx(setupCtx); - await plugin.setup(api, setupCtx); + const setup = plugin.setup.bind(plugin); + await withTimeout( + () => setup(api, setupCtx), + args.setupTimeoutMs, + `Plugin "${plugin.name}" setup`, + ); } catch (error) { + // The plugin is dropped, so nothing would run its cleanup later. + // Run it now: a child process started before the failure would + // otherwise outlive the worker. + await withTimeout( + () => runDisposers(plugin?.name ?? args.pluginPath, disposers), + args.setupTimeoutMs ?? DISPOSE_AFTER_FAILED_SETUP_TIMEOUT_MS, + `Plugin "${plugin.name}" cleanup after failed setup`, + ).catch(() => { + // Best effort; the failure below is what gets reported. + }); return { type: "failure", failure: { @@ -665,7 +825,7 @@ async function loadPluginDescriptor(args: { return { type: "loaded", - state: { plugin, handlers }, + state: { plugin, handlers, disposers }, descriptor: { pluginId: args.pluginId, pluginPath: args.pluginPath, @@ -709,6 +869,9 @@ async function initialize(args: { workspaceInfo?: unknown; loggerEnabled?: boolean; telemetryEnabled?: boolean; + setupTimeoutMs?: number; + policy?: PluginPolicy; + policyNames?: Record; }): Promise { pluginState.clear(); pluginCounter = 0; @@ -718,7 +881,14 @@ async function initialize(args: { // path resolution inside plugins reflect the correct workspace, not the // host process's cwd (which may differ when --cwd was used without calling // process.chdir() on the host side). - if (args.cwd) { + // A worker must not chdir: Node throws, and Bun changes the cwd of the + // whole host process. Overriding process.cwd() is local to the worker, so + // plugins still see the session's directory. Relative fs paths keep + // resolving against the host cwd. + if (args.cwd && port) { + const cwd = args.cwd; + process.cwd = () => cwd; + } else if (args.cwd) { try { process.chdir(args.cwd); } catch { @@ -737,6 +907,7 @@ async function initialize(args: { const descriptors: PluginDescriptor[] = []; const failures: PluginInitializationFailure[] = []; const warnings: PluginInitializationWarning[] = []; + const disabledByPolicy: InitializeResult["disabledByPolicy"] = []; const exportName = args.exportName || "plugin"; const pluginIndexByName = new Map(); const targeting: PluginTargeting = { @@ -748,6 +919,7 @@ async function initialize(args: { client: args.client, user: args.user, workspaceInfo: args.workspaceInfo, + cwd: args.cwd, }; const loadResults = await Promise.all( @@ -759,16 +931,26 @@ async function initialize(args: { exportName, targeting, setupCtxBase, + setupTimeoutMs: args.setupTimeoutMs, + policy: args.policy, + policyNames: args.policyNames?.[pluginPath], loggerEnabled: args.loggerEnabled, telemetryEnabled: args.telemetryEnabled, }); }), ); - for (const result of loadResults) { + for (const [index, result] of loadResults.entries()) { if (result.type === "skipped") { continue; } + if (result.type === "policy_disabled") { + disabledByPolicy.push({ + pluginPath: args.pluginPaths?.[index] ?? "", + name: result.name, + }); + continue; + } if (result.type === "failure") { failures.push(result.failure); continue; @@ -802,7 +984,7 @@ async function initialize(args: { descriptors.push(descriptor); } - return { plugins: descriptors, failures, warnings }; + return { plugins: descriptors, failures, warnings, disabledByPolicy }; } async function invokeHook(args: { @@ -820,18 +1002,78 @@ async function invokeHook(args: { ); } +/** Abort controllers for tool calls in flight, by host call id. */ +const toolCallControllers = new Map(); + async function executeTool(args: { pluginId: string; contributionId: string; input: unknown; context: unknown; + callId?: string; + aborted?: boolean; }): Promise { const state = getPlugin(args.pluginId); const handler = state.handlers.tools.get(args.contributionId); if (typeof handler !== "function") { throw new Error("Unknown sandbox tool contribution"); } - return await handler(args.input, args.context); + // Rebuild the live parts of the tool context the host could not send: + // an abort signal the host drives through `abortToolCall`, progress + // updates, and session events. + const callId = args.callId; + const controller = new AbortController(); + if (args.aborted) controller.abort(); + if (callId) toolCallControllers.set(callId, controller); + const context = { + ...sanitizeObject(args.context), + signal: controller.signal, + emitEvent, + emitUpdate: (update: unknown) => { + if (callId) emitEvent(TOOL_UPDATE_EVENT, { callId, update }); + }, + }; + try { + return await handler(args.input, context); + } finally { + if (callId) toolCallControllers.delete(callId); + } +} + +async function abortToolCall(args: { + callId: string; + reason?: string; +}): Promise { + toolCallControllers + .get(args.callId) + ?.abort(args.reason ? new Error(args.reason) : undefined); +} + +/** Runs every plugin's `ctx.onDispose` cleanup, newest first. */ +async function dispose(): Promise { + for (const state of pluginState.values()) { + await runDisposers(state.plugin.name, state.disposers); + } +} + +const DISPOSE_AFTER_FAILED_SETUP_TIMEOUT_MS = 3_000; + +/** Runs (and clears) `ctx.onDispose` cleanup, newest first. */ +async function runDisposers( + pluginName: string, + disposers: PluginState["disposers"], +): Promise { + for (const cleanup of disposers.splice(0).reverse()) { + try { + await cleanup(); + } catch (error) { + emitEvent("plugin_log", { + level: "error", + pluginName, + message: `onDispose cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } } async function executeCommand(args: { @@ -880,36 +1122,42 @@ const methods: Record Promise> = { initialize, invokeHook, executeTool, + abortToolCall, + dispose, executeCommand, buildMessages, resolveRuleContent, }; -installParentDisconnectGuard(); +async function onHostMessage(message: { + type: string; + id: string; + method: string; + args?: unknown; +}): Promise { + if (!message || message.type !== "call") { + return; + } + const method = methods[message.method]; + if (!method) { + sendResponse(message.id, false, undefined, { + message: `Unknown method: ${String(message.method)}`, + }); + return; + } + try { + const result = await method((message.args || {}) as never); + sendResponse(message.id, true, result); + } catch (error) { + sendResponse(message.id, false, undefined, toErrorPayload(error)); + } +} -process.on( - "message", - async (message: { - type: string; - id: string; - method: string; - args?: unknown; - }) => { - if (!message || message.type !== "call") { - return; - } - const method = methods[message.method]; - if (!method) { - sendResponse(message.id, false, undefined, { - message: `Unknown method: ${String(message.method)}`, - }); - return; - } - try { - const result = await method((message.args || {}) as never); - sendResponse(message.id, true, result); - } catch (error) { - sendResponse(message.id, false, undefined, toErrorPayload(error)); - } - }, -); +if (port) { + // Terminating the worker ends it; there is no IPC channel to watch. + port.on("message", onHostMessage); + postToHost({ type: "ready" }); +} else { + installParentDisconnectGuard(); + process.on("message", onHostMessage); +} diff --git a/sdk/packages/core/src/extensions/plugin/plugin-sandbox.test.ts b/sdk/packages/core/src/extensions/plugin/plugin-sandbox.test.ts index bf742d2267..7890d60019 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-sandbox.test.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-sandbox.test.ts @@ -59,7 +59,10 @@ function makeSnapshot() { }; } -describe("plugin-sandbox", () => { +describe.each([ + "subprocess", + "worker", +] as const)("plugin-sandbox (%s)", (transport) => { let dir = ""; let sharedSandbox: | Awaited> @@ -383,6 +386,7 @@ describe("plugin-sandbox", () => { ); sharedSandbox = await loadSandboxedPlugins({ + transport, pluginPaths: [ join(dir, "plugin.mjs"), join(dir, "plugin-events.mjs"), @@ -532,6 +536,7 @@ describe("plugin-sandbox", () => { ); const events: Array<{ name: string; payload?: unknown }> = []; const sandbox = await loadSandboxedPlugins({ + transport, pluginPaths: [pluginPath], importTimeoutMs: 30_000, // telemetryAvailable intentionally omitted: no host service. @@ -608,6 +613,7 @@ describe("plugin-sandbox", () => { ); const sandboxed = await loadSandboxedPlugins({ + transport, pluginPaths: [pluginPath], hookTimeoutMs: 50, }); @@ -646,7 +652,7 @@ describe("plugin-sandbox", () => { // the per-test timeout (3000 ms below) would fail it. vi.stubEnv("CLINE_PLUGIN_IMPORT_TIMEOUT_MS", "150"); await expect( - loadSandboxedPlugins({ pluginPaths: [pluginPath] }), + loadSandboxedPlugins({ pluginPaths: [pluginPath], transport }), ).rejects.toThrow(/timed out/i); } finally { vi.unstubAllEnvs(); @@ -662,6 +668,7 @@ describe("plugin-sandbox", () => { await writeFile( pluginPath, [ + "import { threadId } from 'node:worker_threads';", "export default {", " name: 'sandbox-idle',", " manifest: { capabilities: ['tools'] },", @@ -670,7 +677,7 @@ describe("plugin-sandbox", () => { " name: 'sandbox_process_id',", " description: 'return the sandbox process id',", " inputSchema: { type: 'object', properties: {}, required: [] },", - " execute: async () => ({ pid: process.pid }),", + " execute: async () => ({ pid: process.pid + ':' + threadId }),", " });", " },", "};", @@ -681,6 +688,7 @@ describe("plugin-sandbox", () => { vi.stubEnv(CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV, "1000"); vi.useFakeTimers(); sandboxed = await loadSandboxedPlugins({ + transport, pluginPaths: [pluginPath], importTimeoutMs: 30_000, }); @@ -694,11 +702,11 @@ describe("plugin-sandbox", () => { conversationId: "conv-1", iteration: 1, } as AgentToolContext; - const first = (await tool?.execute({}, context)) as { pid: number }; + const first = (await tool?.execute({}, context)) as { pid: string }; await vi.advanceTimersByTimeAsync(1001); - const second = (await tool?.execute({}, context)) as { pid: number }; + const second = (await tool?.execute({}, context)) as { pid: string }; expect(second.pid).not.toBe(first.pid); } finally { vi.useRealTimers(); @@ -984,6 +992,7 @@ describe("plugin-sandbox", () => { it("continues loading remaining sandbox plugins when one setup fails", async () => { const sandboxed = await loadSandboxedPlugins({ + transport, pluginPaths: [ join(dir, "plugin.mjs"), join(dir, "plugin-broken-setup.mjs"), @@ -1006,6 +1015,7 @@ describe("plugin-sandbox", () => { it("keeps the later duplicate sandbox plugin and reports the override", async () => { const sandboxed = await loadSandboxedPlugins({ + transport, pluginPaths: [ join(dir, "plugin-duplicate-a.mjs"), join(dir, "plugin-duplicate-b.mjs"), @@ -1059,6 +1069,7 @@ describe("plugin-sandbox", () => { it("loads sandbox plugins matching manifest providerIds and modelIds", async () => { const matched = await loadSandboxedPlugins({ + transport, pluginPaths: [join(dir, "plugin-targeted.mjs")], providerId: "openai", modelId: "gpt-5.4", @@ -1076,6 +1087,7 @@ describe("plugin-sandbox", () => { it("filters sandbox plugins with non-matching manifest providerIds and modelIds", async () => { const filtered = await loadSandboxedPlugins({ + transport, pluginPaths: [join(dir, "plugin-targeted.mjs")], providerId: "anthropic", modelId: "claude-sonnet-4.5", @@ -1089,3 +1101,167 @@ describe("plugin-sandbox", () => { } }, 15_000); }); + +describe("plugin-sandbox (worker only)", () => { + let dir = ""; + const context = { + agentId: "agent-1", + conversationId: "conv-1", + iteration: 1, + } as AgentToolContext; + + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "core-plugin-worker-")); + }); + + afterAll(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function loadToolPlugin( + name: string, + executeBody: string, + options: { cwd?: string; contributionTimeoutMs?: number } = {}, + ) { + const pluginPath = join(dir, `${name}.mjs`); + await writeFile( + pluginPath, + [ + "export default {", + ` name: '${name}',`, + " manifest: { capabilities: ['tools'] },", + " setup(api) {", + " api.registerTool({", + ` name: '${name}',`, + " description: 'test tool',", + " inputSchema: { type: 'object', properties: {} },", + ` execute: async (input) => { ${executeBody} },`, + " });", + " },", + "};", + ].join("\n"), + "utf8", + ); + const sandboxed = await loadSandboxedPlugins({ + transport: "worker", + pluginPaths: [pluginPath], + ...options, + }); + const { tools, api } = createApiCapture(); + await sandboxed.extensions?.[0]?.setup?.(api, {}); + const tool = tools.find((entry) => entry.name === name); + if (!tool) throw new Error(`tool ${name} not registered`); + return { sandboxed, tool }; + } + + it("terminates a plugin stuck in a synchronous loop and recovers", async () => { + const { sandboxed, tool } = await loadToolPlugin( + "worker_spin", + "if (input.spin) { for (;;) {} } return 'ok';", + // The recovery call boots a fresh worker and re-imports the plugin + // within this same limit, so leave room for slow CI machines. + { contributionTimeoutMs: 3000 }, + ); + try { + await expect(tool.execute({ spin: true }, context)).rejects.toThrow( + /timed out/i, + ); + // The host event loop kept running, and the next call gets a fresh + // worker that re-imports the plugin. + await expect(tool.execute({ spin: false }, context)).resolves.toBe("ok"); + } finally { + await sandboxed.shutdown(); + } + }, 20_000); + + it("reports the session cwd without changing the host cwd", async () => { + const hostCwd = process.cwd(); + const { sandboxed, tool } = await loadToolPlugin( + "worker_cwd", + "return process.cwd();", + { cwd: dir }, + ); + try { + await expect(tool.execute({}, context)).resolves.toBe(dir); + expect(process.cwd()).toBe(hostCwd); + } finally { + await sandboxed.shutdown(); + } + }); + + it("does not need a JavaScript runtime executable", async () => { + vi.stubEnv("CLINE_JS_RUNTIME_PATH", join(dir, "missing-node")); + try { + const { sandboxed, tool } = await loadToolPlugin( + "worker_no_runtime", + "return 'ran';", + ); + try { + await expect(tool.execute({}, context)).resolves.toBe("ran"); + } finally { + await sandboxed.shutdown(); + } + } finally { + vi.unstubAllEnvs(); + } + }); + + it("contains a stray throw from a plugin timer", async () => { + const { sandboxed, tool } = await loadToolPlugin( + "worker_stray", + "if (input.boom) { setTimeout(() => { throw new Error('stray boom'); }, 0); return new Promise(() => {}); } return 'alive';", + ); + try { + await expect(tool.execute({ boom: true }, context)).rejects.toThrow( + /stray boom/, + ); + await expect(tool.execute({}, context)).resolves.toBe("alive"); + } finally { + await sandboxed.shutdown(); + } + }, 20_000); + + it("ends only the worker when a plugin calls process.exit", async () => { + const { sandboxed, tool } = await loadToolPlugin( + "worker_exit", + "if (input.exit) { process.exit(7); } return 'alive';", + ); + try { + await expect(tool.execute({ exit: true }, context)).rejects.toThrow( + /exited \(code=7\)/, + ); + await expect(tool.execute({}, context)).resolves.toBe("alive"); + } finally { + await sandboxed.shutdown(); + } + }, 20_000); + + it("discards plugin stdout but completes write callbacks", async () => { + const { sandboxed, tool } = await loadToolPlugin( + "worker_stdout", + [ + "console.log('discarded');", + "await new Promise((resolve) => process.stdout.write('a', resolve));", + "await new Promise((resolve) => process.stdout.write('b', 'utf8', resolve));", + "return process.stdout.write('c');", + ].join(" "), + ); + try { + await expect(tool.execute({}, context)).resolves.toBe(true); + } finally { + await sandboxed.shutdown(); + } + }); + + it("drops functions from results like the JSON IPC does", async () => { + const { sandboxed, tool } = await loadToolPlugin( + "worker_uncloneable", + "return { value: 1, fn: () => 2 };", + ); + try { + await expect(tool.execute({}, context)).resolves.toEqual({ value: 1 }); + } finally { + await sandboxed.shutdown(); + } + }); +}); diff --git a/sdk/packages/core/src/extensions/plugin/plugin-sandbox.ts b/sdk/packages/core/src/extensions/plugin/plugin-sandbox.ts index 5442038587..36c093d37f 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-sandbox.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-sandbox.ts @@ -10,16 +10,24 @@ import type { AgentExtensionRule, AgentRuntimeHooks, AgentTool, + AgentToolContext, Message, PluginSetupContext, WorkspaceInfo, } from "@cline/shared"; import { SubprocessSandbox } from "../../runtime/tools/subprocess-sandbox"; import { MAX_NODE_TIMER_DELAY_MS } from "../../runtime/tools/subprocess-sandbox-lifecycle"; +import { WorkerSandbox } from "../../runtime/tools/worker-sandbox"; import type { PluginLoadDiagnostics } from "./plugin-load-report"; import type { PluginTargeting } from "./plugin-targeting"; export const CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV = "CLINE_PLUGIN_IDLE_TIMEOUT_MS"; +/** + * Absolute path to a compiled `plugin-sandbox-bootstrap.js`, for hosts that + * ship it somewhere this module cannot find on its own (an app bundle's + * resources directory). + */ +export const CLINE_PLUGIN_BOOTSTRAP_PATH_ENV = "CLINE_PLUGIN_BOOTSTRAP_PATH"; export const DEFAULT_PLUGIN_SANDBOX_IDLE_TIMEOUT_MS = 30 * 60 * 1000; export type SandboxedPluginSetupContext = Pick< @@ -27,8 +35,19 @@ export type SandboxedPluginSetupContext = Pick< "session" | "client" | "user" | "workspaceInfo" | "logger" >; +/** + * Where sandboxed plugins run. `subprocess` spawns a `node`/`bun` child and + * needs one on PATH; `worker` runs a worker thread inside the host runtime. + */ +export type PluginSandboxTransport = "subprocess" | "worker"; + +/** The call/shutdown surface both sandbox transports share. */ +type SandboxChannel = Pick; + export interface PluginSandboxOptions extends PluginTargeting { pluginPaths: string[]; + /** Defaults to `subprocess`. */ + transport?: PluginSandboxTransport; exportName?: string; /** * Max wall time for plugin module imports. Defaults to 4000 ms; falls back @@ -46,6 +65,23 @@ export interface PluginSandboxOptions extends PluginTargeting { */ idleTimeoutMs?: number; onEvent?: (event: { name: string; payload?: unknown }) => void; + /** Worker transport only: the worker ended on its own (crash, exit). */ + onUnexpectedExit?: (error: Error) => void; + /** + * Limit for each plugin's `setup()`, timed inside the sandbox and + * separately from the import. When set, the initialize call may take + * `importTimeoutMs + setupTimeoutMs`. + */ + setupTimeoutMs?: number; + /** A plugin finished importing and its `setup()` is starting. */ + onSetupStarted?: (pluginPath: string) => void; + /** + * Session plugin policy, applied inside the sandbox after each plugin is + * imported (when its own name is known) and before its `setup()` runs. + */ + policy?: Record; + /** Per plugin path: other names the policy may use (its file name). */ + policyNames?: Record; /** * The session's working directory. Forwarded to the sandbox subprocess so * that `process.cwd()` returns the correct path inside the sandbox even @@ -87,8 +123,20 @@ type SandboxedContributionDescriptor = { timeoutMs?: number; retryable?: boolean; metadata?: Record; + /** Tools: the rest of the tool definition (lifecycle, executionMode, ...). */ + definition?: Record; }; +/** + * Internal sandbox events, never forwarded to the session. Keep in sync with + * plugin-sandbox-bootstrap.ts. + */ +const TOOL_UPDATE_EVENT = "__cline_tool_update"; +const SETUP_STARTED_EVENT = "__cline_setup_started"; + +/** Progress callbacks of tool calls in flight, by call id. */ +type ToolUpdateRoutes = Map void>; + type SandboxedRuleDescriptor = Omit & { id: string; ruleId: string; @@ -122,6 +170,7 @@ type SandboxedPluginDescriptor = { type SandboxedInitializeResult = { plugins: SandboxedPluginDescriptor[]; + disabledByPolicy?: Array<{ pluginPath: string; name: string }>; } & PluginLoadDiagnostics; function normalizeDescriptor( @@ -229,16 +278,25 @@ export function selectBootstrapCandidate(options: { * (unbundled, where only the `.ts` source exists), we load the TypeScript * bootstrap through jiti from an inline script. */ -function resolveBootstrap(): { file: string } | { script: string } { +function resolveBootstrap(): + | { file: string } + | { script: string; sourcePath: string } { const dir = dirname(fileURLToPath(import.meta.url)); const requireFromHere = createRequire(import.meta.url); // In dev, the bootstrap sits next to this file in src/extensions/. // In production, the main bundle is at dist/ and the bootstrap is emitted // under dist/extensions/. Keep the older dist/agents/ fallback for // compatibility with previously built layouts. + const configured = process.env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]?.trim(); + if (configured && existsSync(configured)) { + return { file: configured }; + } const selected = selectBootstrapCandidate({ siblingCandidates: [ join(dir, "plugin-sandbox-bootstrap.js"), + // Hosts whose package.json is not `"type": "module"` (the VS Code + // extension) ship it as .mjs so Node loads it as ESM. + join(dir, "plugin-sandbox-bootstrap.mjs"), join(dir, "extensions", "plugin-sandbox-bootstrap.js"), join(dir, "agents", "plugin-sandbox-bootstrap.js"), ], @@ -259,6 +317,7 @@ function resolveBootstrap(): { file: string } | { script: string } { // Fall back to bare specifier and let runtime resolution handle it. } return { + sourcePath: tsPath, script: [ `const createJiti = require(${JSON.stringify(jitiSpecifier)});`, `const jiti = createJiti(${JSON.stringify(tsPath)}, { cache: false, requireCache: false, esmResolve: true, interopDefault: false });`, @@ -270,7 +329,26 @@ function resolveBootstrap(): { file: string } | { script: string } { }; } -const BOOTSTRAP = resolveBootstrap(); +let resolvedBootstrap: ReturnType | undefined; + +/** + * Resolved on first use, not at import, so a host can set + * `CLINE_PLUGIN_BOOTSTRAP_PATH` after loading this module. + */ +function getBootstrap(): ReturnType { + resolvedBootstrap ??= resolveBootstrap(); + return resolvedBootstrap; +} + +/** + * Whether this build can start the sandbox bootstrap: the compiled file ships + * beside it, or its TypeScript source exists (running from source). Bundles + * that ship neither cannot isolate plugins. + */ +export function isPluginSandboxBootstrapAvailable(): boolean { + const bootstrap = getBootstrap(); + return "file" in bootstrap || existsSync(bootstrap.sourcePath); +} function withTimeoutFallback( timeoutMs: number | undefined, @@ -311,6 +389,8 @@ export async function loadSandboxedPlugins( { extensions: AgentConfig["extensions"]; pluginPaths: string[]; + /** Plugins the `policy` turned off; their setup did not run. */ + disabledByPolicy: Array<{ pluginPath: string; name: string }>; shutdown: () => Promise; } & PluginLoadDiagnostics > { @@ -319,20 +399,54 @@ export async function loadSandboxedPlugins( DEFAULT_PLUGIN_SANDBOX_IDLE_TIMEOUT_MS, CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV, ); - const sandbox = new SubprocessSandbox({ - name: "plugin-sandbox", - ...("file" in BOOTSTRAP - ? { bootstrapFile: BOOTSTRAP.file } - : { bootstrapScript: BOOTSTRAP.script }), - idleTimeoutMs, - onEvent: options.onEvent, - }); + const resolved = getBootstrap(); + const bootstrap = + "file" in resolved + ? { bootstrapFile: resolved.file } + : { bootstrapScript: resolved.script }; const importTimeoutMs = withTimeoutFallback( options.importTimeoutMs, 4000, "CLINE_PLUGIN_IMPORT_TIMEOUT_MS", ); const hookTimeoutMs = withTimeoutFallback(options.hookTimeoutMs, 3000); + const setupTimeoutMs = + options.setupTimeoutMs === undefined + ? undefined + : withTimeoutFallback(options.setupTimeoutMs, 4000); + const initTimeoutMs = importTimeoutMs + (setupTimeoutMs ?? 0); + const toolUpdates: ToolUpdateRoutes = new Map(); + const onEvent = (event: { name: string; payload?: unknown }) => { + const payload = event.payload as Record | undefined; + if (event.name === TOOL_UPDATE_EVENT) { + toolUpdates.get(String(payload?.callId))?.(payload?.update); + return; + } + if (event.name === SETUP_STARTED_EVENT) { + options.onSetupStarted?.(String(payload?.pluginPath)); + return; + } + options.onEvent?.(event); + }; + // One sandbox per call: each session gets its own process or worker. + const sandbox: SandboxChannel = + options.transport === "worker" + ? new WorkerSandbox({ + name: "plugin-worker", + ...bootstrap, + idleTimeoutMs, + onEvent, + onUnexpectedExit: options.onUnexpectedExit, + // Runs ctx.onDispose cleanup before an idle reclaim or shutdown. + disposeMethod: "dispose", + disposeTimeoutMs: hookTimeoutMs, + }) + : new SubprocessSandbox({ + name: "plugin-sandbox", + ...bootstrap, + idleTimeoutMs, + onEvent, + }); const contributionTimeoutMs = withTimeoutFallback( options.contributionTimeoutMs, 60_000, @@ -349,6 +463,9 @@ export async function loadSandboxedPlugins( workspaceInfo: options.workspaceInfo, loggerEnabled: Boolean(options.logger), telemetryEnabled: options.telemetryAvailable === true, + setupTimeoutMs, + policy: options.policy, + policyNames: options.policyNames, }; // Guard against concurrent re-initialization when multiple tools/hooks @@ -356,7 +473,7 @@ export async function loadSandboxedPlugins( let reinitPromise: Promise | undefined; const reinitialize = (): Promise => { reinitPromise ??= sandbox - .call("initialize", initArgs, { timeoutMs: importTimeoutMs }) + .call("initialize", initArgs, { timeoutMs: initTimeoutMs }) .finally(() => { reinitPromise = undefined; }); @@ -368,7 +485,7 @@ export async function loadSandboxedPlugins( initialized = await sandbox.call( "initialize", initArgs, - { timeoutMs: importTimeoutMs }, + { timeoutMs: initTimeoutMs }, ); } catch (error) { await sandbox.shutdown().catch(() => { @@ -391,6 +508,7 @@ export async function loadSandboxedPlugins( descriptor, contributionTimeoutMs, reinitialize, + toolUpdates, ); registerCommands( api, @@ -430,6 +548,7 @@ export async function loadSandboxedPlugins( return { extensions, + disabledByPolicy: initialized.disabledByPolicy ?? [], failures: initialized.failures, pluginPaths: descriptors.map((descriptor) => descriptor.pluginPath), shutdown: async () => { @@ -444,8 +563,8 @@ export async function loadSandboxedPlugins( // --------------------------------------------------------------------------- /** - * Tool contexts and hook payloads cross the sandbox process boundary over - * JSON IPC, so they must survive JSON.stringify. Host code must not place + * Tool contexts and hook payloads cross the sandbox boundary over JSON IPC + * (subprocess) or structured clone (worker), so they must be plain data. Host code must not place * live host objects (telemetry services, sockets, abort signals) on them, * but a single offender would otherwise fail every sandboxed call — the * telemetry service on toolContextMetadata did exactly that ("JSON.stringify @@ -460,7 +579,11 @@ function isSerializationError(error: unknown): boolean { // "JSON.stringify cannot serialize BigInt." // Node: "Converting circular structure to JSON" / // "Do not know how to serialize a BigInt" - return /cyclic|circular|bigint/i.test(message); + // Worker: DataCloneError "... could not be cloned" (Node) / + // "The object can not be cloned" (Bun) + return /cyclic|circular|bigint|can ?not be cloned|could not be cloned/i.test( + message, + ); } /** @@ -548,15 +671,21 @@ async function callWithSerializableFallback( } } +let toolCallCounter = 0; + function registerTools( api: AgentExtensionApi, - sandbox: SubprocessSandbox, + sandbox: SandboxChannel, descriptor: SandboxedPluginDescriptor, timeoutMs: number, reinitialize: () => Promise, + toolUpdates: ToolUpdateRoutes, ): void { for (const td of descriptor.contributions?.tools ?? []) { const tool: AgentTool = { + // The full definition (lifecycle, executionMode, resultPolicy, ...) + // so the runtime treats the tool as the plugin declared it. + ...(td.definition ?? {}), name: td.name, description: td.description ?? "", inputSchema: (td.inputSchema ?? { @@ -565,7 +694,33 @@ function registerTools( }) as AgentTool["inputSchema"], timeoutMs: td.timeoutMs, retryable: td.retryable, - execute: async (input: unknown, context: unknown) => { + execute: async (input: unknown, context: AgentToolContext) => { + // The live parts of the context cannot cross the boundary. The + // sandbox rebuilds them: an abort signal driven by `abortToolCall`, + // progress updates routed back by call id, and session events + // (which already reach the session through the sandbox's events). + const { + signal, + emitUpdate, + emitEvent: _emitEvent, + ...plainContext + } = context ?? ({} as AgentToolContext); + const callId = `${descriptor.pluginId}:${++toolCallCounter}`; + if (emitUpdate) toolUpdates.set(callId, emitUpdate); + const onAbort = () => { + sandbox + .call("abortToolCall", { + callId, + reason: + signal?.reason instanceof Error + ? signal.reason.message + : undefined, + }) + .catch(() => { + // The call may already have finished. + }); + }; + signal?.addEventListener("abort", onAbort, { once: true }); // The fallback must cover the whole IPC payload: `input` can be // rewritten by beforeTool hooks or programmatic callers, so it is // just as capable of smuggling a non-serializable value as the @@ -575,39 +730,38 @@ function registerTools( input: unknown; context: unknown; }; + const callArgs = () => ({ + pluginId: descriptor.pluginId, + contributionId: td.id, + input: sandboxInput, + context: sandboxContext, + callId, + aborted: signal?.aborted === true, + }); try { - return await sandbox.call( - "executeTool", - { - pluginId: descriptor.pluginId, - contributionId: td.id, - input: sandboxInput, - context: sandboxContext, - }, - { timeoutMs }, - ); + return await sandbox.call("executeTool", callArgs(), { + timeoutMs: td.timeoutMs ?? timeoutMs, + }); } catch (error) { if (!isUnknownPluginIdError(error)) { throw error; } await reinitialize(); - return await sandbox.call( - "executeTool", - { - pluginId: descriptor.pluginId, - contributionId: td.id, - input: sandboxInput, - context: sandboxContext, - }, - { timeoutMs }, - ); + return await sandbox.call("executeTool", callArgs(), { + timeoutMs: td.timeoutMs ?? timeoutMs, + }); } }; - return await callWithSerializableFallback( - invoke, - { input, context }, - (error) => warnNonSerializablePayloadOnce("tool", td.name, error), - ); + try { + return await callWithSerializableFallback( + invoke, + { input, context: plainContext }, + (error) => warnNonSerializablePayloadOnce("tool", td.name, error), + ); + } finally { + signal?.removeEventListener("abort", onAbort); + toolUpdates.delete(callId); + } }, }; api.registerTool(tool); @@ -616,7 +770,7 @@ function registerTools( function registerCommands( api: AgentExtensionApi, - sandbox: SubprocessSandbox, + sandbox: SandboxChannel, descriptor: SandboxedPluginDescriptor, timeoutMs: number, reinitialize: () => Promise, @@ -658,7 +812,7 @@ function registerCommands( function registerRules( api: AgentExtensionApi, - sandbox: SubprocessSandbox, + sandbox: SandboxChannel, descriptor: SandboxedPluginDescriptor, timeoutMs: number, reinitialize: () => Promise, @@ -731,7 +885,7 @@ function registerSimpleContributions( function registerMessageBuilders( api: AgentExtensionApi, - sandbox: SubprocessSandbox, + sandbox: SandboxChannel, descriptor: SandboxedPluginDescriptor, timeoutMs: number, reinitialize: () => Promise, @@ -786,7 +940,7 @@ function isMessageArray(value: unknown): value is Message[] { } function makeHookHandler( - sandbox: SubprocessSandbox, + sandbox: SandboxChannel, pluginId: string, hookName: string, timeoutMs: number, @@ -819,7 +973,7 @@ function makeHookHandler( } function createSandboxRuntimeHooks( - sandbox: SubprocessSandbox, + sandbox: SandboxChannel, descriptor: SandboxedPluginDescriptor, hookTimeoutMs: number, reinitialize: () => Promise, diff --git a/sdk/packages/core/src/hub/client/session-client.ts b/sdk/packages/core/src/hub/client/session-client.ts index 050401f875..338c9d9a8a 100644 --- a/sdk/packages/core/src/hub/client/session-client.ts +++ b/sdk/packages/core/src/hub/client/session-client.ts @@ -377,11 +377,15 @@ export class HubSessionClient { apiKey: request.apiKey, }, toolPolicies: request.toolPolicies, + ...(request.plugins ? { plugins: request.plugins } : {}), }); const row = extractSessionRow(reply.payload); if (!row?.sessionId) { throw new Error("hub session create returned no session id"); } + const pluginIssues = Array.isArray(reply.payload?.pluginIssues) + ? (reply.payload.pluginIssues as ChatStartSessionResponse["pluginIssues"]) + : undefined; return { sessionId: row.sessionId, startResult: { @@ -389,6 +393,7 @@ export class HubSessionClient { manifestPath: "", messagesPath: row.messagesPath ?? "", }, + ...(pluginIssues?.length ? { pluginIssues } : {}), }; } @@ -544,6 +549,7 @@ export class HubSessionClient { apiKey: request.apiKey, }, toolPolicies: request.toolPolicies, + ...(request.plugins ? { plugins: request.plugins } : {}), } : {}), }, diff --git a/sdk/packages/core/src/hub/daemon/entry.test.ts b/sdk/packages/core/src/hub/daemon/entry.test.ts index 1fa242514c..e3c84eadac 100644 --- a/sdk/packages/core/src/hub/daemon/entry.test.ts +++ b/sdk/packages/core/src/hub/daemon/entry.test.ts @@ -119,6 +119,33 @@ vi.mock("./bind-diagnostics", () => ({ describeAddressInUse: mockDescribeAddressInUse, })); +const { mockAttributeUncaughtError, mockPluginPreload } = vi.hoisted(() => ({ + mockAttributeUncaughtError: vi.fn( + (_error: unknown, _label?: string): unknown => undefined, + ), + mockPluginPreload: vi.fn(async () => []), +})); + +vi.mock("../../extensions/plugin/plugin-config-loader", () => ({ + resolvePluginExecutionMode: () => + process.env.CLINE_PLUGIN_MODE === "sandbox" || + process.env.CLINE_PLUGIN_MODE === "in_process" + ? process.env.CLINE_PLUGIN_MODE + : "worker", + resolveAgentPluginPathsWithDiagnostics: () => ({ + paths: [], + disabledPaths: [], + discoveryFailures: [], + }), +})); + +vi.mock("../../extensions/plugin/plugin-registry", () => ({ + getProcessPluginRegistry: () => ({ + attributeUncaughtError: mockAttributeUncaughtError, + preload: mockPluginPreload, + }), +})); + const originalArgv = [...process.argv]; const originalCwd = process.cwd(); @@ -345,6 +372,111 @@ describe("hub daemon entry", () => { }); }); + it("keeps running when an uncaught error is attributed to a plugin", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-")); + tempDirs.push(cwd); + process.argv = ["node", "entry.js", "--cwd", cwd]; + const handlers = new Map void>(); + vi.spyOn(process, "on").mockImplementation((( + event: string, + handler: (reason: unknown) => void, + ) => { + handlers.set(event, handler); + return process; + }) as never); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + const pluginError = new Error("plugin went wrong"); + mockAttributeUncaughtError.mockImplementation((error) => + error === pluginError + ? { + name: "bad-plugin", + pluginPath: "/plugins/bad-plugin.ts", + state: "failed", + } + : undefined, + ); + + const { hubDaemonReady } = await import("./entry"); + await hubDaemonReady; + expect(mockPluginPreload).toHaveBeenCalled(); + + handlers.get("unhandledRejection")?.(pluginError); + handlers.get("uncaughtException")?.(pluginError); + expect(mockAttributeUncaughtError).toHaveBeenCalledWith( + pluginError, + "unhandledRejection", + ); + expect(mockAttributeUncaughtError).toHaveBeenCalledWith( + pluginError, + "uncaughtException", + ); + expect(exitSpy).not.toHaveBeenCalled(); + expect( + stderr.mock.calls.some(([line]) => + String(line).includes('from plugin "bad-plugin"'), + ), + ).toBe(true); + + // Errors that do not come from a plugin stay fatal. + handlers.get("uncaughtException")?.(new Error("hub bug")); + await vi.waitFor(() => { + expect(exitSpy).toHaveBeenCalledWith(1); + }); + mockAttributeUncaughtError.mockReset(); + mockAttributeUncaughtError.mockImplementation(() => undefined); + }); + + it("does not preload plugins in sandbox mode", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-")); + tempDirs.push(cwd); + process.argv = ["node", "entry.js", "--cwd", cwd]; + vi.spyOn(process, "on").mockImplementation((() => process) as never); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + mockPluginPreload.mockClear(); + const previous = process.env.CLINE_PLUGIN_MODE; + process.env.CLINE_PLUGIN_MODE = "sandbox"; + try { + const { hubDaemonReady } = await import("./entry"); + await hubDaemonReady; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockPluginPreload).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) delete process.env.CLINE_PLUGIN_MODE; + else process.env.CLINE_PLUGIN_MODE = previous; + } + }); + + it.each([ + "worker", + "in_process", + ])("preloads plugin status in %s mode", async (mode) => { + const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-")); + tempDirs.push(cwd); + process.argv = ["node", "entry.js", "--cwd", cwd]; + vi.spyOn(process, "on").mockImplementation((() => process) as never); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + mockPluginPreload.mockClear(); + const previous = process.env.CLINE_PLUGIN_MODE; + process.env.CLINE_PLUGIN_MODE = mode; + try { + const { hubDaemonReady } = await import("./entry"); + await hubDaemonReady; + await new Promise((resolve) => setTimeout(resolve, 10)); + // Worker mode preloads in throwaway workers, never in the hub. + expect(mockPluginPreload).toHaveBeenCalledWith( + expect.objectContaining({ isolation: mode }), + ); + } finally { + if (previous === undefined) delete process.env.CLINE_PLUGIN_MODE; + else process.env.CLINE_PLUGIN_MODE = previous; + } + }); + it("routes HTTP and signal shutdown through one cleanup", async () => { const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-")); tempDirs.push(cwd); diff --git a/sdk/packages/core/src/hub/daemon/entry.ts b/sdk/packages/core/src/hub/daemon/entry.ts index fc39d24707..9000645423 100644 --- a/sdk/packages/core/src/hub/daemon/entry.ts +++ b/sdk/packages/core/src/hub/daemon/entry.ts @@ -5,6 +5,14 @@ import { initVcr, resolveClineBuildEnv, } from "@cline/shared"; +import { + resolveAgentPluginPathsWithDiagnostics, + resolvePluginExecutionMode, +} from "../../extensions/plugin/plugin-config-loader"; +import { + getProcessPluginRegistry, + type PluginRegistry, +} from "../../extensions/plugin/plugin-registry"; import { cleanupConnectorInstanceViaCli } from "../../services/connectors/connector-cleanup"; import { ConnectorSupervisor, @@ -155,6 +163,29 @@ export function isAbortRejection(reason: unknown): boolean { return false; } +/** + * Plugins run inside the daemon, so a stray exception or rejection from a + * plugin must not shut every session down. When the error's stack points into + * a loaded plugin, mark that plugin `failed` and keep running. Returns false + * for errors that are not attributable to a plugin. + */ +export function attributeUncaughtErrorToPlugin( + label: "uncaughtException" | "unhandledRejection", + error: unknown, + registry: PluginRegistry = getProcessPluginRegistry(), + write: (line: string) => void = (line) => process.stderr.write(line), +): boolean { + const status = registry.attributeUncaughtError(error, label); + if (!status) { + return false; + } + const message = error instanceof Error ? error.message : String(error); + write( + `[hub-daemon] ${label} from plugin "${status.name}" (${status.pluginPath}); marked it failed and kept running: ${message}\n`, + ); + return true; +} + async function main(): Promise { ensureLoopbackProxyBypass(); const options = parseArgs(process.argv.slice(2)); @@ -238,6 +269,9 @@ async function main(): Promise { process.on("SIGINT", () => handleTerminationSignal("SIGINT")); process.on("SIGTERM", () => handleTerminationSignal("SIGTERM")); process.on("uncaughtException", (error) => { + if (attributeUncaughtErrorToPlugin("uncaughtException", error)) { + return; + } shutdownFatal("uncaughtException", error); }); process.on("unhandledRejection", (reason) => { @@ -248,6 +282,9 @@ async function main(): Promise { ); return; } + if (attributeUncaughtErrorToPlugin("unhandledRejection", reason)) { + return; + } shutdownFatal("unhandledRejection", reason); }); @@ -377,6 +414,9 @@ async function main(): Promise { } resolveHubDaemonReady(); + // Discover and import global plugins once up front so `plugins.status` + // reports them before any session starts. Sessions reuse these modules. + void preloadDaemonPlugins(options.cwd); try { // Adopt first: connectors that outlived the previous hub have to be known // before recovery runs, so they are restarted onto this hub's session @@ -397,6 +437,38 @@ async function main(): Promise { }); } +async function preloadDaemonPlugins(cwd: string): Promise { + // In sandbox mode plugins must never run inside the Hub process; each + // session starts its own sandbox instead. Worker mode preloads in + // throwaway workers, so the Hub still never imports plugin code. + const mode = resolvePluginExecutionMode(); + if (mode === "sandbox") { + return; + } + try { + const resolved = resolveAgentPluginPathsWithDiagnostics({ + workspacePath: cwd, + cwd, + }); + const statuses = await getProcessPluginRegistry().preload({ + isolation: mode, + pluginPaths: resolved.paths, + disabledPluginPaths: resolved.disabledPaths, + discoveryFailures: resolved.discoveryFailures, + }); + const failed = statuses.filter((status) => status.state === "failed"); + for (const status of failed) { + process.stderr.write( + `[hub-daemon] plugin "${status.name}" failed (${status.lastError?.phase}): ${status.lastError?.message}\n`, + ); + } + } catch (error) { + const message = + error instanceof Error ? error.stack || error.message : String(error); + process.stderr.write(`[hub-daemon] plugin preload failed: ${message}\n`); + } +} + void main().catch((error) => { rejectHubDaemonReady(error); if (isHubLockHeldError(error)) { diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts index a76dd6a26a..4480578e44 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts @@ -10,6 +10,7 @@ import type { SessionRecord as HubSessionRecord, ITelemetryService, JsonValue, + SessionPluginIssue, ToolApprovalRequest, } from "@cline/shared"; import { @@ -900,6 +901,11 @@ export class HubRuntimeHost implements RuntimeHost { toolPolicies: toJsonRecord( input.toolPolicies as Record | undefined, ), + ...(input.plugins + ? { + plugins: toJsonRecord(input.plugins as Record), + } + : {}), initialMessages: input.initialMessages, ...(input.initialCompactionState ? { initialCompactionState: input.initialCompactionState } @@ -956,12 +962,16 @@ export class HubRuntimeHost implements RuntimeHost { ); } + const pluginIssues = Array.isArray(reply.payload?.pluginIssues) + ? (reply.payload.pluginIssues as SessionPluginIssue[]) + : undefined; return { sessionId, manifest, manifestPath: "", messagesPath: "", result: undefined, + ...(pluginIssues?.length ? { pluginIssues } : {}), }; } @@ -1049,6 +1059,13 @@ export class HubRuntimeHost implements RuntimeHost { | Record | undefined, ), + ...(startConfig.plugins + ? { + plugins: toJsonRecord( + startConfig.plugins as Record, + ), + } + : {}), ...(startConfig.initialCompactionState ? { initialCompactionState: diff --git a/sdk/packages/core/src/hub/server/handlers/session-handlers.ts b/sdk/packages/core/src/hub/server/handlers/session-handlers.ts index 03a312d7d4..3d7898c123 100644 --- a/sdk/packages/core/src/hub/server/handlers/session-handlers.ts +++ b/sdk/packages/core/src/hub/server/handlers/session-handlers.ts @@ -4,6 +4,7 @@ import type { HubCommandInput, HubReplyEnvelope, JsonValue, + PluginPolicies, ToolApprovalRequest, UserContext, } from "@cline/shared"; @@ -262,6 +263,20 @@ function authorizeSessionCompactionAccess(input: { return undefined; } +/** Accepts `{ "*"?: { enabled?: boolean }, [plugin]: { enabled?: boolean } }`. */ +export function parsePluginPolicies( + value: unknown, +): PluginPolicies | undefined { + const record = asPlainRecord(value); + if (!record) return undefined; + const policies: PluginPolicies = {}; + for (const [name, policy] of Object.entries(record)) { + const enabled = asPlainRecord(policy)?.enabled; + policies[name] = typeof enabled === "boolean" ? { enabled } : {}; + } + return Object.keys(policies).length > 0 ? policies : undefined; +} + export async function handleSessionCreate( ctx: HubTransportContext, envelope: HubCommandEnvelope, @@ -492,7 +507,22 @@ export async function handleSessionCreate( : runtimeOptions.autoApproveTools === true ? { "*": { autoApprove: true } } : undefined, + plugins: parsePluginPolicies(payload.plugins), }); + const pluginIssues = started.pluginIssues ?? []; + if (pluginIssues.length > 0) { + logHubMessage("warn", "session.create.plugin_issues", { + ...baseLogContext, + sessionId: started.sessionId, + plugins: pluginIssues.map((issue) => ({ + name: issue.name, + state: issue.state, + reason: issue.reason, + phase: issue.lastError?.phase, + message: issue.lastError?.message, + })), + }); + } logHubMessage("info", "session.create.start_session.end", { ...baseLogContext, sessionId: started.sessionId, @@ -517,11 +547,19 @@ export async function handleSessionCreate( hasSnapshot: !!snapshot, elapsedMs: Math.round(performance.now() - startedAt), }); + const pluginIssuePayload = + pluginIssues.length > 0 + ? { pluginIssues: JSON.parse(JSON.stringify(pluginIssues)) } + : {}; if (session) { ctx.publish( ctx.buildEvent( "session.created", - { session, ...(snapshot ? { snapshot } : {}) }, + { + session, + ...(snapshot ? { snapshot } : {}), + ...pluginIssuePayload, + }, started.sessionId, ), ); @@ -531,7 +569,11 @@ export async function handleSessionCreate( sessionId: started.sessionId, elapsedMs: Math.round(performance.now() - startedAt), }); - return okReply(envelope, { session, ...(snapshot ? { snapshot } : {}) }); + return okReply(envelope, { + session, + ...(snapshot ? { snapshot } : {}), + ...pluginIssuePayload, + }); } export async function handleSessionRestore( @@ -783,6 +825,7 @@ export async function handleSessionRestore( : runtimeOptions.autoApproveTools === true ? { "*": { autoApprove: true } } : undefined, + plugins: parsePluginPolicies(payload.plugins), }; }, startSession: (startInput) => ctx.sessionHost.startSession(startInput), diff --git a/sdk/packages/core/src/hub/server/hub-plugins.test.ts b/sdk/packages/core/src/hub/server/hub-plugins.test.ts new file mode 100644 index 0000000000..926e70d7eb --- /dev/null +++ b/sdk/packages/core/src/hub/server/hub-plugins.test.ts @@ -0,0 +1,200 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + HubEventEnvelope, + PluginStatusRecord, + SessionPluginIssue, +} from "@cline/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + PluginRegistry, + resetProcessPluginRegistryForTests, +} from "../../extensions/plugin/plugin-registry"; +import type { + StartSessionInput, + StartSessionResult, +} from "../../runtime/host/runtime-host"; +import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers"; +import { HubServerTransport } from "../server"; + +describe("Hub plugin status", () => { + let root: string; + let registry: PluginRegistry; + + function createTransport(startSession = vi.fn()) { + return new HubServerTransport({ + runtimeHandlers: createLocalHubScheduleRuntimeHandlers(), + scheduleOptions: { dbPath: ":memory:" }, + sessionHost: { + subscribe: vi.fn(), + startSession, + stopSession: vi.fn(), + runTurn: vi.fn(), + abort: vi.fn(), + dispose: vi.fn(), + getSession: vi.fn().mockImplementation(async (sessionId: string) => ({ + sessionId, + source: "core", + status: "running", + startedAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + interactive: true, + provider: "cline", + model: "test-model", + cwd: root, + workspaceRoot: root, + enableTools: true, + enableSpawn: true, + enableTeams: false, + isSubagent: false, + })), + getAccumulatedUsage: vi.fn().mockResolvedValue(undefined), + listSessions: vi.fn(), + deleteSession: vi.fn(), + updateSession: vi.fn(), + dispatchHookEvent: vi.fn(), + readSessionMessages: vi.fn(), + } as never, + }); + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "cline-hub-plugins-")); + registry = new PluginRegistry(); + resetProcessPluginRegistryForTests(registry); + }); + + afterEach(async () => { + resetProcessPluginRegistryForTests(undefined); + await rm(root, { recursive: true, force: true }); + }); + + it("reports plugin status, broadcasts changes, and reloads a fixed plugin", async () => { + const transport = createTransport(); + const events: HubEventEnvelope[] = []; + transport.subscribe("ui", (event) => { + if (event.event === "plugin.status_changed") events.push(event); + }); + const pluginPath = join(root, "broken.js"); + await writeFile( + pluginPath, + `throw new Error("cannot import");\nexport default {};\n`, + ); + try { + await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [pluginPath], + }); + + const status = await transport.handleCommand({ + version: "v1", + command: "plugins.status", + clientId: "client-1", + }); + expect(status.ok).toBe(true); + const [plugin] = status.payload?.plugins as PluginStatusRecord[]; + expect(plugin).toMatchObject({ + name: "broken", + pluginPath, + state: "failed", + lastError: { phase: "import", message: "cannot import" }, + }); + expect( + events.map( + (event) => (event.payload?.plugin as PluginStatusRecord).state, + ), + ).toEqual(["loading", "failed"]); + + const missing = await transport.handleCommand({ + version: "v1", + command: "plugins.reload", + payload: { plugin: "nope" }, + }); + expect(missing.error?.code).toBe("plugin_not_found"); + + await writeFile( + pluginPath, + `export default { name: "broken", manifest: { capabilities: ["tools"] } };\n`, + ); + const reloaded = await transport.handleCommand({ + version: "v1", + command: "plugins.reload", + payload: { plugin: "broken" }, + }); + expect(reloaded.ok).toBe(true); + expect( + (reloaded.payload?.plugins as PluginStatusRecord[])[0]?.state, + ).toBe("ready"); + expect(events.at(-1)?.payload?.plugin).toMatchObject({ state: "ready" }); + } finally { + await transport.stop(); + } + }); + + it("passes the session plugin policy through and returns plugin issues", async () => { + let captured: StartSessionInput | undefined; + const issue: SessionPluginIssue = { + name: "broken", + pluginPath: join(root, "broken.js"), + state: "failed", + reason: "error", + lastError: { + phase: "setup", + message: "setup exploded", + pluginPath: join(root, "broken.js"), + timestamp: 1, + }, + }; + const startSession = vi.fn( + async (input: StartSessionInput): Promise => { + captured = input; + return { + sessionId: input.config.sessionId ?? "session-1", + manifest: {} as never, + manifestPath: "", + messagesPath: "", + pluginIssues: [issue], + }; + }, + ); + const transport = createTransport(startSession); + const created: HubEventEnvelope[] = []; + transport.subscribe("ui", (event) => { + if (event.event === "session.created") created.push(event); + }); + try { + const reply = await transport.handleCommand({ + version: "v1", + command: "session.create", + clientId: "client-1", + payload: { + workspaceRoot: root, + cwd: root, + sessionConfig: { + sessionId: "session-plugins", + providerId: "cline", + modelId: "test-model", + systemPrompt: "system", + }, + plugins: { + "*": { enabled: false }, + alpha: { enabled: true }, + ignored: "not a policy", + }, + }, + }); + + expect(reply.ok).toBe(true); + expect(captured?.plugins).toEqual({ + "*": { enabled: false }, + alpha: { enabled: true }, + ignored: {}, + }); + expect(reply.payload?.pluginIssues).toEqual([issue]); + expect(created[0]?.payload?.pluginIssues).toEqual([issue]); + } finally { + await transport.stop(); + } + }); +}); diff --git a/sdk/packages/core/src/hub/server/hub-server-transport.ts b/sdk/packages/core/src/hub/server/hub-server-transport.ts index d2c2d1eb4f..0f85fa73ca 100644 --- a/sdk/packages/core/src/hub/server/hub-server-transport.ts +++ b/sdk/packages/core/src/hub/server/hub-server-transport.ts @@ -20,6 +20,10 @@ import { isChatWorkspacePath } from "@cline/shared/storage"; import { CronService } from "../../cron/service/cron-service"; import { HubScheduleCommandService } from "../../cron/service/schedule-command-service"; import { HubScheduleService } from "../../cron/service/schedule-service"; +import { + getProcessPluginRegistry, + type PluginRegistry, +} from "../../extensions/plugin/plugin-registry"; import { LocalRuntimeHost } from "../../runtime/host/local-runtime-host"; import type { CommandExecutionRuntimeService, @@ -68,6 +72,7 @@ import { import { handleConnectorCommand } from "./handlers/connector-handlers"; import { buildHubEvent, + errorReply, type HubTransportContext, okReply, type PendingApproval, @@ -101,9 +106,9 @@ import { handleSessionRemovePendingPrompt, handleSessionRestore, handleSessionSearch, + handleSessionSteerFirstPendingPrompt, handleSessionUpdate, handleSessionUpdateConnection, - handleSessionSteerFirstPendingPrompt, handleSessionUpdatePendingPrompt, } from "./handlers/session-handlers"; import { HubEventLogStore } from "./hub-event-log"; @@ -232,6 +237,22 @@ function parseSettingsToggleInput(payload: unknown): CoreSettingsToggleInput { }; } +/** Routes plugin registry logs into the Hub's structured log stream. */ +const hubPluginLogger = { + debug: (message: string, metadata?: Record) => + logHubMessage("debug", message, metadata), + log: (message: string, metadata?: Record) => + logHubMessage( + metadata?.severity === "warn" || metadata?.severity === "error" + ? "warn" + : "info", + message, + metadata, + ), + error: (message: string, metadata?: Record) => + logHubMessage("error", message, metadata), +}; + /** @internal Exported for unit testing fetch/runtime wiring. */ export class HubServerTransport implements NativeHubTransport { private readonly clients = new Map(); @@ -270,8 +291,18 @@ export class HubServerTransport implements NativeHubTransport { private runQueue?: HubRunQueue; private runExecutor?: HubRunExecutor; private draining = false; + private readonly plugins: PluginRegistry = getProcessPluginRegistry(); + private unsubscribePluginStatus?: () => void; constructor(readonly options: HubWebSocketServerOptions) { + this.plugins.setLogger(hubPluginLogger); + this.unsubscribePluginStatus = this.plugins.subscribe((status) => { + this.publish( + buildHubEvent("plugin.status_changed", { + plugin: JSON.parse(JSON.stringify(status)), + }), + ); + }); this.sessionHost = options.sessionHost ?? new LocalRuntimeHost({ @@ -710,6 +741,8 @@ export class HubServerTransport implements NativeHubTransport { } async stop(): Promise { + this.unsubscribePluginStatus?.(); + this.unsubscribePluginStatus = undefined; if (this.eventLogPruneTimer) { clearInterval(this.eventLogPruneTimer); this.eventLogPruneTimer = undefined; @@ -890,6 +923,11 @@ export class HubServerTransport implements NativeHubTransport { return this.handleHubDrain(envelope); case "hub.status": return this.handleHubStatus(envelope); + case "plugins.list": + case "plugins.status": + return okReply(envelope, { plugins: this.plugins.list() }); + case "plugins.reload": + return await this.handlePluginsReload(envelope); case "run.abort": return await handleRunAbort(this.ctx, envelope); case "run.proceed_while_running": @@ -1070,6 +1108,31 @@ export class HubServerTransport implements NativeHubTransport { return okReply(envelope, this.describeStatus()); } + private async handlePluginsReload( + envelope: HubCommandEnvelope, + ): Promise { + const plugin = + typeof envelope.payload?.plugin === "string" + ? envelope.payload.plugin.trim() + : ""; + if (!plugin) { + return errorReply( + envelope, + "invalid_plugin", + "plugins.reload requires a plugin name or path", + ); + } + const plugins = await this.plugins.reload(plugin); + if (plugins.length === 0) { + return errorReply( + envelope, + "plugin_not_found", + `No loaded plugin matches "${plugin}"`, + ); + } + return okReply(envelope, { plugins }); + } + private handleHubStatus(envelope: HubCommandEnvelope): HubReplyEnvelope { return okReply(envelope, this.describeStatus()); } diff --git a/sdk/packages/core/src/index.ts b/sdk/packages/core/src/index.ts index df79fd1d60..ef87c5cab4 100644 --- a/sdk/packages/core/src/index.ts +++ b/sdk/packages/core/src/index.ts @@ -241,22 +241,34 @@ export type { LoadAgentPluginPackagesOptions, LoadedAgentPluginPackage, ParsedAgentSkill, + PluginExecutionMode, + PluginHookErrorMode, PluginInitializationFailure, PluginInitializationWarning, PluginLoadDiagnostics, + PluginRegistryOptions, + PluginSessionLoadInput, + PluginSessionLoadResult, + PluginStatusListener, ResolveAgentPluginPathsOptions, } from "./extensions"; export { AGENT_PLUGINS_V1_MANIFEST_SCHEMA, AGENT_PLUGINS_V1_MCP_SCHEMA, + CLINE_PLUGIN_BOOTSTRAP_PATH_ENV, discoverPluginModulePaths, + formatSessionPluginIssue, getPluginDisplayName, + getProcessPluginRegistry, loadAgentPluginFromPath, loadAgentPluginPackages, loadAgentPluginsFromPaths, loadAgentPluginsFromPathsWithDiagnostics, + PluginCallTimeoutError, + PluginRegistry, parseAgentSkillMarkdown, resolveAgentPluginPaths, + resolveAgentPluginPathsWithDiagnostics, resolveAndLoadAgentPlugins, resolvePluginConfigSearchPaths, resolvePluginSkillDirectoriesFromPaths, @@ -535,6 +547,10 @@ export { type DesktopToolApprovalOptions, requestDesktopToolApproval, } from "./runtime/tools/tool-approval"; +export { + WorkerSandbox, + type WorkerSandboxOptions, +} from "./runtime/tools/worker-sandbox"; export * from "./services/cloud-handoff"; export { listActiveConnectors } from "./services/connectors/active-connectors"; export { diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.ts index f3455da00c..a2339b29aa 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.ts @@ -21,6 +21,7 @@ import { createContextCompactionPrepareTurn, createImportedHistoryCompactionPrepareTurn, } from "../../extensions/context/compaction"; +import { formatSessionPluginIssue } from "../../extensions/plugin/plugin-registry"; import type { ToolExecutors } from "../../extensions/tools"; import { DefaultToolNames, @@ -260,6 +261,15 @@ export interface LocalRuntimeHostOptions { fetch?: typeof fetch; } +/** Plugin events that act on a session (prompts) rather than report. */ +function isSessionDirectedPluginEvent(name: string): boolean { + return ( + name === "steer_message" || + name === "queue_message" || + name === "pending_prompt" + ); +} + export class LocalRuntimeHost implements RuntimeHost { public readonly runtimeAddress = undefined; public readonly pendingPrompts: PendingPromptsServiceApi; @@ -554,6 +564,11 @@ export class LocalRuntimeHost implements RuntimeHost { inputLocalConfig?.telemetry ?? this.defaultTelemetry; let bootstrap!: Awaited>; + // Plugin setup runs while the session bootstraps (in worker mode), before + // the session is registered. Prompts it sends to the session would find + // no session and be dropped, so hold them until registration below. + let heldSessionEvents: Array<{ name: string; payload?: unknown }> | null = + []; const subAgentDeps = { getSession: (sid: string) => this.sessions.get(sid), subAgentStarts: this.subAgentStarts, @@ -600,6 +615,10 @@ export class LocalRuntimeHost implements RuntimeHost { ); return; } + if (heldSessionEvents && isSessionDirectedPluginEvent(event.name)) { + heldSessionEvents.push(event); + return; + } void this.eventBridge.handlePluginEvent( sessionId, event, @@ -607,6 +626,18 @@ export class LocalRuntimeHost implements RuntimeHost { pluginEventFallbackTelemetry, ); }, + onPluginIssue: (issue) => { + // Setup runs on the first turn, after the start result went out; + // surface later plugin failures as a status notice instead. + if (!bootstrap) return; + this.eventBridge.dispatchAgentEvent(sessionId, bootstrap.config, { + type: "notice", + noticeType: "status", + displayRole: "status", + message: formatSessionPluginIssue(issue), + metadata: { pluginIssue: issue }, + }); + }, onTeamEvent: (event: TeamEvent) => { void this.eventBridge.handleTeamEvent(sessionId, event); bootstrap.config.onTeamEvent?.(event); @@ -647,11 +678,19 @@ export class LocalRuntimeHost implements RuntimeHost { }, ); if (!resumedArtifacts) manifest.metadata = initialSessionMetadata; - const runtime = await this.runtimeBuilder.build({ - ...bootstrap.runtimeBuilderInput, - distinctId: this.distinctId, - runCommandExecutionController: this.runCommandExecutionController, - }); + let runtime: Awaited>; + try { + runtime = await this.runtimeBuilder.build({ + ...bootstrap.runtimeBuilderInput, + distinctId: this.distinctId, + runCommandExecutionController: this.runCommandExecutionController, + }); + } catch (error) { + // The session never registers, so its stop/dispose paths will not + // run; release the plugin copies the bootstrap loaded for it. + await bootstrap.pluginSandboxShutdown?.().catch(() => undefined); + throw error; + } const configWithProvider = bootstrap.config; const providerConfig = bootstrap.providerConfig; if (runtime.teamRuntime && !configWithProvider.teamName?.trim()) { @@ -961,6 +1000,16 @@ export class LocalRuntimeHost implements RuntimeHost { active.compactionState = undefined; } this.sessions.set(sessionId, active); + const held = heldSessionEvents ?? []; + heldSessionEvents = null; + for (const event of held) { + void this.eventBridge.handlePluginEvent( + sessionId, + event, + pluginEventFallbackAutomation, + pluginEventFallbackTelemetry, + ); + } if (resumedArtifacts) { await this.refreshActiveSessionGitMetadata(active, bootstrap.gitState); } @@ -1050,6 +1099,9 @@ export class LocalRuntimeHost implements RuntimeHost { manifestPath, messagesPath, result, + ...(bootstrap.pluginIssues.length > 0 + ? { pluginIssues: bootstrap.pluginIssues } + : {}), }; } diff --git a/sdk/packages/core/src/runtime/host/runtime-host.ts b/sdk/packages/core/src/runtime/host/runtime-host.ts index 53bc6dafce..0c3c3a13c1 100644 --- a/sdk/packages/core/src/runtime/host/runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/runtime-host.ts @@ -169,6 +169,12 @@ export interface StartSessionInput { localRuntime?: LocalRuntimeStartOptions; capabilities?: RuntimeCapabilities; toolPolicies?: import("@cline/shared").AgentConfig["toolPolicies"]; + /** + * Which plugins this session uses, shaped like `toolPolicies`: + * `{ "*": { enabled: true }, "my-plugin": { enabled: false } }`. Plugins + * are enabled by default. Plugin tools still go through `toolPolicies`. + */ + plugins?: import("@cline/shared").PluginPolicies; } /** Session input after the execution host has resolved a concrete workspace. */ @@ -245,6 +251,11 @@ export interface StartSessionResult { manifestPath: string; messagesPath: string; result?: AgentResult; + /** + * Plugins the session asked for but does not have because they failed or + * are disabled. Clients should show these to the user. + */ + pluginIssues?: import("@cline/shared").SessionPluginIssue[]; } export interface SendSessionInput { diff --git a/sdk/packages/core/src/runtime/index.ts b/sdk/packages/core/src/runtime/index.ts index d31998de79..f5fbb078fd 100644 --- a/sdk/packages/core/src/runtime/index.ts +++ b/sdk/packages/core/src/runtime/index.ts @@ -22,3 +22,7 @@ export { type DesktopToolApprovalOptions, requestDesktopToolApproval, } from "./tools/tool-approval"; +export { + WorkerSandbox, + type WorkerSandboxOptions, +} from "./tools/worker-sandbox"; diff --git a/sdk/packages/core/src/runtime/tools/worker-sandbox.test.ts b/sdk/packages/core/src/runtime/tools/worker-sandbox.test.ts new file mode 100644 index 0000000000..e1f11ed1be --- /dev/null +++ b/sdk/packages/core/src/runtime/tools/worker-sandbox.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { WorkerSandbox } from "./worker-sandbox"; + +// Echoes call args back, and reports ready only after a delay so calls made +// right after start() sit in the host's queue. +const delayedReadyBootstrap = [ + "const { parentPort } = require('node:worker_threads');", + "setTimeout(() => {", + " parentPort.on('message', (m) => {", + " if (!m || m.type !== 'call') return;", + " parentPort.postMessage({ type: 'response', id: m.id, ok: true, result: m.args });", + " });", + " parentPort.postMessage({ type: 'ready' });", + "}, 100);", +].join("\n"); + +const sandboxes: WorkerSandbox[] = []; + +function createSandbox(): WorkerSandbox { + const sandbox = new WorkerSandbox({ + bootstrapScript: delayedReadyBootstrap, + }); + sandboxes.push(sandbox); + return sandbox; +} + +afterEach(async () => { + await Promise.all(sandboxes.splice(0).map((sandbox) => sandbox.shutdown())); +}); + +describe("WorkerSandbox", () => { + it("sends calls made before ready once the worker listens", async () => { + const sandbox = createSandbox(); + await expect( + Promise.all([ + sandbox.call("echo", { n: 1 }), + sandbox.call("echo", { n: 2 }), + ]), + ).resolves.toEqual([{ n: 1 }, { n: 2 }]); + }); + + it("sends queued args as they were at call time", async () => { + const sandbox = createSandbox(); + const args: Record = { value: "original" }; + const result = sandbox.call("echo", args); + args.value = "changed"; + // Uncloneable once queued: must not throw when the queue flushes. + args.fn = () => 1; + await expect(result).resolves.toEqual({ value: "original" }); + }); + + it("rejects an uncloneable call before it is queued", async () => { + const sandbox = createSandbox(); + await expect(sandbox.call("echo", { fn: () => 1 })).rejects.toThrow( + /clone/i, + ); + await expect(sandbox.call("echo", { ok: true })).resolves.toEqual({ + ok: true, + }); + }); + + it("does not send a queued call that timed out before ready", async () => { + const sandbox = createSandbox(); + await expect( + sandbox.call("echo", { late: true }, { timeoutMs: 20 }), + ).rejects.toThrow(/timed out/); + // The timeout terminated that worker; a new call starts a fresh one. + await expect(sandbox.call("echo", { n: 3 })).resolves.toEqual({ n: 3 }); + }); + + it("runs the dispose method before an idle stop, and later calls get a fresh worker", async () => { + const sandbox = new WorkerSandbox({ + bootstrapScript: [ + "const { parentPort, threadId } = require('node:worker_threads');", + "parentPort.on('message', (m) => {", + " if (!m || m.type !== 'call') return;", + " if (m.method === 'dispose') parentPort.postMessage({ type: 'event', name: 'disposed', payload: threadId });", + " parentPort.postMessage({ type: 'response', id: m.id, ok: true, result: threadId });", + "});", + "parentPort.postMessage({ type: 'ready' });", + ].join("\n"), + idleTimeoutMs: 100, + disposeMethod: "dispose", + disposeTimeoutMs: 1_000, + onEvent: (event) => disposed.push(event.payload as number), + }); + const disposed: number[] = []; + sandboxes.push(sandbox); + const first = await sandbox.call("echo", {}); + await expect.poll(() => disposed).toEqual([first]); + const second = await sandbox.call("echo", {}); + expect(second).not.toBe(first); + await sandbox.shutdown(); + expect(disposed).toEqual([first, second]); + }); + + it("does not report an exit during cleanup as a crash", async () => { + const unexpected: Error[] = []; + const sandbox = new WorkerSandbox({ + bootstrapScript: [ + "const { parentPort } = require('node:worker_threads');", + "parentPort.on('message', (m) => {", + " if (!m || m.type !== 'call') return;", + " if (m.method === 'dispose') process.exit(3);", + " parentPort.postMessage({ type: 'response', id: m.id, ok: true, result: 'ok' });", + "});", + "parentPort.postMessage({ type: 'ready' });", + ].join("\n"), + disposeMethod: "dispose", + disposeTimeoutMs: 1_000, + onUnexpectedExit: (error) => unexpected.push(error), + }); + sandboxes.push(sandbox); + await expect(sandbox.call("echo", {})).resolves.toBe("ok"); + await sandbox.shutdown(); + expect(unexpected).toEqual([]); + }); +}); diff --git a/sdk/packages/core/src/runtime/tools/worker-sandbox.ts b/sdk/packages/core/src/runtime/tools/worker-sandbox.ts new file mode 100644 index 0000000000..6798367787 --- /dev/null +++ b/sdk/packages/core/src/runtime/tools/worker-sandbox.ts @@ -0,0 +1,483 @@ +import { Worker } from "node:worker_threads"; +import { withResolvedClineBuildEnv } from "@cline/shared"; +import type { SandboxCallOptions } from "./subprocess-sandbox"; +import { + MAX_NODE_TIMER_DELAY_MS, + normalizeIdleTimeoutMs, +} from "./subprocess-sandbox-lifecycle"; + +interface SandboxResponseMessage { + type: "response"; + id: string; + ok: boolean; + result?: unknown; + error?: { message: string; stack?: string }; +} + +interface SandboxEventMessage { + type: "event"; + name: string; + payload?: unknown; +} + +/** Sent by the bootstrap once it listens for calls. */ +interface SandboxReadyMessage { + type: "ready"; +} + +export interface WorkerSandboxOptions { + /** Inline CommonJS script to run as the worker (`eval: true`). Mutually exclusive with {@link bootstrapFile}. */ + bootstrapScript?: string; + /** Path to a JavaScript file to run as the worker. Mutually exclusive with {@link bootstrapScript}. */ + bootstrapFile?: string; + /** + * Terminate the worker after this many milliseconds with no calls in + * flight. The next call starts a fresh worker transparently. Disabled when + * omitted. + */ + idleTimeoutMs?: number; + name?: string; + onEvent?: (event: { name: string; payload?: unknown }) => void; + /** + * Called when a worker ends on its own (a crash, an uncaught error, + * `process.exit()`), not when this sandbox terminates it for a timeout, + * idle reclaim, or shutdown. + */ + onUnexpectedExit?: (error: Error) => void; + /** + * A bootstrap method run before a graceful stop (idle reclaim or + * `shutdown()`), so code in the worker can clean up what terminating it + * would not, such as child processes it spawned. Not run when a call + * times out: the worker may be stuck. + */ + disposeMethod?: string; + disposeTimeoutMs?: number; +} + +type QueuedCall = { id: string; message: SandboxCallMessage }; + +interface SandboxCallMessage { + type: "call"; + id: string; + method: string; + args: unknown; +} + +type PendingRequest = { + worker: Worker; + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout?: NodeJS.Timeout; +}; + +function asError(value: unknown): Error { + if (value instanceof Error) { + return value; + } + return new Error(String(value)); +} + +/** + * Runs a bootstrap in a worker thread and speaks the same call/response/event + * protocol as {@link SubprocessSandbox}. Unlike the subprocess it needs no + * `node`/`bun` executable on PATH: the worker runs inside the host runtime, + * so it also works from a compiled single-file binary. + * + * A worker has its own module graph, globals, and timers, and `terminate()` + * stops it even mid-loop, so a hung or leaking plugin is reclaimed without + * touching the host. It shares the host's process (memory limit, cwd, env + * snapshot, native addons), so it is isolation, not a security boundary. + */ +export class WorkerSandbox { + private readonly options: WorkerSandboxOptions; + private readonly idleTimeoutMs: number | undefined; + private worker: Worker | null = null; + private requestCounter = 0; + private readonly pending = new Map(); + private idleTimer: NodeJS.Timeout | undefined; + private readonly exited = new WeakSet(); + private readonly shutdowns = new Map>(); + /** + * Calls posted before the worker reports ready. Bun drops messages that + * reach a worker before its bootstrap attaches a listener (Node queues + * them), and the bootstrap may still be importing when the first call is + * made. + */ + private readonly outboxes = new WeakMap(); + /** + * Set while an idle worker runs its dispose method and stops. Calls made + * meanwhile wait and start a fresh worker, so none reaches a plugin that + * already cleaned up. + */ + private draining: Promise | undefined; + /** + * Workers this sandbox decided to stop, from the moment cleanup starts. + * An exit while cleanup runs (a disposer calling `process.exit()` or + * throwing from a timer) is part of that stop, not a crash. + */ + private readonly stopping = new WeakSet(); + + constructor(options: WorkerSandboxOptions) { + this.options = options; + this.idleTimeoutMs = normalizeIdleTimeoutMs(options.idleTimeoutMs); + if ( + options.idleTimeoutMs !== undefined && + this.idleTimeoutMs === undefined + ) { + throw new RangeError( + `idleTimeoutMs must be an integer between 1 and ${MAX_NODE_TIMER_DELAY_MS}`, + ); + } + } + + private get label(): string { + return this.options.name ?? "worker-sandbox"; + } + + private isAvailable(worker: Worker): boolean { + return !this.exited.has(worker) && !this.shutdowns.has(worker); + } + + private clearIdleTimer(): void { + if (!this.idleTimer) { + return; + } + clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } + + private hasPendingRequests(worker: Worker): boolean { + for (const pending of this.pending.values()) { + if (pending.worker === worker) { + return true; + } + } + return false; + } + + private armIdleTimer(worker: Worker): void { + if (this.worker !== worker) { + return; + } + this.clearIdleTimer(); + if ( + this.idleTimeoutMs === undefined || + !this.isAvailable(worker) || + this.hasPendingRequests(worker) + ) { + return; + } + const timer = setTimeout(() => { + if (this.idleTimer === timer) { + this.idleTimer = undefined; + } + if ( + this.worker !== worker || + !this.isAvailable(worker) || + this.hasPendingRequests(worker) + ) { + return; + } + this.draining = this.stopGracefully(worker).finally(() => { + this.draining = undefined; + }); + }, this.idleTimeoutMs); + timer.unref(); + this.idleTimer = timer; + } + + private clearPendingRequest(id: string): PendingRequest | undefined { + const pending = this.pending.get(id); + if (!pending) { + return undefined; + } + this.pending.delete(id); + if (pending.timeout) { + clearTimeout(pending.timeout); + } + this.armIdleTimer(pending.worker); + return pending; + } + + start(): void { + if (this.worker && this.isAvailable(this.worker)) { + return; + } + this.worker = null; + this.clearIdleTimer(); + + const worker = this.options.bootstrapFile + ? new Worker(this.options.bootstrapFile, this.workerOptions(false)) + : new Worker( + this.options.bootstrapScript ?? "", + this.workerOptions(true), + ); + this.worker = worker; + this.outboxes.set(worker, []); + + let stderrBuffer = ""; + // Plugin stdout must not reach the host's stdout, which may carry a + // protocol (matches the subprocess sandbox, which ignores it). + worker.stdout?.resume(); + worker.stderr?.setEncoding("utf8"); + worker.stderr?.on("data", (chunk: string) => { + const next = stderrBuffer + chunk; + stderrBuffer = next.length > 4000 ? next.slice(-4000) : next; + }); + worker.on("message", (message) => { + this.onMessage( + worker, + message as + | SandboxResponseMessage + | SandboxEventMessage + | SandboxReadyMessage, + ); + }); + let workerError: Error | undefined; + worker.on("error", (error) => { + // An uncaught exception in the worker ends the worker, not the host. + workerError = asError(error); + }); + worker.on("exit", (code) => { + this.exited.add(worker); + if (this.worker === worker) { + this.worker = null; + this.clearIdleTimer(); + } + const detail = [workerError?.message, stderrBuffer.trim()] + .filter(Boolean) + .join(": "); + const error = new Error( + `${this.label} exited (code=${String(code)})${detail ? `: ${detail}` : ""}`, + ); + if (workerError?.stack) error.stack = workerError.stack; + const intended = this.shutdowns.has(worker) || this.stopping.has(worker); + this.failPendingForWorker(worker, error); + if (!intended) { + try { + this.options.onUnexpectedExit?.(error); + } catch { + // Reporting must never turn into a host failure. + } + } + }); + this.armIdleTimer(worker); + } + + private workerOptions( + evalScript: boolean, + ): ConstructorParameters[1] { + return { + eval: evalScript, + stdout: true, + stderr: true, + env: withResolvedClineBuildEnv(process.env), + }; + } + + async call( + method: string, + args: unknown, + options: SandboxCallOptions = {}, + ): Promise { + if (this.draining) await this.draining; + this.start(); + const worker = this.worker; + if (!worker || !this.isAvailable(worker)) { + throw new Error(`${this.label} is not available`); + } + return await this.send(worker, method, args, options); + } + + /** Runs the dispose method (best effort), then terminates the worker. */ + private async stopGracefully(worker: Worker): Promise { + this.stopping.add(worker); + const method = this.options.disposeMethod; + if (method && this.isAvailable(worker)) { + await this.send( + worker, + method, + {}, + { + timeoutMs: this.options.disposeTimeoutMs, + }, + ).catch(() => { + // Cleanup is best effort; the worker is stopped either way. + }); + } + await this.shutdownWorker(worker); + } + + private async send( + worker: Worker, + method: string, + args: unknown, + options: SandboxCallOptions, + ): Promise { + this.clearIdleTimer(); + const id = `req_${++this.requestCounter}`; + return await new Promise((resolve, reject) => { + const pending: PendingRequest = { + worker, + resolve: (value) => resolve(value as TResult), + reject, + }; + if ((options.timeoutMs ?? 0) > 0) { + pending.timeout = setTimeout(() => { + const entry = this.clearPendingRequest(id); + if (!entry) { + return; + } + // terminate() stops the worker even inside a synchronous loop. + this.shutdownWorker(entry.worker).catch(() => { + // Best-effort shutdown after timeout. + }); + entry.reject( + new Error( + `${this.label} call timed out after ${options.timeoutMs}ms: ${method}`, + ), + ); + }, options.timeoutMs); + } + this.pending.set(id, pending); + try { + const message: SandboxCallMessage = { type: "call", id, method, args }; + const outbox = this.outboxes.get(worker); + if (outbox) { + // Queue a snapshot, as postMessage would send: the caller may + // change `args` before the worker is ready, and cloning now + // surfaces DataCloneError here instead of at flush time. + outbox.push({ id, message: structuredClone(message) }); + } else { + worker.postMessage(message); + } + } catch (error) { + // postMessage throws synchronously (DataCloneError) when the + // payload holds functions or other uncloneable values. Reject with + // the original error so callers can retry with a cleaned payload. + this.clearPendingRequest(id); + reject(asError(error)); + } + }); + } + + async shutdown(): Promise { + this.clearIdleTimer(); + if (this.draining) await this.draining; + const worker = this.worker; + const inFlight = [...this.shutdowns.values()]; + if (worker) { + inFlight.push(this.stopGracefully(worker)); + } + if (inFlight.length === 0) { + this.failPending(new Error(`${this.label} shutdown`)); + return; + } + await Promise.all(inFlight); + } + + private shutdownWorker(worker: Worker): Promise { + const existing = this.shutdowns.get(worker); + if (existing) { + return existing; + } + if (this.worker === worker) { + this.worker = null; + this.clearIdleTimer(); + } + const shutdown = worker + .terminate() + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + this.exited.add(worker); + this.failPendingForWorker(worker, new Error(`${this.label} shutdown`)); + if (this.shutdowns.get(worker) === shutdown) { + this.shutdowns.delete(worker); + } + }); + this.shutdowns.set(worker, shutdown); + return shutdown; + } + + private onMessage( + worker: Worker, + message: SandboxResponseMessage | SandboxEventMessage | SandboxReadyMessage, + ): void { + if (!message) { + return; + } + if (message.type === "ready") { + const outbox = this.outboxes.get(worker); + this.outboxes.delete(worker); + for (const queued of outbox ?? []) { + // A queued call whose request already timed out is dropped. + if (!this.pending.has(queued.id)) continue; + try { + worker.postMessage(queued.message); + } catch (error) { + // Snapshots are clones, so this should not happen; if it does, + // reject that call rather than throw from the message listener. + this.clearPendingRequest(queued.id)?.reject(asError(error)); + } + } + return; + } + if (message.type === "event") { + if ( + this.worker === worker && + typeof message.name === "string" && + message.name.length > 0 + ) { + this.options.onEvent?.({ + name: message.name, + payload: message.payload, + }); + } + return; + } + if (message.type !== "response" || !message.id) { + return; + } + const matching = this.pending.get(message.id); + if (!matching || matching.worker !== worker) { + return; + } + const pending = this.clearPendingRequest(message.id); + if (!pending) { + return; + } + if (message.ok) { + pending.resolve(message.result); + return; + } + pending.reject( + new Error(message.error?.message || `${this.label} call failed`), + ); + } + + private failPendingForWorker(worker: Worker, error: Error): void { + for (const [id, pending] of this.pending.entries()) { + if (pending.worker !== worker) { + continue; + } + this.pending.delete(id); + if (pending.timeout) { + clearTimeout(pending.timeout); + } + pending.reject(error); + } + } + + private failPending(error: Error): void { + for (const [id, pending] of this.pending.entries()) { + this.pending.delete(id); + if (pending.timeout) { + clearTimeout(pending.timeout); + } + pending.reject(error); + } + } +} diff --git a/sdk/packages/core/src/services/local-runtime-bootstrap.test.ts b/sdk/packages/core/src/services/local-runtime-bootstrap.test.ts index ee8730f9f4..e077e74de1 100644 --- a/sdk/packages/core/src/services/local-runtime-bootstrap.test.ts +++ b/sdk/packages/core/src/services/local-runtime-bootstrap.test.ts @@ -524,6 +524,189 @@ describe("prepareLocalRuntimeBootstrap", () => { expect(registeredTools).toEqual(["compatible_tool"]); }); + it("gives two sessions only the plugins their policies enable and reports failures", async () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "core-plugin-policy-bootstrap-")), + ); + setHomeDir(join(root, "home")); + process.env.CLINE_GLOBAL_SETTINGS_PATH = join(root, "global-settings.json"); + const { PluginRegistry, resetProcessPluginRegistryForTests } = await import( + "../extensions/plugin/plugin-registry" + ); + resetProcessPluginRegistryForTests(new PluginRegistry()); + const writePlugin = (name: string, body: string) => { + const path = join(root, `${name}.js`); + writeFileSync(path, body, "utf8"); + return path; + }; + const toolPlugin = (name: string) => + writePlugin( + name, + `export default { + name: "${name}", + manifest: { capabilities: ["tools"] }, + setup(api) { + api.registerTool({ name: "${name}_tool", description: "", inputSchema: {}, execute: () => "ok" }); + }, +};`, + ); + const pluginPaths = [ + toolPlugin("alpha"), + toolPlugin("beta"), + writePlugin("broken", `throw new Error("broken on import");`), + ]; + const { prepareLocalRuntimeBootstrap } = await import( + "./local-runtime-bootstrap" + ); + const start = (sessionId: string, plugins: Record) => + prepareLocalRuntimeBootstrap({ + input: { ...createStartInput(), plugins } as never, + localRuntime: { pluginPaths } as never, + sessionId, + providerSettingsManager: createProviderSettingsManager() as never, + onPluginEvent: () => {}, + onTeamEvent: () => {}, + createSpawnTool, + readSessionMetadata: async () => undefined, + writeSessionMetadata: async () => {}, + }); + const pluginNames = ( + bootstrap: Awaited>, + ) => + (bootstrap.extensions ?? []) + .map((extension) => extension.name) + .filter((name) => ["alpha", "beta", "broken"].includes(name)); + + try { + const first = await start("sess-a", { beta: { enabled: false } }); + const second = await start("sess-b", { + "*": { enabled: false }, + beta: { enabled: true }, + }); + + expect(pluginNames(first)).toEqual(["alpha"]); + expect(pluginNames(second)).toEqual(["beta"]); + expect(first.pluginIssues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "broken", + state: "failed", + lastError: expect.objectContaining({ + phase: "import", + message: expect.stringContaining("broken on import"), + }), + }), + expect.objectContaining({ + name: "beta", + state: "disabled", + reason: "session_policy", + }), + ]), + ); + } finally { + resetProcessPluginRegistryForTests(undefined); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("gives new or reloaded plugin tools only to sessions started afterwards", async () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "core-plugin-new-sessions-")), + ); + setHomeDir(join(root, "home")); + process.env.CLINE_GLOBAL_SETTINGS_PATH = join(root, "global-settings.json"); + const workspace = join(root, "workspace"); + const pluginDir = join(workspace, ".cline", "plugins"); + mkdirSync(pluginDir, { recursive: true }); + const { PluginRegistry, resetProcessPluginRegistryForTests } = await import( + "../extensions/plugin/plugin-registry" + ); + const registry = new PluginRegistry(); + resetProcessPluginRegistryForTests(registry); + const writePlugin = (file: string, name: string, toolName: string) => + writeFileSync( + join(pluginDir, file), + `export default { + name: "${name}", + manifest: { capabilities: ["tools"] }, + setup(api) { + api.registerTool({ name: "${toolName}", description: "", inputSchema: {}, execute: () => "${toolName}" }); + }, +};`, + "utf8", + ); + const { prepareLocalRuntimeBootstrap } = await import( + "./local-runtime-bootstrap" + ); + const start = (sessionId: string) => + prepareLocalRuntimeBootstrap({ + input: { + ...createStartInput(), + config: { + ...createStartInput().config, + cwd: workspace, + workspaceRoot: workspace, + }, + }, + sessionId, + providerSettingsManager: createProviderSettingsManager() as never, + onPluginEvent: () => {}, + onTeamEvent: () => {}, + createSpawnTool, + readSessionMetadata: async () => undefined, + writeSessionMetadata: async () => {}, + }); + // Setup runs on a session's first turn, which can come long after it + // started; run it explicitly to see which tools each session ends up with. + const toolsOf = async ( + bootstrap: Awaited>, + ) => { + const names: string[] = []; + for (const extension of bootstrap.extensions ?? []) { + if (!["existing", "added"].includes(extension.name)) continue; + await extension.setup?.( + { + registerTool: (tool: { name: string }) => names.push(tool.name), + registerCommand: () => {}, + registerMessageBuilder: () => {}, + registerRule: () => {}, + registerProvider: () => {}, + registerAutomationEventType: () => {}, + registerMcpServer: () => {}, + }, + {}, + ); + } + return names.sort(); + }; + + try { + writePlugin("existing.js", "existing", "tool_v1"); + const before = await start("started-before"); + + // Change the plugins while that session is still waiting for its + // first turn: edit one plugin and add another. + writePlugin("existing.js", "existing", "tool_v2"); + writePlugin("added.js", "added", "added_tool"); + const after = await start("started-after"); + + expect(await toolsOf(before)).toEqual(["tool_v1"]); + expect(await toolsOf(after)).toEqual(["added_tool", "tool_v2"]); + + // An explicit reload behaves the same way. + writePlugin("existing.js", "existing", "tool_v3"); + const beforeReload = await start("started-before-reload"); + writePlugin("existing.js", "existing", "tool_v4"); + await registry.reload("existing"); + const afterReload = await start("started-after-reload"); + expect(await toolsOf(beforeReload)).toEqual(["added_tool", "tool_v3"]); + expect(await toolsOf(afterReload)).toEqual(["added_tool", "tool_v4"]); + } finally { + resetProcessPluginRegistryForTests(undefined); + rmSync(root, { recursive: true, force: true }); + } + }); + it("threads active plugin skill directories into the runtime builder input", async () => { vi.resetModules(); resetModulesAfterEach = true; diff --git a/sdk/packages/core/src/services/local-runtime-bootstrap.ts b/sdk/packages/core/src/services/local-runtime-bootstrap.ts index 421d9889fd..599c96c6b0 100644 --- a/sdk/packages/core/src/services/local-runtime-bootstrap.ts +++ b/sdk/packages/core/src/services/local-runtime-bootstrap.ts @@ -9,6 +9,7 @@ import type { ExtensionContext, ITelemetryService, RuntimeConfigExtensionKind, + SessionPluginIssue, ToolApprovalRequest, ToolApprovalResult, WorkspaceInfo, @@ -288,6 +289,8 @@ export interface PrepareLocalRuntimeBootstrapOptions { defaultFetch?: typeof fetch; connectorsRequest?: ConnectorsRequest; onPluginEvent: (event: { name: string; payload?: unknown }) => void; + /** A plugin this session uses became degraded or failed after start. */ + onPluginIssue?: (issue: SessionPluginIssue) => void; onTeamEvent: (event: TeamEvent) => void; createSubAgentLifecycleCallbacks?: (config: CoreSessionConfig) => { onSubAgentEvent?: (event: AgentEvent) => void; @@ -316,6 +319,8 @@ export interface LocalRuntimeBootstrap { request: ToolApprovalRequest, ) => Promise | ToolApprovalResult; pluginSandboxShutdown?: () => Promise; + /** Plugins the session asked for but does not have (failed or disabled). */ + pluginIssues: SessionPluginIssue[]; runtimeBuilderInput: RuntimeBuilderInput; } @@ -334,6 +339,7 @@ export async function prepareLocalRuntimeBootstrap( defaultFetch, connectorsRequest, onPluginEvent, + onPluginIssue, onTeamEvent, createSubAgentLifecycleCallbacks, createSpawnTool, @@ -450,6 +456,7 @@ export async function prepareLocalRuntimeBootstrap( let loadedPlugins: | Awaited> | undefined; + const pluginIssues: SessionPluginIssue[] = []; if (hasConfigExtension(configExtensions, "plugins")) { try { loadedPlugins = await resolveAndLoadAgentPlugins({ @@ -466,17 +473,39 @@ export async function prepareLocalRuntimeBootstrap( logger: extensionContext.logger, telemetry: extensionContext.telemetry, automation: extensionContext.automation, + policy: input.plugins, + hookErrorMode: input.config.hookErrorMode, + onIssue: onPluginIssue, }); + pluginIssues.push(...(loadedPlugins.issues ?? [])); logPluginDiagnostics( loadedPlugins.failures, loadedPlugins.warnings, - localConfig?.logger, + localConfig?.logger ?? extensionContext.logger, ); } catch (error) { + // The registry reports per-plugin failures itself; reaching this + // means discovery as a whole broke. Surface it like a plugin issue + // so the client can show it instead of silently dropping plugins. const message = error instanceof Error ? error.message : String(error); - localConfig?.logger?.log?.( - `plugin loading failed; continuing without plugins (${message})`, + (localConfig?.logger ?? extensionContext.logger)?.log( + `Plugin loading failed; the session has no plugins: ${message}`, + { severity: "error", sessionId }, ); + pluginIssues.push({ + name: "plugins", + pluginPath: workspacePath, + state: "failed", + reason: "error", + lastError: { + phase: "discover", + message, + stack: error instanceof Error ? error.stack : undefined, + pluginPath: workspacePath, + timestamp: Date.now(), + sessionId, + }, + }); } } @@ -597,6 +626,7 @@ export async function prepareLocalRuntimeBootstrap( toolPolicies, requestToolApproval, pluginSandboxShutdown: loadedPlugins?.shutdown, + pluginIssues, runtimeBuilderInput: { config, hooks, diff --git a/sdk/packages/core/src/services/plugin-commands.ts b/sdk/packages/core/src/services/plugin-commands.ts index 33680dac34..748a315591 100644 --- a/sdk/packages/core/src/services/plugin-commands.ts +++ b/sdk/packages/core/src/services/plugin-commands.ts @@ -1,4 +1,3 @@ -import { statSync } from "node:fs"; import { type AgentExtensionCommand, type AgentExtensionCommandResult, @@ -11,6 +10,7 @@ import { resolveAgentPluginPaths, resolveAndLoadAgentPlugins, } from "../extensions/plugin/plugin-config-loader"; +import { fingerprintPluginSources } from "../extensions/plugin/plugin-module-import"; export interface PluginSlashCommand { /** Normalized token: lowercase, no leading slash. */ @@ -102,13 +102,7 @@ export function createPluginCommandService(options: { const ensureHost = (): Promise => { const pluginPaths = resolveAgentPluginPaths(loadOptions); const key = pluginPaths - .map((path) => { - try { - return `${path}:${statSync(path).mtimeMs}`; - } catch { - return path; - } - }) + .map((path) => `${path}:${fingerprintPluginSources(path)}`) .join("\n"); // Chain onto the previous host promise so concurrent callers serialize: // a stale host is shut down and replaced exactly once. diff --git a/sdk/packages/core/src/services/plugin-mcp-settings.ts b/sdk/packages/core/src/services/plugin-mcp-settings.ts index 167687cb56..9fda1bace3 100644 --- a/sdk/packages/core/src/services/plugin-mcp-settings.ts +++ b/sdk/packages/core/src/services/plugin-mcp-settings.ts @@ -1,20 +1,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, relative, resolve } from "node:path"; -import type { - AgentConfig, - AgentExtensionMcpServer, - AgentTool, -} from "@cline/shared"; +import type { AgentExtensionMcpServer } from "@cline/shared"; import { type McpServerRegistration, resolveDefaultMcpSettingsPath, resolvePluginMcpServerRegistrations, } from "../extensions/mcp"; -import { loadSandboxedPlugins } from "../extensions/plugin/plugin-sandbox"; - -type AgentExtension = NonNullable[number]; -type AgentExtensionApi = Parameters>[0]; -type AgentExtensionWithPath = AgentExtension & { __clinePluginPath?: string }; +import { collectPluginContributions } from "../extensions/plugin/plugin-contributions"; export interface PluginMcpSettingsMutation { name: string; @@ -235,100 +227,39 @@ async function collectPluginMcpServers( if (options.pluginPaths.length === 0) { return { plugins: [], servers: [], failures: [] }; } - const sandboxed = await loadSandboxedPlugins({ - pluginPaths: [...options.pluginPaths], + const collected = await collectPluginContributions({ + pluginPaths: options.pluginPaths, cwd: options.cwd, + workspacePath: options.workspacePath, providerId: options.providerId, modelId: options.modelId, - workspaceInfo: options.workspacePath - ? { rootPath: options.workspacePath } - : undefined, }); - try { - const plugins: Array<{ - pluginName: string; - pluginPath: string; - }> = []; - const servers: Array<{ - pluginName: string; - pluginPath: string; - server: AgentExtensionMcpServer; - }> = []; - const failures: PluginMcpSettingsSyncResult["failures"] = ( - sandboxed.failures ?? [] - ).map((failure) => ({ + const plugins = collected.plugins.map((plugin) => ({ + pluginName: plugin.pluginName, + pluginPath: plugin.pluginPath, + })); + const servers = collected.plugins.flatMap((plugin) => + plugin.mcpServers.map((server) => ({ + pluginName: plugin.pluginName, + pluginPath: plugin.pluginPath, + server: { + ...server, + metadata: { + ...(server.metadata ?? {}), + source: "plugin", + pluginName: plugin.pluginName, + pluginPath: plugin.pluginPath, + }, + }, + })), + ); + const failures: PluginMcpSettingsSyncResult["failures"] = + collected.failures.map((failure) => ({ pluginPath: failure.pluginPath, pluginName: failure.pluginName, message: failure.message, })); - for (const extension of sandboxed.extensions ?? []) { - const pluginPath = (extension as AgentExtensionWithPath) - .__clinePluginPath; - if (!pluginPath) { - continue; - } - if (!extension.setup) { - plugins.push({ - pluginName: extension.name, - pluginPath, - }); - continue; - } - const mcpServers: AgentExtensionMcpServer[] = []; - const api: AgentExtensionApi = { - registerTool: (_tool: AgentTool) => {}, - registerCommand: () => {}, - registerMessageBuilder: () => {}, - registerRule: () => {}, - registerProvider: () => {}, - registerAutomationEventType: () => {}, - registerMcpServer: (server) => { - if (!extension.manifest.capabilities.includes("mcp")) { - throw new Error('registerMcpServer requires the "mcp" capability'); - } - mcpServers.push(server); - }, - }; - try { - await extension.setup(api, { - workspaceInfo: options.workspacePath - ? { rootPath: options.workspacePath } - : undefined, - }); - } catch (error) { - failures.push({ - pluginPath, - pluginName: extension.name, - message: error instanceof Error ? error.message : String(error), - }); - continue; - } - plugins.push({ - pluginName: extension.name, - pluginPath, - }); - for (const server of mcpServers) { - servers.push({ - pluginName: extension.name, - pluginPath, - server: { - ...server, - metadata: { - ...(server.metadata ?? {}), - source: "plugin", - pluginName: extension.name, - pluginPath, - }, - }, - }); - } - } - return { plugins, servers, failures }; - } finally { - await sandboxed.shutdown().catch(() => { - // Best-effort cleanup after contribution discovery. - }); - } + return { plugins, servers, failures }; } export async function syncPluginMcpServersToSettings( diff --git a/sdk/packages/core/src/services/plugin-tools.test.ts b/sdk/packages/core/src/services/plugin-tools.test.ts new file mode 100644 index 0000000000..02abf7ccb0 --- /dev/null +++ b/sdk/packages/core/src/services/plugin-tools.test.ts @@ -0,0 +1,86 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setHomeDir } from "@cline/shared/storage"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + PluginRegistry, + resetProcessPluginRegistryForTests, +} from "../extensions/plugin/plugin-registry"; +import { listPluginToolsWithDiagnostics } from "./plugin-tools"; + +describe("listPluginToolsWithDiagnostics", () => { + const envSnapshot = { + HOME: process.env.HOME, + CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH, + CLINE_PLUGIN_MODE: process.env.CLINE_PLUGIN_MODE, + }; + let root: string; + let registry: PluginRegistry; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "core-plugin-tools-")); + process.env.HOME = root; + setHomeDir(root); + process.env.CLINE_GLOBAL_SETTINGS_PATH = join(root, "settings.json"); + registry = new PluginRegistry(); + resetProcessPluginRegistryForTests(registry); + await mkdir(join(root, ".cline", "plugins"), { recursive: true }); + }); + + afterEach(async () => { + resetProcessPluginRegistryForTests(undefined); + for (const [key, value] of Object.entries(envSnapshot)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + setHomeDir(envSnapshot.HOME ?? "~"); + await rm(root, { recursive: true, force: true }); + }); + + const writePlugin = async (name: string) => { + const path = join(root, ".cline", "plugins", `${name}.js`); + await writeFile( + path, + `export default { + name: "${name}", + manifest: { capabilities: ["tools"] }, + setup(api) { api.registerTool({ name: "${name}_tool", description: "", inputSchema: {}, execute: () => "ok" }); }, +};`, + "utf8", + ); + return path; + }; + + it("does not answer from a cached inspection after the plugin is turned off", async () => { + const path = await writePlugin("cached"); + const first = await listPluginToolsWithDiagnostics({ workspacePath: root }); + expect(first.tools.map((tool) => tool.name)).toEqual(["cached_tool"]); + + // A running session's copy failed badly enough to turn it off. + const error = new Error("stray"); + error.stack = `Error: stray\n at run (${path}:1:1)`; + registry.attributeUncaughtError(error); + + const second = await listPluginToolsWithDiagnostics({ + workspacePath: root, + }); + expect(second.tools).toEqual([]); + expect(second.plugins).toEqual([ + expect.objectContaining({ pluginName: "cached", state: "failed" }), + ]); + }); + + it("inspects plugins through the sandbox in sandbox mode", async () => { + await writePlugin("sandboxed"); + process.env.CLINE_PLUGIN_MODE = "sandbox"; + + const result = await listPluginToolsWithDiagnostics({ + workspacePath: root, + }); + + expect(result.tools.map((tool) => tool.name)).toEqual(["sandboxed_tool"]); + // Nothing was imported into this process. + expect(registry.list()).toEqual([]); + }); +}); diff --git a/sdk/packages/core/src/services/plugin-tools.ts b/sdk/packages/core/src/services/plugin-tools.ts index 00393b19f8..16c41576a8 100644 --- a/sdk/packages/core/src/services/plugin-tools.ts +++ b/sdk/packages/core/src/services/plugin-tools.ts @@ -1,18 +1,18 @@ -import { stat } from "node:fs/promises"; import { isAbsolute, relative, resolve } from "node:path"; -import type { AgentConfig, AgentTool } from "@cline/shared"; -import { resolveAgentPluginPaths } from "../extensions/plugin/plugin-config-loader"; +import type { PluginErrorRecord, PluginRuntimeState } from "@cline/shared"; +import { + resolveAgentPluginPathsWithDiagnostics, + resolvePluginExecutionMode, +} from "../extensions/plugin/plugin-config-loader"; +import { collectPluginContributions } from "../extensions/plugin/plugin-contributions"; import type { PluginInitializationFailure, PluginInitializationWarning, } from "../extensions/plugin/plugin-load-report"; -import { loadSandboxedPlugins } from "../extensions/plugin/plugin-sandbox"; +import { fingerprintPluginSources } from "../extensions/plugin/plugin-module-import"; +import { getProcessPluginRegistry } from "../extensions/plugin/plugin-registry"; import { resolveDisabledToolNames } from "./global-settings"; -type AgentExtension = NonNullable[number]; -type AgentExtensionApi = Parameters>[0]; -type AgentExtensionWithPath = AgentExtension & { __clinePluginPath?: string }; - function isPathWithin(parentPath: string, childPath: string): boolean { const relativePath = relative(resolve(parentPath), resolve(childPath)); return ( @@ -47,6 +47,9 @@ export interface PluginContributionSummary { commands: string[]; mcpServers: string[]; providers: string[]; + /** Runtime state; `failed` plugins are listed with empty contributions. */ + state: PluginRuntimeState; + lastError?: PluginErrorRecord; } type PluginToolDescriptor = Omit; @@ -86,22 +89,26 @@ async function buildPluginToolDescriptorCacheKey(input: { providerId?: string; modelId?: string; }): Promise { - const pathStats = await Promise.all( - input.pluginPaths.map(async (pluginPath) => { - try { - const stats = await stat(pluginPath); - return `${pluginPath}:${stats.mtimeMs}:${stats.size}`; - } catch { - return `${pluginPath}:missing`; - } - }), + // Content fingerprints, so an edit to any of a plugin's own files (not + // just its entry) invalidates the inspection. + const pathStats = input.pluginPaths.map( + (pluginPath) => `${pluginPath}:${fingerprintPluginSources(pluginPath)}`, ); + // Status is part of the key: a plugin a running session turned off (or a + // reload revived) must not be answered from an inspection made before. + const registry = getProcessPluginRegistry(); + const statuses = input.pluginPaths.map((pluginPath) => { + const status = registry.get(pluginPath)[0]; + return status ? `${status.state}:${status.updatedAt}` : "unknown"; + }); return JSON.stringify({ workspacePath: input.workspacePath, cwd: input.cwd, providerId: input.providerId, modelId: input.modelId, + mode: resolvePluginExecutionMode(), pathStats, + statuses, }); } @@ -127,53 +134,6 @@ function sortPluginToolDescriptors( }); } -async function collectPluginContributions( - extension: AgentExtension, - workspaceInfo?: { rootPath: string }, -): Promise<{ - tools: AgentTool[]; - rules: Array[0]>; - commands: Array[0]>; - mcpServers: Array[0]>; - providers: Array[0]>; -}> { - if (!extension.setup) { - return { - tools: [], - rules: [], - commands: [], - mcpServers: [], - providers: [], - }; - } - - const tools: AgentTool[] = []; - const rules: Array[0]> = []; - const commands: Array[0]> = - []; - const mcpServers: Array< - Parameters[0] - > = []; - const providers: Array[0]> = - []; - const api: AgentExtensionApi = { - registerTool: (tool) => tools.push(tool), - registerCommand: (command) => commands.push(command), - registerMessageBuilder: () => {}, - registerRule: (rule) => rules.push(rule), - registerProvider: (provider) => providers.push(provider), - registerAutomationEventType: () => {}, - registerMcpServer: (server) => { - if (!extension.manifest.capabilities.includes("mcp")) { - throw new Error('registerMcpServer requires the "mcp" capability'); - } - mcpServers.push(server); - }, - }; - await extension.setup(api, { workspaceInfo }); - return { tools, rules, commands, mcpServers, providers }; -} - export async function listPluginToolsWithDiagnostics(input: { workspacePath: string; cwd?: string; @@ -181,12 +141,13 @@ export async function listPluginToolsWithDiagnostics(input: { providerId?: string; modelId?: string; }): Promise { - const pluginPaths = resolveAgentPluginPaths({ + const resolved = resolveAgentPluginPathsWithDiagnostics({ workspacePath: input.workspacePath, cwd: input.cwd, }); + const pluginPaths = resolved.paths; const disabled = resolveDisabledToolNames(input.disabledToolNames); - if (pluginPaths.length === 0) { + if (pluginPaths.length === 0 && resolved.discoveryFailures.length === 0) { return { tools: [], plugins: [], failures: [], warnings: [] }; } @@ -209,94 +170,76 @@ export async function listPluginToolsWithDiagnostics(input: { const tools: PluginToolDescriptor[] = []; const plugins: PluginContributionSummary[] = []; - let failures: PluginInitializationFailure[] = []; - let warnings: PluginInitializationWarning[] = []; - let sandboxed: Awaited> | undefined; - - try { - sandboxed = await loadSandboxedPlugins({ - pluginPaths, - cwd: input.cwd, - providerId: input.providerId, - modelId: input.modelId, - workspaceInfo: { rootPath: input.workspacePath }, - }); - failures = [...sandboxed.failures]; - warnings = [...sandboxed.warnings]; - for (const extension of sandboxed.extensions ?? []) { - const pluginPath = (extension as AgentExtensionWithPath) - .__clinePluginPath; - if (!pluginPath) { - continue; - } - const pluginSource = isPathWithin(input.workspacePath, pluginPath) - ? "workspace-plugin" - : "global-plugin"; - let contributions: Awaited>; - try { - contributions = await collectPluginContributions(extension, { - rootPath: input.workspacePath, - }); - } catch (error) { - failures.push({ - pluginPath, - pluginName: extension.name, - phase: "setup", - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - continue; - } - for (const tool of contributions.tools) { - tools.push({ - name: tool.name, - pluginName: extension.name, - path: pluginPath, - source: pluginSource, - description: tool.description?.trim() || undefined, - }); - } - plugins.push({ + const collected = await collectPluginContributions({ + pluginPaths, + discoveryFailures: resolved.discoveryFailures, + cwd: input.cwd, + workspacePath: input.workspacePath, + providerId: input.providerId, + modelId: input.modelId, + }); + for (const contribution of collected.plugins) { + const { extension, pluginPath } = contribution; + const pluginSource = isPathWithin(input.workspacePath, pluginPath) + ? "workspace-plugin" + : "global-plugin"; + for (const tool of contribution.tools) { + tools.push({ + name: tool.name, pluginName: extension.name, path: pluginPath, - capabilities: [...extension.manifest.capabilities].sort(), - tools: contributions.tools.map((tool) => tool.name).sort(), - rules: contributions.rules.map((rule) => rule.id).sort(), - hooks: Object.keys(extension.hooks ?? {}).sort(), - commands: contributions.commands.map((command) => command.name).sort(), - mcpServers: contributions.mcpServers - .map((server) => server.name) - .sort(), - providers: contributions.providers - .map((provider) => provider.name) - .sort(), + source: pluginSource, + description: tool.description?.trim() || undefined, }); } - } catch (error) { - failures = pluginPaths.map((pluginPath) => ({ - pluginPath, - phase: "load" as const, - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - })); - } finally { - await sandboxed?.shutdown().catch(() => { - // Best effort cleanup after contribution discovery. + plugins.push({ + pluginName: extension.name, + path: pluginPath, + capabilities: [...extension.manifest.capabilities].sort(), + tools: contribution.tools.map((tool) => tool.name).sort(), + rules: contribution.rules.map((rule) => rule.id).sort(), + hooks: Object.keys(extension.hooks ?? {}).sort(), + commands: contribution.commands.map((command) => command.name).sort(), + mcpServers: contribution.mcpServers.map((server) => server.name).sort(), + providers: contribution.providers.map((provider) => provider.name).sort(), + state: "ready", + }); + } + // Failed plugins stay in the list so settings surfaces can show why a + // plugin's tools are missing instead of silently leaving it out. + for (const status of collected.failed) { + plugins.push({ + pluginName: status.name, + path: status.pluginPath, + capabilities: status.capabilities ?? [], + tools: [], + rules: [], + hooks: [], + commands: [], + mcpServers: [], + providers: [], + state: status.state, + ...(status.lastError ? { lastError: status.lastError } : {}), }); } const sortedTools = sortPluginToolDescriptors(tools); - cachePluginToolDescriptors(cacheKey, { + const entry = { tools: sortedTools, plugins, - failures, - warnings, - }); + failures: collected.failures, + warnings: collected.warnings, + }; + // A failed plugin may be fixed without touching the files the key + // fingerprints (a missing dependency installed), so only cache clean runs. + if (collected.failures.length === 0) { + cachePluginToolDescriptors(cacheKey, entry); + } return { tools: withEnabledState(sortedTools, disabled), plugins, - failures, - warnings, + failures: collected.failures, + warnings: collected.warnings, }; } diff --git a/sdk/packages/core/src/settings/settings-service.test.ts b/sdk/packages/core/src/settings/settings-service.test.ts index 7f94885802..76515d6fb2 100644 --- a/sdk/packages/core/src/settings/settings-service.test.ts +++ b/sdk/packages/core/src/settings/settings-service.test.ts @@ -614,6 +614,27 @@ Use the browser.`, ).toBe(true); }); + it("marks a plugin that fails to set up as failed and explains why", async () => { + const tempRoot = await mkdtemp(join(tmpdir(), "core-settings-plugins-")); + tempRoots.push(tempRoot); + process.env.HOME = tempRoot; + setHomeDir(tempRoot); + process.env.CLINE_GLOBAL_SETTINGS_PATH = join(tempRoot, "settings.json"); + const pluginRoot = join(tempRoot, ".cline", "plugins"); + await mkdir(pluginRoot, { recursive: true }); + const pluginPath = join(pluginRoot, "broken-setup.ts"); + await writeFile( + pluginPath, + 'export default { name: "broken-setup", manifest: { capabilities: ["tools"] }, setup() { throw new Error("no api key"); } };', + ); + + const snapshot = await new CoreSettingsService().list({ cwd: tempRoot }); + const plugin = snapshot.plugins.find((item) => item.path === pluginPath); + + expect(plugin?.contributions?.inspectionStatus).toBe("failed"); + expect(plugin?.loadError).toBe("Failed during setup: no api key"); + }); + it("does not use a shared plugin search-root package name for standalone plugins", async () => { const tempRoot = await mkdtemp(join(tmpdir(), "core-settings-plugins-")); tempRoots.push(tempRoot); diff --git a/sdk/packages/core/src/settings/settings-service.ts b/sdk/packages/core/src/settings/settings-service.ts index a7e94889b2..4fe2fffbed 100644 --- a/sdk/packages/core/src/settings/settings-service.ts +++ b/sdk/packages/core/src/settings/settings-service.ts @@ -596,13 +596,17 @@ export class CoreSettingsService { continue; } const contribution = contributionByPath.get(plugin.path); + const failed = !contribution || contribution.state === "failed"; + if (plugin.enabled !== false && contribution?.lastError) { + plugin.loadError ??= `Failed during ${contribution.lastError.phase}: ${contribution.lastError.message}`; + } plugin.contributions = { inspectionStatus: plugin.enabled === false ? "disabled" - : contribution - ? "available" - : "failed", + : failed + ? "failed" + : "available", capabilities: contribution?.capabilities ?? [], tools: contribution?.tools ?? [], skills: [ diff --git a/sdk/packages/core/src/types.ts b/sdk/packages/core/src/types.ts index bf598ccb2c..d113527aac 100644 --- a/sdk/packages/core/src/types.ts +++ b/sdk/packages/core/src/types.ts @@ -129,6 +129,8 @@ export type { SubprocessSandboxOptions, } from "./runtime/tools/subprocess-sandbox"; export { SubprocessSandbox } from "./runtime/tools/subprocess-sandbox"; +export type { WorkerSandboxOptions } from "./runtime/tools/worker-sandbox"; +export { WorkerSandbox } from "./runtime/tools/worker-sandbox"; export { FeatureFlagsService, type FeatureFlagsServiceOptions, diff --git a/sdk/packages/shared/src/agent.ts b/sdk/packages/shared/src/agent.ts index 9d897e854d..b280ee2d48 100644 --- a/sdk/packages/shared/src/agent.ts +++ b/sdk/packages/shared/src/agent.ts @@ -199,6 +199,10 @@ export interface AgentToolContext { metadata?: Record; snapshot?: AgentRuntimeStateSnapshot; emitUpdate?: (update: unknown) => void; + /** Session working directory. Set by the host for plugin tools. */ + cwd?: string; + /** Sends a plugin event to the calling session. Set by the host for plugin tools. */ + emitEvent?: (name: string, payload?: unknown) => void; } export interface AgentTool diff --git a/sdk/packages/shared/src/extensions/contribution-registry.ts b/sdk/packages/shared/src/extensions/contribution-registry.ts index 05d0b312c4..2665b0d395 100644 --- a/sdk/packages/shared/src/extensions/contribution-registry.ts +++ b/sdk/packages/shared/src/extensions/contribution-registry.ts @@ -199,6 +199,24 @@ export interface PluginSetupContext { * it); identity and common-property setters are no-ops. */ telemetry?: ITelemetryService; + /** + * The session's working directory. Use it instead of `process.cwd()`: + * plugins loaded in the Hub share one process across sessions. + */ + cwd?: string; + /** + * Sends a plugin event (for example `steer_message`) to the session that + * set the plugin up. Replaces `globalThis.__clinePluginHost.emitEvent`, + * which is kept as a shim. + */ + emitEvent?: (name: string, payload?: unknown) => void; + /** + * Registers cleanup to run when the session that set the plugin up ends. + * Plugins loaded in the Hub share its process, so anything a plugin starts + * for a session (timers, child processes, sockets, listeners on shared + * emitters) must be stopped here. + */ + onDispose?: (cleanup: () => void | Promise) => void; } const ExtensionCapabilityOptions = [ diff --git a/sdk/packages/shared/src/hub.ts b/sdk/packages/shared/src/hub.ts index 69b6f802a9..5f4a7dea3b 100644 --- a/sdk/packages/shared/src/hub.ts +++ b/sdk/packages/shared/src/hub.ts @@ -45,6 +45,9 @@ export type HubCapabilityName = | "run.list" | "hub.drain" | "hub.status" + | "plugins.list" + | "plugins.status" + | "plugins.reload" | "stream.replay"; export const HUB_CAPABILITIES: readonly HubCapabilityName[] = [ @@ -75,6 +78,9 @@ export const HUB_CAPABILITIES: readonly HubCapabilityName[] = [ "run.list", "hub.drain", "hub.status", + "plugins.list", + "plugins.status", + "plugins.reload", "stream.replay", ]; @@ -481,6 +487,95 @@ export interface HubSessionSearchHit { score: number; } +/** + * Lifecycle state of one plugin module loaded by a host (normally the Hub). + * + * - `loading`: the module is being imported. + * - `ready`: imported and set up without errors. + * - `degraded`: a hook or tool call failed; the plugin is still called. + * - `failed`: the last setup failed, or the plugin is off for every session + * because its import failed, a stray error was attributed to it, or calls + * kept failing. Only a setup failure lets the next session try again. + * - `disabled`: turned off in settings; never imported. + */ +export type PluginRuntimeState = + | "loading" + | "ready" + | "degraded" + | "failed" + | "disabled"; + +/** + * Where a plugin error happened: `discover`, `import`, `setup`, `dispose`, + * `hook:`, `tool:`, `command:`, `rule:`, + * `messageBuilder:`, or `uncaught` for a stray exception or rejection + * the host attributed to the plugin from its stack. + */ +export type PluginErrorPhase = + | "discover" + | "import" + | "setup" + | "dispose" + | "uncaught" + | `hook:${string}` + | `tool:${string}` + | `command:${string}` + | `rule:${string}` + | `messageBuilder:${string}`; + +export interface PluginErrorRecord { + phase: PluginErrorPhase; + message: string; + stack?: string; + pluginPath: string; + /** Epoch milliseconds. */ + timestamp: number; + sessionId?: string; + timedOut?: boolean; +} + +export interface PluginStatusRecord { + /** Plugin `name` export, or a name derived from the path before import. */ + name: string; + pluginPath: string; + state: PluginRuntimeState; + lastError?: PluginErrorRecord; + errorCount: number; + timeoutCount: number; + /** Live sessions that set this plugin up. */ + sessionIds: string[]; + capabilities?: string[]; + hooks?: string[]; + /** Epoch milliseconds of the last state or error change. */ + updatedAt: number; +} + +/** + * Per-session plugin selection, shaped like `toolPolicies`: `"*"` sets the + * default and named entries override it for one plugin. Plugins are enabled + * unless a policy says otherwise. + */ +export interface PluginPolicy { + enabled?: boolean; +} + +export type PluginPolicies = Record; + +/** A plugin a session asked for but does not have, reported at session start. */ +export interface SessionPluginIssue { + name: string; + pluginPath: string; + state: PluginRuntimeState; + /** `session_policy` when the session's `plugins` policy turned it off. */ + reason?: "session_policy" | "settings" | "error"; + lastError?: PluginErrorRecord; +} + +export interface HubPluginsReloadInput { + /** Plugin name or absolute module path. */ + plugin: string; +} + /** * Strongly typed task command payloads. This map is intentionally extensible so * other Hub command families can adopt typed payloads without changing the wire @@ -497,6 +592,9 @@ export interface HubCommandInputMap { "task.run": HubTaskRevisionInput; "task.automation.get": Record; "task.automation.set": HubTaskAutomationSetInput; + "plugins.list": Record; + "plugins.status": Record; + "plugins.reload": HubPluginsReloadInput; } /** Typed task command results returned in {@link HubReplyEnvelope.payload}. */ @@ -511,6 +609,9 @@ export interface HubCommandOutputMap { "task.run": { task: AgendaTaskRecord; run?: AgendaTaskRunRecord }; "task.automation.get": { policy: AgendaAutomationPolicy }; "task.automation.set": { policy: AgendaAutomationPolicy }; + "plugins.list": { plugins: PluginStatusRecord[] }; + "plugins.status": { plugins: PluginStatusRecord[] }; + "plugins.reload": { plugins: PluginStatusRecord[] }; } export type HubTypedCommandName = keyof HubCommandInputMap & HubCommandName; @@ -556,6 +657,9 @@ export type HubCommandName = | "run.proceed_while_running" | "hub.drain" | "hub.status" + | "plugins.list" + | "plugins.status" + | "plugins.reload" | "approval.request" | "approval.respond" | "capability.request" @@ -707,6 +811,7 @@ export type HubEventName = | "task.run.failed" | "task.automation.updated" | "settings.changed" + | "plugin.status_changed" | "ui.notify" | "ui.show_window" | "hub.client.updated"; diff --git a/sdk/packages/shared/src/rpc/runtime.ts b/sdk/packages/shared/src/rpc/runtime.ts index 844d892445..9c05d42110 100644 --- a/sdk/packages/shared/src/rpc/runtime.ts +++ b/sdk/packages/shared/src/rpc/runtime.ts @@ -1,5 +1,9 @@ import z from "zod"; -import type { HubToolExecutorName } from "../hub"; +import type { + HubToolExecutorName, + PluginPolicies, + SessionPluginIssue, +} from "../hub"; import type { ModelModality, ModelOperation, @@ -29,6 +33,8 @@ export interface ChatRuntimeConfig extends SessionPromptConfig { missionTimeIntervalMs?: number; timeoutSeconds?: number; toolPolicies?: SessionExecutionConfig["toolPolicies"]; + /** Per-session plugin selection, shaped like `toolPolicies`. */ + plugins?: PluginPolicies; toolExecutors?: HubToolExecutorName[]; configExtensions?: RuntimeConfigExtensionKind[]; } @@ -59,6 +65,8 @@ export interface ChatStartSessionArtifacts { export interface ChatStartSessionResponse { sessionId: string; startResult?: ChatStartSessionArtifacts; + /** Plugins the session asked for but does not have (failed or disabled). */ + pluginIssues?: SessionPluginIssue[]; } export interface ChatAttachmentFile {