// Gateway plugin tests cover plugin loading, auto-enable, runtime registry setup, // request-scope injection, diagnostics, and handler dispatch integration. import os from "node:os"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { getGlobalPluginRegistry, initializeGlobalHookRunner, resetGlobalHookRunner, } from "../plugins/hook-runner-global.js"; import { createPluginRecord } from "../plugins/loader-records.js"; import type { PluginDiagnostic } from "../plugins/manifest-types.js"; import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js"; import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.test-fixtures.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { withEnv } from "../test-utils/env.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); const loadPluginLookUpTable = vi.hoisted(() => vi.fn(() => ({ startup: { pluginIds: ["discord", "telegram"], }, })), ); const applyPluginAutoEnable = vi.hoisted(() => vi.fn(({ config }) => ({ config, changes: [], autoEnabledReasons: {} })), ); const primeConfiguredBindingRegistry = vi.hoisted(() => vi.fn(() => ({ bindingCount: 0, channelCount: 0 })), ); const normalizeProviderModelIdWithRuntime = vi.hoisted(() => vi.fn(() => undefined)); const pluginRuntimeLoaderLogger = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), })); type HandleGatewayRequestOptions = GatewayRequestOptions & { extraHandlers?: Record; }; const handleGatewayRequest = vi.hoisted(() => vi.fn(async (_opts: HandleGatewayRequestOptions) => {}), ); const dispatchReplyFromConfig = vi.hoisted(() => vi.fn(async () => ({ counts: {}, queuedFinal: false })), ); vi.mock("../plugins/loader.js", () => ({ loadAndActivateRootPluginRegistry: loadOpenClawPlugins, loadOpenClawPlugins, })); vi.mock("../plugins/runtime/load-context.js", () => ({ createPluginRuntimeLoaderLogger: () => pluginRuntimeLoaderLogger, })); vi.mock("../plugins/plugin-lookup-table.js", () => ({ loadPluginLookUpTable, })); vi.mock("../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable, })); vi.mock("../agents/provider-model-normalization.runtime.js", () => ({ normalizeProviderModelIdWithRuntime, })); vi.mock("../channels/plugins/configured-binding-registry.js", async () => { const actual = await vi.importActual< typeof import("../channels/plugins/configured-binding-registry.js") >("../channels/plugins/configured-binding-registry.js"); return { ...actual, primeConfiguredBindingRegistry, }; }); vi.mock("./server-methods.js", () => ({ handleGatewayRequest, })); vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ dispatchReplyFromConfig, })); vi.mock("./agent-turn/internal-facade.js", () => ({ createInternalAgentTurnFacade: (options: { client: GatewayRequestOptions["client"]; getContext: () => GatewayRequestContext; isWebchatConnect?: GatewayRequestOptions["isWebchatConnect"]; }) => ({ dispatch: async ( request: Record, dispatchOptions?: { expectFinal?: boolean; onAccepted?: (payload: unknown) => void; onSignalAbort?: () => Promise | void; signal?: AbortSignal; timeoutMs?: number; }, ) => { const { dispatchGatewayRequestInProcess } = await import("./server-in-process-dispatch.js"); return await dispatchGatewayRequestInProcess("agent", request, { client: options.client, context: options.getContext(), isWebchatConnect: options.isWebchatConnect, ...dispatchOptions, }); }, wait: async (params: Record, timeoutMs?: number, signal?: AbortSignal) => { const { dispatchGatewayRequestInProcess } = await import("./server-in-process-dispatch.js"); return await dispatchGatewayRequestInProcess("agent.wait", params, { client: options.client, context: options.getContext(), isWebchatConnect: options.isWebchatConnect, signal, timeoutMs, }); }, }), })); vi.mock("../channels/registry.js", () => ({ CHAT_CHANNEL_ORDER: [], CHANNEL_IDS: [], listChatChannels: () => [], getChatChannelMeta: () => null, normalizeChatChannelId: () => null, normalizeChannelId: () => null, normalizeAnyChannelId: () => null, formatChannelPrimerLine: () => "", formatChannelSelectionLine: () => "", })); const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ ...createEmptyPluginRegistry(), diagnostics, }); function addLoadedPlugin( registry: PluginRegistry, params: { id: string; origin?: PluginRegistry["plugins"][number]["origin"]; trustedOfficialInstall?: boolean; }, ): PluginRegistry { registry.plugins.push( createPluginRecord({ id: params.id, name: params.id, source: `/tmp/${params.id}/index.js`, origin: params.origin ?? "bundled", enabled: true, configSchema: false, ...(params.trustedOfficialInstall !== undefined ? { trustedOfficialInstall: params.trustedOfficialInstall } : {}), }), ); return registry; } function createLookUpTableForTest(params: { installRecords?: PluginLookUpTable["index"]["installRecords"]; manifestRegistry?: PluginLookUpTable["manifestRegistry"]; pluginIds?: readonly string[]; workerProviderIds?: readonly string[]; }): PluginLookUpTable { return { policyHash: "test", index: { version: 1, hostContractVersion: "test", compatRegistryVersion: "test", migrationVersion: 1, policyHash: "test", generatedAtMs: 1, installRecords: params.installRecords ?? {}, plugins: [], diagnostics: [], }, registryDiagnostics: [], manifestRegistry: params.manifestRegistry ?? { plugins: [], diagnostics: [] }, plugins: [], diagnostics: [], byPluginId: new Map(), normalizePluginId: (pluginId) => pluginId, owners: { channels: new Map(), channelConfigs: new Map(), providers: new Map(), modelCatalogProviders: new Map(), cliBackends: new Map(), setupProviders: new Map(), commandAliases: new Map(), contracts: new Map(), }, startup: { channelPluginIds: [], pluginIds: params.pluginIds ?? [], }, workerProviderIds: params.workerProviderIds ?? [], metrics: { registrySnapshotMs: 0, manifestRegistryMs: 0, startupPlanMs: 0, ownerMapsMs: 0, totalMs: 0, indexPluginCount: 0, manifestPluginCount: 0, startupPluginCount: params.pluginIds?.length ?? 0, }, }; } type ServerPluginsModule = typeof import("./server-plugins.js") & { clearFallbackGatewayContext: () => void; setFallbackGatewayContext: (context: GatewayRequestContext) => () => void; setFallbackGatewayContextResolver: ( resolve: () => GatewayRequestContext | undefined, ) => () => void; }; type ServerPluginBootstrapModule = typeof import("./server-plugin-bootstrap.js"); type PluginRuntimeModule = typeof import("../plugins/runtime/index.js"); type PluginRuntimeRegistryModule = typeof import("../plugins/runtime.js"); type GatewayRequestScopeModule = typeof import("../plugins/runtime/gateway-request-scope.js"); type MethodScopesModule = typeof import("./method-scopes.js"); type RuntimeStateModule = typeof import("../plugins/runtime-state.js"); let serverPluginsModule: ServerPluginsModule; let serverPluginBootstrapModule: ServerPluginBootstrapModule; let runtimeModule: PluginRuntimeModule; let runtimeRegistryModule: PluginRuntimeRegistryModule; let gatewayRequestScopeModule: GatewayRequestScopeModule; let methodScopesModule: MethodScopesModule; let getActivePluginRegistryWorkspaceDirFromState: typeof import("../plugins/runtime-state.js").getActivePluginRegistryWorkspaceDirFromState; let resolveTestGatewayContext: () => GatewayRequestContext | undefined = () => undefined; function createTestLog() { return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }; } function createTestContext(label: string): GatewayRequestContext { return { label } as unknown as GatewayRequestContext; } const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function getLastMockFirstArg( mock: { mock: { calls: ReadonlyArray> } }, label: string, ): unknown { const calls = mock.mock.calls; const call = calls[calls.length - 1]; if (!call) { throw new Error(`Expected ${label} mock to have at least one call`); } return call[0]; } function readRecordField(record: Record, key: string, label: string) { const value = record[key]; if (!isRecord(value)) { throw new Error(`Expected ${label} to be an object`); } return value; } function getLastPluginLoadOptions(): Record { return requireRecord( getLastMockFirstArg(loadOpenClawPlugins, "plugin load"), "plugin load options", ); } function getLastPluginLoadOption(key: string) { return getLastPluginLoadOptions()[key]; } function getLastDispatchedParams(): Record | undefined { const call = getLastMockFirstArg(handleGatewayRequest, "gateway request") as | HandleGatewayRequestOptions | undefined; return call?.req?.params as Record | undefined; } function getRequiredLastDispatchedParams(): Record { return requireRecord(getLastDispatchedParams(), "dispatched params"); } function getLastDispatchedClientScopes(): string[] { const call = getLastMockFirstArg(handleGatewayRequest, "gateway request") as | HandleGatewayRequestOptions | undefined; const scopes = call?.client?.connect?.scopes; return Array.isArray(scopes) ? scopes : []; } function getLastDispatchedClientInternal(): Record { const call = getLastMockFirstArg(handleGatewayRequest, "gateway request") as | HandleGatewayRequestOptions | undefined; return (call?.client?.internal ?? {}) as Record; } function getLastPluginLoadLogger(): { info: (message: string) => void; warn: (message: string) => void; error: (message: string) => void; debug?: (message: string) => void; } { const call = getLastMockFirstArg(loadOpenClawPlugins, "plugin load") as | { logger?: { info: (message: string) => void; warn: (message: string) => void; error: (message: string) => void; debug?: (message: string) => void; }; } | undefined; if (!call?.logger) { throw new Error("Expected plugin loader to receive a logger"); } return call.logger; } async function loadTestModules() { const actualServerPlugins = await import("./server-plugins.js"); serverPluginsModule = { ...actualServerPlugins, clearFallbackGatewayContext: () => { resolveTestGatewayContext = () => undefined; }, setFallbackGatewayContext: (context) => { resolveTestGatewayContext = () => context; return () => { if (resolveTestGatewayContext() === context) { resolveTestGatewayContext = () => undefined; } }; }, setFallbackGatewayContextResolver: (resolve) => { resolveTestGatewayContext = resolve; return () => { if (resolveTestGatewayContext === resolve) { resolveTestGatewayContext = () => undefined; } }; }, dispatchGatewayMethodInProcess: (method, params, options) => actualServerPlugins.dispatchGatewayMethodInProcess(method, params, { ...options, resolveGatewayContext: resolveTestGatewayContext, }), dispatchGatewayMethodInProcessRaw: (method, params, options) => actualServerPlugins.dispatchGatewayMethodInProcessRaw(method, params, { ...options, resolveGatewayContext: resolveTestGatewayContext, }), getInProcessGatewayRequestContext: () => actualServerPlugins.getInProcessGatewayRequestContext(resolveTestGatewayContext), hasInProcessGatewayContext: () => actualServerPlugins.hasInProcessGatewayContext(resolveTestGatewayContext), } as ServerPluginsModule; serverPluginBootstrapModule = await import("./server-plugin-bootstrap.js"); runtimeModule = await import("../plugins/runtime/index.js"); runtimeRegistryModule = await import("../plugins/runtime.js"); gatewayRequestScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); methodScopesModule = await import("./method-scopes.js"); const runtimeStateModule: RuntimeStateModule = await import("../plugins/runtime-state.js"); ({ getActivePluginRegistryWorkspaceDirFromState } = runtimeStateModule); } async function createSubagentRuntime( _serverPlugins: ServerPluginsModule, cfg: Record = {}, ): Promise { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayStartupPluginsForTest({ cfg, }); return createRuntimeFromLastGatewayLoad().subagent; } function createRuntimeFromLastGatewayLoad(): PluginRuntime { const runtimeOptions = getLastPluginLoadOption("runtimeOptions") as | Parameters[0] | undefined; if (!runtimeOptions?.nodes || !runtimeOptions.subagent) { throw new Error("Expected gateway plugin load to receive concrete node and subagent runtimes"); } return runtimeModule.createPluginRuntime(runtimeOptions); } function registerActivePluginToolOwnership( pluginId: string, names: string[], declaredNames: string[] = names, ): void { const registry = runtimeRegistryModule.getActivePluginRegistry(); if (!registry) { throw new Error("Expected an active plugin registry"); } registry.tools.push({ pluginId, factory: () => null, names, declaredNames, optional: true, source: `/tmp/${pluginId}/index.js`, }); } function loadGatewayPluginsForTest( overrides: Partial[0]> = {}, ) { const log = createTestLog(); const loaded = serverPluginsModule.loadGatewayPlugins({ cfg: {}, workspaceDir: "/tmp", log, coreGatewayHandlers: {}, baseMethods: [], resolveGatewayContext: () => resolveTestGatewayContext(), ...overrides, }); // The mocked root loader returns a value without performing its production // installation side effect, so mirror that ownership boundary in the harness. runtimeRegistryModule.setActivePluginRegistry(loaded.pluginRegistry); return log; } function loadGatewayStartupPluginsForTest( overrides: Partial[0]> = {}, ) { const log = createTestLog(); const loaded = serverPluginBootstrapModule.loadGatewayStartupPlugins({ cfg: {}, workspaceDir: "/tmp", log, coreGatewayHandlers: {}, baseMethods: [], resolveGatewayContext: () => resolveTestGatewayContext(), ...overrides, }); runtimeRegistryModule.setActivePluginRegistry(loaded.pluginRegistry); return log; } beforeAll(async () => { await loadTestModules(); }); beforeEach(() => { loadOpenClawPlugins.mockReset(); loadPluginLookUpTable.mockReset().mockReturnValue({ startup: { pluginIds: ["discord", "telegram"], }, }); applyPluginAutoEnable .mockReset() .mockImplementation(({ config }) => ({ config, changes: [], autoEnabledReasons: {} })); primeConfiguredBindingRegistry.mockClear().mockReturnValue({ bindingCount: 0, channelCount: 0 }); normalizeProviderModelIdWithRuntime.mockReset().mockReturnValue(undefined); dispatchReplyFromConfig.mockClear(); pluginRuntimeLoaderLogger.info.mockClear(); pluginRuntimeLoaderLogger.warn.mockClear(); pluginRuntimeLoaderLogger.error.mockClear(); pluginRuntimeLoaderLogger.debug.mockClear(); handleGatewayRequest.mockReset(); handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => { switch (opts.req.method) { case "agent": opts.respond(true, { runId: "run-1" }); return; case "agent.wait": opts.respond(true, { status: "ok" }); return; case "sessions.get": opts.respond(true, { messages: [] }); return; case "sessions.delete": opts.respond(true, {}); return; default: opts.respond(true, {}); } }); }); afterEach(() => { setActiveDegradedPlugins([]); serverPluginsModule.clearFallbackGatewayContext(); runtimeRegistryModule.resetPluginRuntimeStateForTest(); resetGlobalHookRunner(); }); describe("loadGatewayPlugins", () => { test("logs plugin errors with details", () => { const diagnostics: PluginDiagnostic[] = [ { level: "error", pluginId: "telegram", source: "/tmp/telegram/index.ts", message: "failed to load plugin: boom", }, ]; loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics)); const log = loadGatewayStartupPluginsForTest(); expect(log.error).toHaveBeenCalledWith( "[plugins] failed to load plugin: boom (plugin=telegram, source=/tmp/telegram/index.ts)", ); expect(log.warn).not.toHaveBeenCalled(); }); test("does not re-log a quarantined plugin verification diagnostic", () => { const diagnostic: PluginDiagnostic = { level: "error", code: "plugin-verification", pluginId: "broken-payload", source: "/tmp/broken-payload/index.ts", message: "configured plugin payload verification failed (missing-main-entry): missing", }; const distinctDiagnostic: PluginDiagnostic = { ...diagnostic, message: "configured plugin payload verification failed (missing-package-json): missing", }; const registry = createRegistry([diagnostic, distinctDiagnostic]); loadOpenClawPlugins.mockReturnValue(registry); setActiveDegradedPlugins([ { pluginId: "broken-payload", state: "configured-unavailable", diagnostic: { kind: "plugin-verification", reason: "missing-main-entry", detail: "missing", }, }, ]); const log = loadGatewayStartupPluginsForTest(); expect(log.error).toHaveBeenCalledOnce(); expect(log.error).toHaveBeenCalledWith( "[plugins] configured plugin payload verification failed (missing-package-json): missing (plugin=broken-payload, source=/tmp/broken-payload/index.ts)", ); expect(registry.diagnostics).toEqual([diagnostic, distinctDiagnostic]); }); test("loads only gateway startup plugin ids", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayPluginsForTest(); expect(applyPluginAutoEnable).toHaveBeenCalledWith({ config: {}, env: process.env, }); expect(loadPluginLookUpTable).toHaveBeenCalledWith({ config: {}, activationSourceConfig: undefined, workspaceDir: "/tmp", env: process.env, }); expect(getLastPluginLoadOption("onlyPluginIds")).toEqual(["discord", "telegram"]); expect(getLastPluginLoadOption("preferBuiltPluginArtifacts")).toBe(true); }); test("binds channel reply dispatch to the owning Gateway context", async () => { const context = { label: "channel-reply-owner", workerSessionPlacementService: { getMany: vi.fn(() => new Map()) }, } as unknown as GatewayRequestContext; serverPluginsModule.setFallbackGatewayContext(context); loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayPluginsForTest(); const runtimeOptions = getLastPluginLoadOption("runtimeOptions") as | Parameters[0] | undefined; const boundDispatch = runtimeOptions?.dispatchReplyFromConfig; if (!boundDispatch) { throw new Error("Expected gateway plugin load to bind channel reply dispatch"); } const params = {} as Parameters[0]; await boundDispatch(params); expect(dispatchReplyFromConfig).toHaveBeenCalledWith({ ...params, sessionWorkerPlacementContext: context, }); }); test("injects the process HOME-isolation fact into registry construction", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); const home = os.userInfo().homedir; const defaultStateDir = path.join(home, ".openclaw"); withEnv( { HOME: home, USERPROFILE: home, OPENCLAW_HOME: undefined, OPENCLAW_PROFILE: undefined, OPENCLAW_STATE_DIR: defaultStateDir, OPENCLAW_CONFIG_PATH: path.join(defaultStateDir, "openclaw.json"), }, () => loadGatewayPluginsForTest(), ); expect(getLastPluginLoadOption("allowProcessHomeSessionCatalogs")).toBe(true); withEnv( { HOME: home, USERPROFILE: home, OPENCLAW_HOME: undefined, OPENCLAW_PROFILE: "dev", OPENCLAW_STATE_DIR: path.join(home, ".openclaw-dev"), OPENCLAW_CONFIG_PATH: path.join(home, ".openclaw-dev", "openclaw.json"), }, () => loadGatewayPluginsForTest(), ); expect(getLastPluginLoadOption("allowProcessHomeSessionCatalogs")).toBe(false); }); test("routes plugin registration logs through the plugin logger", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); const log = loadGatewayPluginsForTest(); const logger = getLastPluginLoadLogger(); logger.info("plugin ready"); logger.warn("plugin warning"); expect(pluginRuntimeLoaderLogger.info).toHaveBeenCalledWith("plugin ready"); expect(pluginRuntimeLoaderLogger.warn).toHaveBeenCalledWith("plugin warning"); expect(log.info).not.toHaveBeenCalled(); expect(log.warn).not.toHaveBeenCalled(); }); test("can suppress provisional plugin info logs while preserving warnings", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayPluginsForTest({ suppressPluginInfoLogs: true, }); const logger = getLastPluginLoadLogger(); logger.info("plugin ready"); logger.warn("plugin warning"); expect(pluginRuntimeLoaderLogger.info).not.toHaveBeenCalled(); expect(pluginRuntimeLoaderLogger.warn).toHaveBeenCalledWith("plugin warning"); }); test("reuses the provided startup plugin scope without recomputing it", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayPluginsForTest({ pluginIds: ["browser"], }); expect(loadPluginLookUpTable).not.toHaveBeenCalled(); expect(getLastPluginLoadOption("onlyPluginIds")).toEqual(["browser"]); }); test("reuses a provided lookup table for startup scope and auto-enable manifests", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); const manifestRegistry = { plugins: [], diagnostics: [] }; const installRecords = { telegram: { source: "npm" as const, spec: "@openclaw/telegram@1.0.0", installPath: "/tmp/plugins/telegram", }, }; loadGatewayPluginsForTest({ pluginLookUpTable: createLookUpTableForTest({ installRecords, manifestRegistry, pluginIds: ["telegram"], }), }); expect(loadPluginLookUpTable).not.toHaveBeenCalled(); expect(applyPluginAutoEnable).toHaveBeenCalledWith({ config: {}, env: process.env, manifestRegistry, }); expect(getLastPluginLoadOption("manifestRegistry")).toBe(manifestRegistry); expect(getLastPluginLoadOption("installRecords")).toEqual(installRecords); expect(getLastPluginLoadOption("onlyPluginIds")).toEqual(["telegram"]); }); test("keeps the raw activation source when a precomputed startup scope is reused", () => { const rawConfig = { channels: { slack: { botToken: "x" } } }; const resolvedConfig = { channels: { slack: { botToken: "x", enabled: true } }, autoEnabled: true, }; applyPluginAutoEnable.mockReturnValue({ config: resolvedConfig, changes: [], autoEnabledReasons: { slack: ["slack configured"], }, }); loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayStartupPluginsForTest({ cfg: resolvedConfig, activationSourceConfig: rawConfig, pluginIds: ["slack"], }); expect(loadPluginLookUpTable).not.toHaveBeenCalled(); expect(applyPluginAutoEnable).toHaveBeenCalledWith({ config: rawConfig, env: process.env, }); expect(getLastPluginLoadOption("config")).toStrictEqual(resolvedConfig); expect(getLastPluginLoadOption("activationSourceConfig")).toStrictEqual(rawConfig); expect(getLastPluginLoadOption("onlyPluginIds")).toEqual(["slack"]); expect(getLastPluginLoadOption("autoEnabledReasons")).toEqual({ slack: ["slack configured"], }); }); test("passes durable worker activation reasons to the runtime plugin load", () => { applyPluginAutoEnable.mockReturnValue({ config: {}, changes: [], autoEnabledReasons: { "qa-lab": ["static-ssh worker provider selected"] }, }); loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayStartupPluginsForTest({ pluginIds: ["qa-lab"], pluginLookUpTable: createLookUpTableForTest({ manifestRegistry: { plugins: [ { id: "qa-lab", origin: "bundled", channels: [], providers: [], cliBackends: [], skills: [], hooks: [], rootDir: "/tmp/qa-lab", source: "/tmp/qa-lab/index.js", manifestPath: "/tmp/qa-lab/openclaw.plugin.json", contracts: { workerProviders: ["static-ssh"] }, }, ], diagnostics: [], }, workerProviderIds: ["static-ssh"], }), }); expect(getLastPluginLoadOption("autoEnabledReasons")).toEqual({ "qa-lab": ["static-ssh durable worker lease"], }); }); test("preserves runtime defaults while applying source activation to startup loads", () => { const rawConfig = { channels: { telegram: { botToken: "token", }, }, plugins: { allow: ["bench-plugin"], }, }; const runtimeConfig = { channels: { telegram: { botToken: "token", dmPolicy: "pairing" as const, groupPolicy: "allowlist" as const, }, }, plugins: { allow: ["bench-plugin", "memory-core"], entries: { "bench-plugin": { config: { runtimeDefault: true, }, }, "memory-core": { config: { dreaming: { enabled: false, }, }, }, }, }, }; const activationConfig = { channels: { telegram: { botToken: "token", enabled: true, }, }, plugins: { allow: ["bench-plugin"], entries: { "bench-plugin": { enabled: true, }, }, }, }; applyPluginAutoEnable.mockReturnValue({ config: activationConfig, changes: [], autoEnabledReasons: { telegram: ["telegram configured"], }, }); loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayStartupPluginsForTest({ cfg: runtimeConfig, activationSourceConfig: rawConfig, pluginIds: ["telegram"], }); const config = requireRecord(getLastPluginLoadOption("config"), "plugin load config"); const channels = readRecordField(config, "channels", "plugin load channels"); const telegram = readRecordField(channels, "telegram", "telegram channel config"); expect(telegram.enabled).toBe(true); expect(telegram.dmPolicy).toBe("pairing"); expect(telegram.groupPolicy).toBe("allowlist"); const plugins = readRecordField(config, "plugins", "plugin load plugins config"); expect(plugins.allow).toEqual(["bench-plugin"]); const entries = readRecordField(plugins, "entries", "plugin load entries"); const benchPlugin = readRecordField(entries, "bench-plugin", "bench plugin entry"); expect(benchPlugin.enabled).toBe(true); expect(benchPlugin.config).toEqual({ runtimeDefault: true, }); expect(entries["memory-core"]).toEqual({ config: { dreaming: { enabled: false, }, }, }); expect(getLastPluginLoadOption("activationSourceConfig")).toStrictEqual(rawConfig); expect(getLastPluginLoadOption("autoEnabledReasons")).toEqual({ telegram: ["telegram configured"], }); }); test("treats an empty startup scope as no plugin load instead of an unscoped load", () => { loadPluginLookUpTable.mockReturnValue({ startup: { pluginIds: [], }, }); const result = serverPluginsModule.loadGatewayPlugins({ cfg: {}, workspaceDir: "/tmp", log: createTestLog(), coreGatewayHandlers: {}, baseMethods: ["sessions.get"], }); expect(loadOpenClawPlugins).not.toHaveBeenCalled(); expect(result.pluginRegistry.plugins).toStrictEqual([]); expect(result.gatewayMethods).toEqual(["sessions.get"]); }); test("activates the empty registry in the global hook runner", () => { const previous = addLoadedPlugin(createRegistry([]), { id: "previous-plugin" }); runtimeRegistryModule.setActivePluginRegistry(previous); initializeGlobalHookRunner(previous); loadPluginLookUpTable.mockReturnValue({ startup: { pluginIds: [] } }); const result = serverPluginsModule.loadGatewayPlugins({ cfg: {}, workspaceDir: "/tmp", log: createTestLog(), coreGatewayHandlers: {}, baseMethods: [], }); expect(getGlobalPluginRegistry()).toBe(result.pluginRegistry); expect(result.pluginRegistry.plugins).toStrictEqual([]); }); test("stores workspaceDir on the active registry when startup scope is empty", () => { loadPluginLookUpTable.mockReturnValue({ startup: { pluginIds: [], }, }); serverPluginsModule.loadGatewayPlugins({ cfg: {}, workspaceDir: "/tmp/gateway-workspace", log: createTestLog(), coreGatewayHandlers: {}, baseMethods: [], }); expect(getActivePluginRegistryWorkspaceDirFromState()).toBe("/tmp/gateway-workspace"); }); test("loads gateway plugins from the auto-enabled config snapshot", () => { const autoEnabledConfig = { channels: { slack: { enabled: true } }, autoEnabled: true }; applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [], autoEnabledReasons: { slack: ["slack configured"], }, }); loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayPluginsForTest(); expect(loadPluginLookUpTable).toHaveBeenCalledWith({ config: autoEnabledConfig, activationSourceConfig: undefined, workspaceDir: "/tmp", env: process.env, }); expect(getLastPluginLoadOption("config")).toStrictEqual(autoEnabledConfig); expect(getLastPluginLoadOption("activationSourceConfig")).toEqual({}); expect(getLastPluginLoadOption("autoEnabledReasons")).toEqual({ slack: ["slack configured"], }); }); test("re-derives auto-enable reasons when only activationSourceConfig is provided", () => { const rawConfig = { channels: { slack: { enabled: true } } }; const resolvedConfig = { channels: { slack: { enabled: true } }, autoEnabled: true }; applyPluginAutoEnable.mockReturnValue({ config: resolvedConfig, changes: [], autoEnabledReasons: { slack: ["slack configured"], }, }); loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayPluginsForTest({ cfg: resolvedConfig, activationSourceConfig: rawConfig, }); expect(applyPluginAutoEnable).toHaveBeenCalledWith({ config: rawConfig, env: process.env, }); expect(loadPluginLookUpTable).toHaveBeenCalledWith({ config: resolvedConfig, activationSourceConfig: rawConfig, workspaceDir: "/tmp", env: process.env, }); expect(getLastPluginLoadOption("config")).toStrictEqual(resolvedConfig); expect(getLastPluginLoadOption("activationSourceConfig")).toStrictEqual(rawConfig); expect(getLastPluginLoadOption("autoEnabledReasons")).toEqual({ slack: ["slack configured"], }); }); test("provides subagent runtime session messages through sessions.get", async () => { const runtime = await createSubagentRuntime(serverPluginsModule); serverPluginsModule.setFallbackGatewayContext(createTestContext("sessions-get-aliases")); handleGatewayRequest .mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { expect(opts.req.method).toBe("sessions.get"); expect(opts.req.params).toEqual({ key: "s-read" }); opts.respond(true, { messages: [{ id: "m-1" }] }); }) .mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { expect(opts.req.method).toBe("sessions.get"); expect(opts.req.params).toEqual({ key: "s-limited", limit: 1_000 }); opts.respond(true, { messages: [{ id: "m-3" }] }); }); await expect(runtime.getSessionMessages({ sessionKey: "s-read" })).resolves.toEqual({ messages: [{ id: "m-1" }], }); await expect( runtime.getSessionMessages({ sessionKey: "s-limited", limit: 9e15, }), ).resolves.toEqual({ messages: [{ id: "m-3" }], }); }); test("times out while waiting for the first in-process gateway response", async () => { serverPluginsModule.setFallbackGatewayContext(createTestContext("initial-response-timeout")); handleGatewayRequest.mockImplementationOnce(async () => { await new Promise(() => {}); }); await expect( serverPluginsModule.dispatchGatewayMethodInProcess( "sessions.delete", { key: "stuck-session" }, { timeoutMs: 5 }, ), ).rejects.toThrow("gateway request timeout for sessions.delete"); }); test("does not dispatch or clean up a pre-aborted in-process request", async () => { serverPluginsModule.setFallbackGatewayContext(createTestContext("pre-aborted-request")); const controller = new AbortController(); const onSignalAbort = vi.fn(); controller.abort(); await expect( serverPluginsModule.dispatchGatewayMethodInProcess( "conversations.turn", { turnId: "turn-pre-aborted" }, { signal: controller.signal, onSignalAbort }, ), ).rejects.toThrow(); expect(handleGatewayRequest).not.toHaveBeenCalled(); expect(onSignalAbort).not.toHaveBeenCalled(); }); test("runs in-process abort cleanup once without replacing the abort error", async () => { serverPluginsModule.setFallbackGatewayContext(createTestContext("aborted-request-cleanup")); const controller = new AbortController(); const onSignalAbort = vi.fn(async () => { throw new Error("cleanup failed"); }); handleGatewayRequest.mockImplementationOnce(async () => { await new Promise(() => {}); }); const result = serverPluginsModule.dispatchGatewayMethodInProcess( "conversations.turn", { turnId: "turn-aborted" }, { signal: controller.signal, onSignalAbort }, ); await vi.waitFor(() => expect(handleGatewayRequest).toHaveBeenCalledTimes(1)); controller.abort(new Error("primary aborted")); await expect(result).rejects.toThrow("primary aborted"); expect(onSignalAbort).toHaveBeenCalledTimes(1); }); test("returns an accepted in-process response without waiting for handler completion", async () => { serverPluginsModule.setFallbackGatewayContext(createTestContext("accepted-before-complete")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { opts.respond(true, { status: "accepted", runId: "run-accepted" }); await new Promise(() => {}); }); await expect( serverPluginsModule.dispatchGatewayMethodInProcess( "agent", { sessionKey: "s-accepted" }, { timeoutMs: 5 }, ), ).resolves.toEqual({ status: "accepted", runId: "run-accepted" }); }); test("marks synthetic cron continuation calls as server-owned", async () => { serverPluginsModule.setFallbackGatewayContext(createTestContext("cron-run-continuation")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { expect(opts.client?.connect.client.mode).toBe("backend"); expect(opts.client?.internal?.cronRunContinuation).toBe(true); opts.respond(true, { status: "ok" }); }); await expect( serverPluginsModule.dispatchGatewayMethodInProcess( "agent", { sessionKey: "agent:main:cron:job:run:run-1" }, { allowSyntheticCronRunContinuation: true, forceSyntheticClient: true }, ), ).resolves.toEqual({ status: "ok" }); }); test("carries delegated tool-policy handoffs only in synthetic client context", async () => { serverPluginsModule.setFallbackGatewayContext(createTestContext("delegated-tool-policy")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { expect(opts.req.params).not.toHaveProperty("delegatedToolPolicyHandoff"); expect(opts.client?.internal?.delegatedToolPolicyHandoffId).toEqual(expect.any(String)); opts.respond(true, { status: "ok" }); }); await expect( serverPluginsModule.dispatchGatewayMethodInProcess( "agent", { sessionKey: "agent:main:main" }, { delegatedToolPolicyHandoff: { sourceSessionKey: "agent:main:subagent:child", targetSessionKey: "agent:main:main", targetSessionId: "requester-session", idempotencyKey: "announce-1", }, forceSyntheticClient: true, }, ), ).resolves.toEqual({ status: "ok" }); }); test("carries scoped delivery media only in the synthetic client context", async () => { serverPluginsModule.setFallbackGatewayContext(createTestContext("scoped-delivery-media")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { expect(opts.req.params).not.toHaveProperty("internalDeliveryMediaUrls"); expect(opts.req.params).not.toHaveProperty("internalDeliverySuppressText"); expect(opts.client?.internal?.internalDeliveryMediaUrls).toEqual(["/tmp/proof.png"]); expect(opts.client?.internal?.internalDeliverySuppressText).toBe(true); opts.respond(true, { status: "ok" }); }); await expect( serverPluginsModule.dispatchGatewayMethodInProcess( "agent", { sessionKey: "agent:main:main" }, { forceSyntheticClient: true, internalDeliveryMediaUrls: ["/tmp/proof.png"], internalDeliverySuppressText: true, }, ), ).resolves.toEqual({ status: "ok" }); }); test("uses one timeout budget across accepted and final in-process responses", async () => { vi.useFakeTimers(); try { serverPluginsModule.setFallbackGatewayContext(createTestContext("single-final-deadline")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { setTimeout(() => { opts.respond(true, { status: "accepted", runId: "run-deadline" }); }, 7); setTimeout(() => { opts.respond(true, { status: "ok", runId: "run-deadline" }); }, 13); await new Promise((resolve) => { setTimeout(resolve, 13); }); }); const result = expect( serverPluginsModule.dispatchGatewayMethodInProcess( "agent", { sessionKey: "s-deadline" }, { expectFinal: true, timeoutMs: 10 }, ), ).rejects.toThrow("gateway request timeout for agent"); await vi.advanceTimersByTimeAsync(10); await result; await vi.advanceTimersByTimeAsync(10); } finally { vi.useRealTimers(); } }); test("reports accepted in-process agent requests before their final response", async () => { serverPluginsModule.setFallbackGatewayContext(createTestContext("accepted-callback")); const onAccepted = vi.fn(); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { opts.respond(true, { status: "accepted", runId: "run-callback" }); opts.respond(true, { status: "ok", runId: "run-callback" }); }); await expect( serverPluginsModule.dispatchGatewayMethodInProcess( "agent", { sessionKey: "s-callback" }, { expectFinal: true, onAccepted }, ), ).resolves.toEqual({ status: "ok", runId: "run-callback" }); expect(onAccepted).toHaveBeenCalledWith({ status: "accepted", runId: "run-callback" }); }); test("clears final-response timeout when handler rejects after accepted response", async () => { vi.useFakeTimers(); try { serverPluginsModule.setFallbackGatewayContext(createTestContext("accepted-then-error")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { opts.respond(true, { status: "accepted", runId: "run-error-after-accepted" }); await new Promise((resolve) => { setTimeout(resolve, 5); }); throw new Error("handler failed after accepted"); }); const result = expect( serverPluginsModule.dispatchGatewayMethodInProcess( "agent", { sessionKey: "s-error-after-accepted" }, { expectFinal: true, timeoutMs: 1_000 }, ), ).rejects.toThrow("handler failed after accepted"); await vi.advanceTimersByTimeAsync(5); await result; expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); } }); test("filters connected plugin nodes locally without sending unsupported node.list params", async () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext(createTestContext("nodes-list-filter")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { expect(opts.req.method).toBe("node.list"); opts.respond(true, { nodes: [ { nodeId: "connected", connected: true, gatewayLocal: true }, { nodeId: "offline", connected: false }, ], }); }); const runtime = createRuntimeFromLastGatewayLoad(); const result = await runtime.nodes.list({ connected: true }); expect(getLastDispatchedParams()).toStrictEqual({}); expect(result.nodes).toEqual([{ nodeId: "connected", connected: true, gatewayLocal: true }]); }); test("projects effective node-command policy into the plugin node runtime", async () => { const command = "agent.cli.claude.run.v1"; loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext({ getRuntimeConfig: () => ({ gateway: { nodes: { commands: { deny: [command] } } } }), nodeRegistry: { get: () => ({ nodeId: "node-policy", connId: "conn-policy", platform: "linux", commands: [command], }), }, } as unknown as GatewayRequestContext); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { opts.respond(true, { nodes: [{ nodeId: "node-policy", connected: true, commands: [command] }], }); }); const runtime = createRuntimeFromLastGatewayLoad(); const result = await runtime.nodes.list({ connected: true }); expect(result.nodes[0]?.commands).toEqual([command]); expect(result.nodes[0]?.invocableCommands).toEqual([]); }); test("lets trusted official plugin runtime request admin scope for browser proxy", async () => { loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" })); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext(createTestContext("nodes-invoke-browser-proxy")); const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "google-meet", pluginOrigin: "bundled" }, () => runtime.nodes.invoke({ nodeId: "node-1", command: "browser.proxy", params: { method: "GET", path: "/profiles" }, scopes: ["operator.admin"], }), ); expect(getLastDispatchedParams()).toMatchObject({ nodeId: "node-1", command: "browser.proxy", params: { method: "GET", path: "/profiles" }, }); expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet"); }); test("honors trusted plugin node scopes inside a narrower Gateway request", async () => { loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "opencode" })); loadGatewayStartupPluginsForTest(); const scope = { context: createTestContext("nodes-invoke-read-caller"), client: { connect: { scopes: ["operator.read"] }, } as GatewayRequestOptions["client"], isWebchatConnect: () => false, } satisfies PluginRuntimeGatewayRequestScope; const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "opencode", pluginOrigin: "bundled" }, () => runtime.nodes.invoke({ nodeId: "node-1", command: "opencode.sessions.list.v1", scopes: ["operator.write"], }), ), ); expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("opencode"); }); test("dispatches gateway methods with the trusted plugin identity", async () => { loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" })); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-request")); const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "google-meet", pluginOrigin: "bundled" }, () => runtime.gateway.request("voicecall.start", { to: "+15550001234" }), ); expect(getLastDispatchedParams()).toEqual({ to: "+15550001234" }); expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet"); }); test("lets trusted official plugins request explicit Gateway scopes", async () => { loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" })); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-admin")); const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "google-meet", pluginOrigin: "bundled" }, () => runtime.gateway.request( "browser.request", { method: "GET", path: "/tabs" }, { scopes: ["operator.admin"] }, ), ); expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet"); }); test("reports whether trusted in-process Gateway dispatch is available", async () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayStartupPluginsForTest(); const runtime = createRuntimeFromLastGatewayLoad(); expect(await runtime.gateway.isAvailable()).toBe(false); serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-available")); expect(await runtime.gateway.isAvailable()).toBe(true); }); test("does not inherit admin scope for trusted plugin gateway requests", async () => { loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" })); loadGatewayStartupPluginsForTest(); const scope = { context: createTestContext("plugin-gateway-request-admin-caller"), client: { connect: { scopes: ["operator.admin"], }, } as GatewayRequestOptions["client"], isWebchatConnect: () => false, } satisfies PluginRuntimeGatewayRequestScope; const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "google-meet", pluginOrigin: "bundled" }, () => runtime.gateway.request("voicecall.start", { to: "+15550001234" }), ), ); expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet"); }); test("preserves structured errors from trusted plugin gateway requests", async () => { loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" })); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-error")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { opts.respond(false, undefined, { code: "INVALID_REQUEST", message: "browser login required", details: { manualActionRequired: true, reason: "not-authenticated" }, }); }); const runtime = createRuntimeFromLastGatewayLoad(); const request = gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "google-meet", pluginOrigin: "bundled" }, () => runtime.gateway.request("googlemeet.join", { url: "https://meet.google.com/abc" }), ); await expect(request).rejects.toMatchObject({ name: "GatewayClientRequestError", gatewayCode: "INVALID_REQUEST", details: { manualActionRequired: true, reason: "not-authenticated" }, }); }); test("rejects gateway dispatch from arbitrary plugins", async () => { loadOpenClawPlugins.mockReturnValue( addLoadedPlugin(createRegistry([]), { id: "third-party", origin: "global" }), ); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-rejected")); const runtime = createRuntimeFromLastGatewayLoad(); await expect( gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "third-party", pluginOrigin: "global" }, () => runtime.gateway.request( "voicecall.start", { to: "+15550001234" }, { scopes: ["operator.admin"] }, ), ), ).rejects.toThrow("bundled or trusted official plugins"); expect(handleGatewayRequest).not.toHaveBeenCalled(); }); test("does not let arbitrary plugin nodes runtime mint admin scope for browser proxy", async () => { loadOpenClawPlugins.mockReturnValue( addLoadedPlugin(createRegistry([]), { id: "third-party", origin: "global" }), ); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext( createTestContext("nodes-invoke-browser-proxy-no-elevate"), ); const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "third-party", pluginOrigin: "global" }, () => runtime.nodes.invoke({ nodeId: "node-1", command: "browser.proxy", params: { method: "GET", path: "/profiles" }, scopes: ["operator.admin"], }), ); expect(getLastDispatchedParams()).toMatchObject({ nodeId: "node-1", command: "browser.proxy", params: { method: "GET", path: "/profiles" }, }); expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("third-party"); }); test("forwards provider and model overrides when the request scope is authorized", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); const scope = { context: createTestContext("request-scope-forward-overrides"), client: { connect: { scopes: ["operator.admin"], }, } as GatewayRequestOptions["client"], isWebchatConnect: () => false, } satisfies PluginRuntimeGatewayRequestScope; await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => runtime.run({ sessionKey: "s-override", message: "use the override", provider: "anthropic", model: "claude-haiku-4-5", deliver: false, }), ); const params = getRequiredLastDispatchedParams(); expect(params.sessionKey).toBe("s-override"); expect(params.message).toBe("use the override"); expect(params.provider).toBe("anthropic"); expect(params.model).toBe("claude-haiku-4-5"); expect(params.deliver).toBe(false); }); test("returns resolved runtime metadata from plugin-owned subagent starts", async () => { const runtime = await createSubagentRuntime(serverPluginsModule); serverPluginsModule.setFallbackGatewayContext(createTestContext("resolved-subagent-runtime")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { expect(opts.req.method).toBe("agent"); opts.respond(true, { runId: "run-claude", sessionKey: "agent:worker:s-runtime", runtime: { harness: "claude-cli", provider: "anthropic", model: "claude-sonnet-4-6", }, }); }); await expect( runtime.run({ sessionKey: "s-runtime", message: "use configured runtime", }), ).resolves.toEqual({ runId: "run-claude", sessionKey: "agent:worker:s-runtime", runtime: { harness: "claude-cli", provider: "anthropic", model: "claude-sonnet-4-6", }, }); }); test("forwards caller-supplied idempotencyKey on subagent run", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("idempotency-forward")); await runtime.run({ sessionKey: "s-idem-forward", message: "hello", deliver: false, idempotencyKey: "caller-provided-key", }); const params = getRequiredLastDispatchedParams(); expect(params.sessionKey).toBe("s-idem-forward"); expect(params.message).toBe("hello"); expect(params.idempotencyKey).toBe("caller-provided-key"); }); test("forwards cwd on plugin-owned subagent runs", async () => { const runtime = await createSubagentRuntime(serverPluginsModule); serverPluginsModule.setFallbackGatewayContext(createTestContext("cwd-forward")); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "workboard", pluginOrigin: "bundled" }, () => runtime.run({ sessionKey: "s-cwd-forward", message: "hello", cwd: "/tmp/managed-worktree", }), ); expect(getRequiredLastDispatchedParams().cwd).toBe("/tmp/managed-worktree"); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("workboard"); }); test("forwards exact plugin-owned additive tools through internal run metadata", async () => { const runtime = await createSubagentRuntime(serverPluginsModule); serverPluginsModule.setFallbackGatewayContext(createTestContext("tools-also-allow")); registerActivePluginToolOwnership("workboard", [ "workboard_heartbeat", "workboard_complete", "workboard_block", ]); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "workboard", pluginOrigin: "bundled" }, () => runtime.run({ sessionKey: "s-tools-also-allow", message: "finish the card", toolsAlsoAllow: ["workboard_heartbeat", " workboard_complete ", "workboard_heartbeat"], }), ); expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toEqual({ pluginId: "workboard", toolNames: ["workboard_heartbeat", "workboard_complete"], }); expect(getRequiredLastDispatchedParams()).not.toHaveProperty("toolsAlsoAllow"); }); test("rejects additive subagent tools not registered by the calling plugin", async () => { const runtime = await createSubagentRuntime(serverPluginsModule); serverPluginsModule.setFallbackGatewayContext(createTestContext("foreign-tools-also-allow")); registerActivePluginToolOwnership("workboard", ["workboard_complete"]); registerActivePluginToolOwnership("other-plugin", ["other_plugin_tool"]); await expect( gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "workboard", pluginOrigin: "bundled" }, () => runtime.run({ sessionKey: "s-foreign-tools-also-allow", message: "finish the card", toolsAlsoAllow: ["other_plugin_tool"], }), ), ).rejects.toThrow('plugin "workboard" does not uniquely own subagent tool "other_plugin_tool"'); expect(handleGatewayRequest).not.toHaveBeenCalled(); }); test("accepts additive tools declared by an unnamed plugin factory", async () => { const runtime = await createSubagentRuntime(serverPluginsModule); serverPluginsModule.setFallbackGatewayContext(createTestContext("declared-tools-also-allow")); registerActivePluginToolOwnership("workboard", [], ["workboard_complete"]); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "workboard", pluginOrigin: "bundled" }, () => runtime.run({ sessionKey: "s-declared-tools-also-allow", message: "finish the card", toolsAlsoAllow: ["workboard_complete"], }), ); expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toEqual({ pluginId: "workboard", toolNames: ["workboard_complete"], }); }); test("rejects core and ambiguously-owned additive tool names", async () => { const runtime = await createSubagentRuntime(serverPluginsModule); serverPluginsModule.setFallbackGatewayContext(createTestContext("colliding-tools-also-allow")); registerActivePluginToolOwnership("workboard", ["exec", "workboard_complete"]); registerActivePluginToolOwnership("other-plugin", ["workboard_complete"]); await expect( gatewayRequestScopeModule.withPluginRuntimePluginIdScope("workboard", () => runtime.run({ sessionKey: "s-core-tools-also-allow", message: "run a command", toolsAlsoAllow: ["exec"], }), ), ).rejects.toThrow('plugin "workboard" may not add core tool "exec" to subagent runs'); await expect( gatewayRequestScopeModule.withPluginRuntimePluginIdScope("workboard", () => runtime.run({ sessionKey: "s-ambiguous-tools-also-allow", message: "finish the card", toolsAlsoAllow: ["workboard_complete"], }), ), ).rejects.toThrow( 'plugin "workboard" does not uniquely own subagent tool "workboard_complete"', ); expect(handleGatewayRequest).not.toHaveBeenCalled(); }); test("clears inherited additive grants when a scoped plugin run requests none", async () => { const runtime = await createSubagentRuntime(serverPluginsModule); const scope = { context: createTestContext("clear-tools-also-allow"), client: { connect: { scopes: ["operator.write"] }, internal: { agentRunTracking: "plugin_subagent", pluginRuntimeOwnerId: "other-plugin", runtimePluginToolGrant: { pluginId: "other-plugin", toolNames: ["other_plugin_tool"], }, delegatedToolPolicyHandoffId: "handoff-old", }, } as unknown as GatewayRequestOptions["client"], isWebchatConnect: () => false, pluginId: "workboard", pluginOrigin: "bundled" as const, } satisfies PluginRuntimeGatewayRequestScope; await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => runtime.run({ sessionKey: "s-clear-tools-also-allow", message: "do normal work", }), ); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("workboard"); expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toBeUndefined(); expect(getLastDispatchedClientInternal().delegatedToolPolicyHandoffId).toBeUndefined(); }); test("forwards lightContext as lightweight bootstrap context on subagent run", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("light-context-forward")); await runtime.run({ sessionKey: "s-light-context", message: "hello", lightContext: true, lane: "dreaming-narrative:s-light-context", deliver: false, }); const params = getRequiredLastDispatchedParams(); expect(params.sessionKey).toBe("s-light-context"); expect(params.message).toBe("hello"); expect(params.lane).toBe("dreaming-narrative:s-light-context"); expect(params.bootstrapContextMode).toBe("lightweight"); expect(params.deliver).toBe(false); }); test("generates a non-empty idempotencyKey when the caller omits it", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("idempotency-generate")); await runtime.run({ sessionKey: "s-idem-generate", message: "hello", deliver: false, }); const params = getLastDispatchedParams(); if (params === undefined) { throw new Error("expected dispatched agent params"); } // The gateway `agent` schema requires `idempotencyKey: NonEmptyString`, so // the runtime must always send a populated value. A missing field here // would reproduce the memory-core dreaming-narrative regression. const generated = params.idempotencyKey; expect(typeof generated).toBe("string"); expect((generated as string).length).toBeGreaterThan(0); }); test("rejects provider/model overrides for fallback runs without explicit authorization", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("fallback-deny-overrides")); await expect( runtime.run({ sessionKey: "s-fallback-override", message: "use the override", provider: "anthropic", model: "claude-haiku-4-5", deliver: false, }), ).rejects.toThrow( "provider/model override requires plugin identity in fallback subagent runs.", ); }); test("allows trusted fallback provider/model overrides when plugin config is explicit", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins, { plugins: { entries: { "voice-call": { subagent: { allowModelOverride: true, allowedModels: ["anthropic/claude-haiku-4-5"], }, }, }, }, }); expect(normalizeProviderModelIdWithRuntime).not.toHaveBeenCalled(); serverPlugins.setFallbackGatewayContext(createTestContext("fallback-trusted-overrides")); await gatewayRequestScopeModule.withPluginRuntimePluginIdScope("voice-call", () => runtime.run({ sessionKey: "s-trusted-override", message: "use trusted override", provider: "anthropic", model: "claude-haiku-4-5", deliver: false, }), ); const params = getRequiredLastDispatchedParams(); expect(params.sessionKey).toBe("s-trusted-override"); expect(params.provider).toBe("anthropic"); expect(params.model).toBe("claude-haiku-4-5"); expect(normalizeProviderModelIdWithRuntime).toHaveBeenCalledOnce(); }); test("keeps fallback model policy bound to the runtime that loaded it", async () => { const allowedRuntime = await createSubagentRuntime(serverPluginsModule, { plugins: { entries: { "voice-call": { subagent: { allowModelOverride: true, allowedModels: ["anthropic/claude-haiku-4-5"], }, }, }, }, }); const deniedRuntime = await createSubagentRuntime(serverPluginsModule); serverPluginsModule.setFallbackGatewayContext(createTestContext("fallback-policy-binding")); const run = (runtime: PluginRuntime["subagent"]) => gatewayRequestScopeModule.withPluginRuntimePluginIdScope("voice-call", () => runtime.run({ sessionKey: "s-policy-binding", message: "use trusted override", provider: "anthropic", model: "claude-haiku-4-5", deliver: false, }), ); await expect(run(allowedRuntime)).resolves.toMatchObject({ runId: expect.any(String) }); await expect(run(deniedRuntime)).rejects.toThrow( 'plugin "voice-call" is not trusted for fallback provider/model override requests.', ); }); test("tags plugin fallback subagent runs with the creating plugin id", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("fallback-plugin-owner")); await gatewayRequestScopeModule.withPluginRuntimePluginIdScope("memory-core", () => runtime.run({ sessionKey: "dreaming-narrative-light-workspace-1", message: "write a narrative", deliver: false, }), ); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("memory-core"); }); test("includes docs guidance when a plugin fallback override is not trusted", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("fallback-untrusted-plugin")); await expect( gatewayRequestScopeModule.withPluginRuntimePluginIdScope("voice-call", () => runtime.run({ sessionKey: "s-untrusted-override", message: "use untrusted override", provider: "anthropic", model: "claude-haiku-4-5", deliver: false, }), ), ).rejects.toThrow( 'plugin "voice-call" is not trusted for fallback provider/model override requests. See https://docs.openclaw.ai/plugins/sdk-runtime#api-runtime-subagent and search for: plugins.entries..subagent.allowModelOverride', ); }); test("allows trusted fallback model-only overrides when the model ref is canonical", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins, { plugins: { entries: { "voice-call": { subagent: { allowModelOverride: true, allowedModels: ["anthropic/claude-haiku-4-5"], }, }, }, }, }); serverPlugins.setFallbackGatewayContext(createTestContext("fallback-model-only-override")); await gatewayRequestScopeModule.withPluginRuntimePluginIdScope("voice-call", () => runtime.run({ sessionKey: "s-model-only-override", message: "use trusted model-only override", model: "anthropic/claude-haiku-4-5", deliver: false, }), ); const params = getRequiredLastDispatchedParams(); expect(params.sessionKey).toBe("s-model-only-override"); expect(params.model).toBe("anthropic/claude-haiku-4-5"); expect(getLastDispatchedParams()).not.toHaveProperty("provider"); }); test("rejects trusted fallback overrides when the configured allowlist normalizes to empty", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins, { plugins: { entries: { "voice-call": { subagent: { allowModelOverride: true, allowedModels: ["anthropic"], }, }, }, }, }); serverPlugins.setFallbackGatewayContext(createTestContext("fallback-invalid-allowlist")); await expect( gatewayRequestScopeModule.withPluginRuntimePluginIdScope("voice-call", () => runtime.run({ sessionKey: "s-invalid-allowlist", message: "use trusted override", provider: "anthropic", model: "claude-haiku-4-5", deliver: false, }), ), ).rejects.toThrow( 'plugin "voice-call" configured subagent.allowedModels, but none of the entries normalized to a valid provider/model target.', ); }); test("uses least-privilege synthetic fallback scopes without admin", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-least-privilege")); await runtime.run({ sessionKey: "s-synthetic", message: "run synthetic", deliver: false, }); expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); }); test("allows fallback session reads with synthetic write scope", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-session-read")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { const scopes = Array.isArray(opts.client?.connect?.scopes) ? opts.client.connect.scopes : []; const auth = methodScopesModule.authorizeOperatorScopesForMethod("sessions.get", scopes); if (!auth.allowed) { opts.respond(false, undefined, { code: "INVALID_REQUEST", message: `missing scope: ${auth.missingScope}`, }); return; } opts.respond(true, { messages: [{ id: "m-1" }] }); }); await expect( runtime.getSessionMessages({ sessionKey: "s-read", }), ).resolves.toEqual({ messages: [{ id: "m-1" }], }); expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); }); test("rejects fallback session deletion without minting admin scope", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-delete-session")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { // Re-run the gateway scope check here so the test proves fallback dispatch // does not smuggle admin into the request client. const scopes = Array.isArray(opts.client?.connect?.scopes) ? opts.client.connect.scopes : []; const auth = methodScopesModule.authorizeOperatorScopesForMethod("sessions.delete", scopes); if (!auth.allowed) { opts.respond(false, undefined, { code: "INVALID_REQUEST", message: `missing scope: ${auth.missingScope}`, }); return; } opts.respond(true, {}); }); await expect( runtime.deleteSession({ sessionKey: "s-delete", deleteTranscript: true, }), ).rejects.toThrow("missing scope: operator.admin"); expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); }); test("uses owner-scoped synthetic admin for plugin-created session cleanup", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("fallback-plugin-delete-session")); handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { const scopes = Array.isArray(opts.client?.connect?.scopes) ? opts.client.connect.scopes : []; const auth = methodScopesModule.authorizeOperatorScopesForMethod("sessions.delete", scopes); if (!auth.allowed) { opts.respond(false, undefined, { code: "INVALID_REQUEST", message: `missing scope: ${auth.missingScope}`, }); return; } opts.respond(true, {}); }); await expect( gatewayRequestScopeModule.withPluginRuntimePluginIdScope("memory-core", () => runtime.deleteSession({ sessionKey: "dreaming-narrative-light-workspace-1", deleteTranscript: true, }), ), ).resolves.toBeUndefined(); expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("memory-core"); }); test("allows session deletion when the request scope already has admin", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); const scope = { context: createTestContext("request-scope-delete-session"), client: { connect: { scopes: ["operator.admin"], }, } as GatewayRequestOptions["client"], isWebchatConnect: () => false, } satisfies PluginRuntimeGatewayRequestScope; await expect( gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => runtime.deleteSession({ sessionKey: "s-delete-admin", deleteTranscript: true, }), ), ).resolves.toBeUndefined(); expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); }); test("keeps plugin owner metadata on admin-scoped plugin session cleanup", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); const scope = { context: createTestContext("request-scope-plugin-delete-session"), client: { connect: { scopes: ["operator.admin"], }, } as GatewayRequestOptions["client"], isWebchatConnect: () => false, } satisfies PluginRuntimeGatewayRequestScope; await expect( gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => gatewayRequestScopeModule.withPluginRuntimePluginIdScope("memory-core", () => runtime.deleteSession({ sessionKey: "dreaming-narrative-light-workspace-1", deleteTranscript: true, }), ), ), ).resolves.toBeUndefined(); expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("memory-core"); }); test("can select setup-runtime channel plugins for setup flows", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayPluginsForTest({ channelPluginLoadIntent: "setup", }); expect(getLastPluginLoadOption("channelPluginLoadIntent")).toBe("setup"); }); test("primes configured bindings during gateway startup", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); const cfg = {}; const autoEnabledConfig = { channels: { slack: { enabled: true } }, autoEnabled: true }; applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [], autoEnabledReasons: { slack: ["slack configured"], }, }); loadGatewayStartupPluginsForTest({ cfg }); expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg: autoEnabledConfig }); }); test("uses the auto-enabled config snapshot for gateway bootstrap policies", async () => { const serverPlugins = serverPluginsModule; const autoEnabledConfig = { plugins: { entries: { demo: { subagent: { allowModelOverride: true, allowedModels: ["openai/gpt-5.4"] }, }, }, }, }; applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [], autoEnabledReasons: {}, }); const runtime = await createSubagentRuntime(serverPlugins, {}); serverPlugins.setFallbackGatewayContext(createTestContext("auto-enabled-bootstrap-policy")); await gatewayRequestScopeModule.withPluginRuntimePluginIdScope("demo", () => runtime.run({ sessionKey: "s-auto-enabled-bootstrap-policy", message: "use trusted override", model: "openai/gpt-5.4", deliver: false, }), ); const params = getRequiredLastDispatchedParams(); expect(params.sessionKey).toBe("s-auto-enabled-bootstrap-policy"); expect(params.model).toBe("openai/gpt-5.4"); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */