mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 18:35:21 -06:00
549e6349f8
* fix(workboard): make ownership updates atomic Persist launch intent before worker acceptance, reconcile accepted identities, fence cross-host card mutations, and deduplicate session capture in SQLite. Closes #125932 * test(workboard): keep race fixtures plugin-local Use a plugin-local synchronization helper and update the sessions-page capture expectation for the new store-owned RPC. * fix(workboard): fence lifecycle and restore races Apply terminal lifecycle state only while the matched association is current, and converge concurrent archived session restores on the active winner.
2160 lines
77 KiB
TypeScript
2160 lines
77 KiB
TypeScript
// 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<string, unknown>;
|
|
};
|
|
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<string, unknown>,
|
|
dispatchOptions?: {
|
|
expectFinal?: boolean;
|
|
onAccepted?: (payload: unknown) => void;
|
|
onSignalAbort?: () => Promise<void> | 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<string, unknown>, 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<ReadonlyArray<unknown>> } },
|
|
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<string, unknown>, 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<string, unknown> {
|
|
return requireRecord(
|
|
getLastMockFirstArg(loadOpenClawPlugins, "plugin load"),
|
|
"plugin load options",
|
|
);
|
|
}
|
|
|
|
function getLastPluginLoadOption(key: string) {
|
|
return getLastPluginLoadOptions()[key];
|
|
}
|
|
|
|
function getLastDispatchedParams(): Record<string, unknown> | undefined {
|
|
const call = getLastMockFirstArg(handleGatewayRequest, "gateway request") as
|
|
| HandleGatewayRequestOptions
|
|
| undefined;
|
|
return call?.req?.params as Record<string, unknown> | undefined;
|
|
}
|
|
|
|
function getRequiredLastDispatchedParams(): Record<string, unknown> {
|
|
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<string, unknown> {
|
|
const call = getLastMockFirstArg(handleGatewayRequest, "gateway request") as
|
|
| HandleGatewayRequestOptions
|
|
| undefined;
|
|
return (call?.client?.internal ?? {}) as Record<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown> = {},
|
|
): Promise<PluginRuntime["subagent"]> {
|
|
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
|
loadGatewayStartupPluginsForTest({
|
|
cfg,
|
|
});
|
|
return createRuntimeFromLastGatewayLoad().subagent;
|
|
}
|
|
|
|
function createRuntimeFromLastGatewayLoad(): PluginRuntime {
|
|
const runtimeOptions = getLastPluginLoadOption("runtimeOptions") as
|
|
| Parameters<PluginRuntimeModule["createPluginRuntime"]>[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<Parameters<ServerPluginsModule["loadGatewayPlugins"]>[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<Parameters<ServerPluginBootstrapModule["loadGatewayStartupPlugins"]>[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<PluginRuntimeModule["createPluginRuntime"]>[0]
|
|
| undefined;
|
|
const boundDispatch = runtimeOptions?.dispatchReplyFromConfig;
|
|
if (!boundDispatch) {
|
|
throw new Error("Expected gateway plugin load to bind channel reply dispatch");
|
|
}
|
|
const params = {} as Parameters<typeof boundDispatch>[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.<id>.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. */
|