diff --git a/src/agents/openai-ws-connection.test.ts b/src/agents/openai-ws-connection.test.ts index 4f3f2d4e706..c1f6c077184 100644 --- a/src/agents/openai-ws-connection.test.ts +++ b/src/agents/openai-ws-connection.test.ts @@ -6,6 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClientOptions } from "ws"; import type { ClientEvent, OpenAIWebSocketEvent, @@ -34,12 +35,12 @@ const { MockWebSocket } = vi.hoisted(() => { readyState: number = MockWebSocket.CONNECTING; url: string; - options: Record; + options: ClientOptions | undefined; sentMessages: string[] = []; private _listeners: Map = new Map(); - constructor(url: string, options?: Record) { + constructor(url: string, options?: ClientOptions) { this.url = url; this.options = options ?? {}; MockWebSocket.lastInstance = this; @@ -167,8 +168,7 @@ function buildManager(opts?: ConstructorParameters - new MockWebSocket(url, options as Record) as never, + socketFactory: (url, options) => new MockWebSocket(url, options) as never, ...opts, }); } diff --git a/src/agents/openai-ws-connection.ts b/src/agents/openai-ws-connection.ts index 1765eb00172..028311ddacb 100644 --- a/src/agents/openai-ws-connection.ts +++ b/src/agents/openai-ws-connection.ts @@ -14,7 +14,7 @@ */ import { EventEmitter } from "node:events"; -import WebSocket from "ws"; +import WebSocket, { type ClientOptions } from "ws"; import { resolveProviderAttributionHeaders } from "./provider-attribution.js"; // ───────────────────────────────────────────────────────────────────────────── @@ -268,7 +268,7 @@ export interface OpenAIWebSocketManagerOptions { /** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */ backoffDelaysMs?: readonly number[]; /** Custom socket factory for tests. */ - socketFactory?: (url: string, options: ConstructorParameters[1]) => WebSocket; + socketFactory?: (url: string, options: ClientOptions) => WebSocket; } type InternalEvents = { @@ -308,10 +308,7 @@ export class OpenAIWebSocketManager extends EventEmitter { private readonly wsUrl: string; private readonly maxRetries: number; private readonly backoffDelaysMs: readonly number[]; - private readonly socketFactory: ( - url: string, - options: ConstructorParameters[1], - ) => WebSocket; + private readonly socketFactory: (url: string, options: ClientOptions) => WebSocket; constructor(options: OpenAIWebSocketManagerOptions = {}) { super(); diff --git a/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts b/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts index 2eac44e922b..61b37f37f63 100644 --- a/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts +++ b/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts @@ -175,9 +175,9 @@ vi.mock("@mariozechner/pi-ai", async () => { const sawBundleResult = toolResultText.some((text) => text.includes("FROM-BUNDLE")); if (!sawBundleResult) { stream.push({ - type: "done", + type: "error", reason: "error", - message: { + error: { role: "assistant" as const, content: [], stopReason: "error" as const, diff --git a/src/agents/pi-project-settings.bundle.test.ts b/src/agents/pi-project-settings.bundle.test.ts index 5859e18ac6e..8c104f74282 100644 --- a/src/agents/pi-project-settings.bundle.test.ts +++ b/src/agents/pi-project-settings.bundle.test.ts @@ -1,205 +1,222 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; +import { captureEnv } from "../test-utils/env.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; - -const hoisted = vi.hoisted(() => ({ - loadPluginManifestRegistry: vi.fn(), -})); - -vi.mock("../plugins/manifest-registry.js", () => ({ - loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args), -})); - -const { loadEnabledBundlePiSettingsSnapshot } = await import("./pi-project-settings.js"); +import { loadEnabledBundlePiSettingsSnapshot } from "./pi-project-settings.js"; const tempDirs = createTrackedTempDirs(); -function buildRegistry(params: { - pluginRoot: string; - settingsFiles?: string[]; -}): PluginManifestRegistry { - return { - diagnostics: [], - plugins: [ - { - id: "claude-bundle", - name: "Claude Bundle", - format: "bundle", - bundleFormat: "claude", - bundleCapabilities: ["settings"], - channels: [], - providers: [], - skills: [], - settingsFiles: params.settingsFiles ?? ["settings.json"], - hooks: [], - origin: "workspace", - rootDir: params.pluginRoot, - source: params.pluginRoot, - manifestPath: path.join(params.pluginRoot, ".claude-plugin", "plugin.json"), - }, - ], - }; +async function createHomeAndWorkspace() { + const homeDir = await tempDirs.make("openclaw-bundle-home-"); + const workspaceDir = await tempDirs.make("openclaw-workspace-"); + return { homeDir, workspaceDir }; +} + +async function createClaudeBundlePlugin(params: { + homeDir: string; + pluginId: string; + pluginJson?: Record; + settingsJson?: Record; + mcpJson?: Record; +}) { + const pluginRoot = path.join(params.homeDir, ".openclaw", "extensions", params.pluginId); + await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: params.pluginId, ...params.pluginJson }, null, 2)}\n`, + "utf-8", + ); + if (params.settingsJson) { + await fs.writeFile( + path.join(pluginRoot, "settings.json"), + `${JSON.stringify(params.settingsJson, null, 2)}\n`, + "utf-8", + ); + } + if (params.mcpJson) { + await fs.mkdir(path.join(pluginRoot, "servers"), { recursive: true }); + await fs.writeFile( + path.join(pluginRoot, ".mcp.json"), + `${JSON.stringify(params.mcpJson, null, 2)}\n`, + "utf-8", + ); + } + return pluginRoot; } afterEach(async () => { - hoisted.loadPluginManifestRegistry.mockReset(); + clearPluginManifestRegistryCache(); await tempDirs.cleanup(); }); describe("loadEnabledBundlePiSettingsSnapshot", () => { it("loads sanitized settings from enabled bundle plugins", async () => { - const workspaceDir = await tempDirs.make("openclaw-workspace-"); - const pluginRoot = await tempDirs.make("openclaw-bundle-"); - await fs.writeFile( - path.join(pluginRoot, "settings.json"), - JSON.stringify({ - hideThinkingBlock: true, - shellPath: "/tmp/blocked-shell", - compaction: { keepRecentTokens: 64_000 }, - }), - "utf-8", - ); - hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ pluginRoot })); + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); + try { + const { homeDir, workspaceDir } = await createHomeAndWorkspace(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; - const snapshot = loadEnabledBundlePiSettingsSnapshot({ - cwd: workspaceDir, - cfg: { - plugins: { - entries: { - "claude-bundle": { enabled: true }, - }, + await createClaudeBundlePlugin({ + homeDir, + pluginId: "claude-bundle", + settingsJson: { + hideThinkingBlock: true, + shellPath: "/tmp/blocked-shell", + compaction: { keepRecentTokens: 64_000 }, }, - }, - }); + }); - expect(snapshot.hideThinkingBlock).toBe(true); - expect(snapshot.shellPath).toBeUndefined(); - expect(snapshot.compaction?.keepRecentTokens).toBe(64_000); - }); - - it("loads enabled bundle MCP servers into the Pi settings snapshot", async () => { - const workspaceDir = await tempDirs.make("openclaw-workspace-"); - const pluginRoot = await tempDirs.make("openclaw-bundle-"); - await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.mkdir(path.join(pluginRoot, "servers"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, ".claude-plugin", "plugin.json"), - JSON.stringify({ - name: "claude-bundle", - }), - "utf-8", - ); - await fs.writeFile( - path.join(pluginRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - bundleProbe: { - command: "node", - args: ["./servers/probe.mjs"], - }, - }, - }), - "utf-8", - ); - hoisted.loadPluginManifestRegistry.mockReturnValue( - buildRegistry({ pluginRoot, settingsFiles: [] }), - ); - - const snapshot = loadEnabledBundlePiSettingsSnapshot({ - cwd: workspaceDir, - cfg: { - plugins: { - entries: { - "claude-bundle": { enabled: true }, - }, - }, - }, - }); - - expect(snapshot.mcpServers).toEqual({ - bundleProbe: { - command: "node", - args: [path.join(pluginRoot, "servers", "probe.mjs")], - cwd: pluginRoot, - }, - }); - }); - - it("lets top-level MCP config override bundle MCP defaults", async () => { - const workspaceDir = await tempDirs.make("openclaw-workspace-"); - const pluginRoot = await tempDirs.make("openclaw-bundle-"); - await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, ".claude-plugin", "plugin.json"), - JSON.stringify({ - name: "claude-bundle", - }), - "utf-8", - ); - await fs.writeFile( - path.join(pluginRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - sharedServer: { - command: "node", - args: ["./servers/bundle.mjs"], - }, - }, - }), - "utf-8", - ); - hoisted.loadPluginManifestRegistry.mockReturnValue( - buildRegistry({ pluginRoot, settingsFiles: [] }), - ); - - const snapshot = loadEnabledBundlePiSettingsSnapshot({ - cwd: workspaceDir, - cfg: { - mcp: { - servers: { - sharedServer: { - url: "https://example.com/mcp", + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + plugins: { + entries: { + "claude-bundle": { enabled: true }, }, }, }, - plugins: { - entries: { - "claude-bundle": { enabled: true }, + }); + + expect(snapshot.hideThinkingBlock).toBe(true); + expect(snapshot.shellPath).toBeUndefined(); + expect(snapshot.compaction?.keepRecentTokens).toBe(64_000); + } finally { + env.restore(); + } + }); + + it("loads enabled bundle MCP servers into the Pi settings snapshot", async () => { + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); + try { + const { homeDir, workspaceDir } = await createHomeAndWorkspace(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; + + const pluginRoot = await createClaudeBundlePlugin({ + homeDir, + pluginId: "claude-bundle", + mcpJson: { + mcpServers: { + bundleProbe: { + command: "node", + args: ["./servers/probe.mjs"], + }, }, }, - }, - }); + }); - expect(snapshot.mcpServers).toEqual({ - sharedServer: { - url: "https://example.com/mcp", - }, - }); + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + plugins: { + entries: { + "claude-bundle": { enabled: true }, + }, + }, + }, + }); + const resolvedPluginRoot = await fs.realpath(pluginRoot); + + expect(snapshot.mcpServers).toEqual({ + bundleProbe: { + command: "node", + args: [path.join(resolvedPluginRoot, "servers", "probe.mjs")], + cwd: resolvedPluginRoot, + }, + }); + } finally { + env.restore(); + } + }); + + it("lets top-level MCP config override bundle MCP defaults", async () => { + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); + try { + const { homeDir, workspaceDir } = await createHomeAndWorkspace(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; + + await createClaudeBundlePlugin({ + homeDir, + pluginId: "claude-bundle", + mcpJson: { + mcpServers: { + sharedServer: { + command: "node", + args: ["./servers/bundle.mjs"], + }, + }, + }, + }); + + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + mcp: { + servers: { + sharedServer: { + url: "https://example.com/mcp", + }, + }, + }, + plugins: { + entries: { + "claude-bundle": { enabled: true }, + }, + }, + }, + }); + + expect(snapshot.mcpServers).toEqual({ + sharedServer: { + url: "https://example.com/mcp", + }, + }); + } finally { + env.restore(); + } }); it("ignores disabled bundle plugins", async () => { - const workspaceDir = await tempDirs.make("openclaw-workspace-"); - const pluginRoot = await tempDirs.make("openclaw-bundle-"); - await fs.writeFile( - path.join(pluginRoot, "settings.json"), - JSON.stringify({ hideThinkingBlock: true }), - "utf-8", - ); - hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ pluginRoot })); + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); + try { + const { homeDir, workspaceDir } = await createHomeAndWorkspace(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; - const snapshot = loadEnabledBundlePiSettingsSnapshot({ - cwd: workspaceDir, - cfg: { - plugins: { - entries: { - "claude-bundle": { enabled: false }, + await createClaudeBundlePlugin({ + homeDir, + pluginId: "claude-bundle", + settingsJson: { + hideThinkingBlock: true, + }, + }); + + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + plugins: { + entries: { + "claude-bundle": { enabled: false }, + }, }, }, - }, - }); + }); - expect(snapshot).toEqual({}); + expect(snapshot).toEqual({}); + } finally { + env.restore(); + } }); }); diff --git a/src/agents/pi-project-settings.ts b/src/agents/pi-project-settings.ts index fd66a6ee393..9732e8088a9 100644 --- a/src/agents/pi-project-settings.ts +++ b/src/agents/pi-project-settings.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { BundleMcpServerConfig } from "../plugins/bundle-mcp.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; @@ -18,7 +19,9 @@ export const SANITIZED_PROJECT_PI_KEYS = ["shellPath", "shellCommandPrefix"] as export type EmbeddedPiProjectSettingsPolicy = "trusted" | "sanitize" | "ignore"; -type PiSettingsSnapshot = ReturnType; +type PiSettingsSnapshot = ReturnType & { + mcpServers?: Record; +}; function sanitizePiSettingsSnapshot(settings: PiSettingsSnapshot): PiSettingsSnapshot { const sanitized = { ...settings }; diff --git a/src/cli/mcp-cli.ts b/src/cli/mcp-cli.ts index 62831ee827d..aaeba39bb34 100644 --- a/src/cli/mcp-cli.ts +++ b/src/cli/mcp-cli.ts @@ -10,6 +10,7 @@ import { defaultRuntime } from "../runtime.js"; function fail(message: string): never { defaultRuntime.error(message); defaultRuntime.exit(1); + throw new Error("unreachable"); } function printJson(value: unknown): void {