From 38b09866b89cf98352099d5353ba9d321fb7dea7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 01:59:08 +0000 Subject: [PATCH 01/23] test: share directory runtime helpers --- .../msteams/src/channel.directory.test.ts | 17 +++------- extensions/test-utils/directory.ts | 33 +++++++++++++++++++ extensions/zalo/src/channel.directory.test.ts | 17 +++------- 3 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 extensions/test-utils/directory.ts diff --git a/extensions/msteams/src/channel.directory.test.ts b/extensions/msteams/src/channel.directory.test.ts index 0746f78aabb..be95e6103ea 100644 --- a/extensions/msteams/src/channel.directory.test.ts +++ b/extensions/msteams/src/channel.directory.test.ts @@ -1,15 +1,10 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/msteams"; import { describe, expect, it } from "vitest"; +import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js"; import { msteamsPlugin } from "./channel.js"; describe("msteams directory", () => { - const runtimeEnv: RuntimeEnv = { - log: () => {}, - error: () => {}, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv; it("lists peers and groups from config", async () => { const cfg = { @@ -29,12 +24,10 @@ describe("msteams directory", () => { }, } as unknown as OpenClawConfig; - expect(msteamsPlugin.directory).toBeTruthy(); - expect(msteamsPlugin.directory?.listPeers).toBeTruthy(); - expect(msteamsPlugin.directory?.listGroups).toBeTruthy(); + const directory = expectDirectorySurface(msteamsPlugin.directory); await expect( - msteamsPlugin.directory!.listPeers!({ + directory.listPeers({ cfg, query: undefined, limit: undefined, @@ -50,7 +43,7 @@ describe("msteams directory", () => { ); await expect( - msteamsPlugin.directory!.listGroups!({ + directory.listGroups({ cfg, query: undefined, limit: undefined, diff --git a/extensions/test-utils/directory.ts b/extensions/test-utils/directory.ts new file mode 100644 index 00000000000..60a769f50d7 --- /dev/null +++ b/extensions/test-utils/directory.ts @@ -0,0 +1,33 @@ +export function createDirectoryTestRuntime() { + return { + log: () => {}, + error: () => {}, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; +} + +export function expectDirectorySurface( + directory: + | { + listPeers?: unknown; + listGroups?: unknown; + } + | null + | undefined, +) { + if (!directory) { + throw new Error("expected directory"); + } + if (!directory.listPeers) { + throw new Error("expected listPeers"); + } + if (!directory.listGroups) { + throw new Error("expected listGroups"); + } + return directory as { + listPeers: NonNullable; + listGroups: NonNullable; + }; +} diff --git a/extensions/zalo/src/channel.directory.test.ts b/extensions/zalo/src/channel.directory.test.ts index 99821c85017..8a303e72a97 100644 --- a/extensions/zalo/src/channel.directory.test.ts +++ b/extensions/zalo/src/channel.directory.test.ts @@ -1,15 +1,10 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it } from "vitest"; +import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js"; import { zaloPlugin } from "./channel.js"; describe("zalo directory", () => { - const runtimeEnv: RuntimeEnv = { - log: () => {}, - error: () => {}, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv; it("lists peers from allowFrom", async () => { const cfg = { @@ -20,12 +15,10 @@ describe("zalo directory", () => { }, } as unknown as OpenClawConfig; - expect(zaloPlugin.directory).toBeTruthy(); - expect(zaloPlugin.directory?.listPeers).toBeTruthy(); - expect(zaloPlugin.directory?.listGroups).toBeTruthy(); + const directory = expectDirectorySurface(zaloPlugin.directory); await expect( - zaloPlugin.directory!.listPeers!({ + directory.listPeers({ cfg, accountId: undefined, query: undefined, @@ -41,7 +34,7 @@ describe("zalo directory", () => { ); await expect( - zaloPlugin.directory!.listGroups!({ + directory.listGroups({ cfg, accountId: undefined, query: undefined, From 55ebdce9c3e055acdf385b0e5be111b60bc74339 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:00:38 +0000 Subject: [PATCH 02/23] refactor: share open allowFrom config checks --- extensions/irc/src/config-schema.ts | 13 +++++----- extensions/mattermost/src/config-schema.ts | 15 ++++++----- .../nextcloud-talk/src/config-schema.ts | 15 ++++++----- extensions/shared/config-schema-helpers.ts | 25 +++++++++++++++++++ 4 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 extensions/shared/config-schema-helpers.ts diff --git a/extensions/irc/src/config-schema.ts b/extensions/irc/src/config-schema.ts index aa37b596cd1..8b9625b5bc4 100644 --- a/extensions/irc/src/config-schema.ts +++ b/extensions/irc/src/config-schema.ts @@ -9,6 +9,7 @@ import { requireOpenAllowFrom, } from "openclaw/plugin-sdk/irc"; import { z } from "zod"; +import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; const IrcGroupSchema = z .object({ @@ -69,12 +70,12 @@ export const IrcAccountSchemaBase = z .strict(); export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => { - requireOpenAllowFrom({ + requireChannelOpenAllowFrom({ + channel: "irc", policy: value.dmPolicy, allowFrom: value.allowFrom, ctx, - path: ["allowFrom"], - message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"', + requireOpenAllowFrom, }); }); @@ -82,11 +83,11 @@ export const IrcConfigSchema = IrcAccountSchemaBase.extend({ accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(), defaultAccount: z.string().optional(), }).superRefine((value, ctx) => { - requireOpenAllowFrom({ + requireChannelOpenAllowFrom({ + channel: "irc", policy: value.dmPolicy, allowFrom: value.allowFrom, ctx, - path: ["allowFrom"], - message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"', + requireOpenAllowFrom, }); }); diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 43dd7ede8d2..16ee615454c 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -6,6 +6,7 @@ import { requireOpenAllowFrom, } from "openclaw/plugin-sdk/mattermost"; import { z } from "zod"; +import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { buildSecretInputSchema } from "./secret-input.js"; const MattermostSlashCommandsSchema = z @@ -61,13 +62,12 @@ const MattermostAccountSchemaBase = z .strict(); const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => { - requireOpenAllowFrom({ + requireChannelOpenAllowFrom({ + channel: "mattermost", policy: value.dmPolicy, allowFrom: value.allowFrom, ctx, - path: ["allowFrom"], - message: - 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"', + requireOpenAllowFrom, }); }); @@ -75,12 +75,11 @@ export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({ accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(), defaultAccount: z.string().optional(), }).superRefine((value, ctx) => { - requireOpenAllowFrom({ + requireChannelOpenAllowFrom({ + channel: "mattermost", policy: value.dmPolicy, allowFrom: value.allowFrom, ctx, - path: ["allowFrom"], - message: - 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"', + requireOpenAllowFrom, }); }); diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 5ab3e632d22..85cb14ff213 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -9,6 +9,7 @@ import { requireOpenAllowFrom, } from "openclaw/plugin-sdk/nextcloud-talk"; import { z } from "zod"; +import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { buildSecretInputSchema } from "./secret-input.js"; export const NextcloudTalkRoomSchema = z @@ -48,13 +49,12 @@ export const NextcloudTalkAccountSchemaBase = z export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine( (value, ctx) => { - requireOpenAllowFrom({ + requireChannelOpenAllowFrom({ + channel: "nextcloud-talk", policy: value.dmPolicy, allowFrom: value.allowFrom, ctx, - path: ["allowFrom"], - message: - 'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"', + requireOpenAllowFrom, }); }, ); @@ -63,12 +63,11 @@ export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({ accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(), defaultAccount: z.string().optional(), }).superRefine((value, ctx) => { - requireOpenAllowFrom({ + requireChannelOpenAllowFrom({ + channel: "nextcloud-talk", policy: value.dmPolicy, allowFrom: value.allowFrom, ctx, - path: ["allowFrom"], - message: - 'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"', + requireOpenAllowFrom, }); }); diff --git a/extensions/shared/config-schema-helpers.ts b/extensions/shared/config-schema-helpers.ts new file mode 100644 index 00000000000..869e98a0763 --- /dev/null +++ b/extensions/shared/config-schema-helpers.ts @@ -0,0 +1,25 @@ +import type { z } from "zod"; + +type RequireOpenAllowFromFn = (params: { + policy: unknown; + allowFrom: unknown; + ctx: z.RefinementCtx; + path: string[]; + message: string; +}) => void; + +export function requireChannelOpenAllowFrom(params: { + channel: string; + policy: unknown; + allowFrom: unknown; + ctx: z.RefinementCtx; + requireOpenAllowFrom: RequireOpenAllowFromFn; +}) { + params.requireOpenAllowFrom({ + policy: params.policy, + allowFrom: params.allowFrom, + ctx: params.ctx, + path: ["allowFrom"], + message: `channels.${params.channel}.dmPolicy="open" requires channels.${params.channel}.allowFrom to include "*"`, + }); +} From 74e50d3be31a362c9e82adae7a5a9e25147245b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:02:56 +0000 Subject: [PATCH 03/23] test: share send cfg threading helpers --- extensions/irc/src/send.test.ts | 30 ++++----- .../mattermost/src/mattermost/send.test.ts | 14 ++-- extensions/nextcloud-talk/src/send.test.ts | 30 ++++----- extensions/test-utils/send-config.ts | 65 +++++++++++++++++++ 4 files changed, 99 insertions(+), 40 deletions(-) create mode 100644 extensions/test-utils/send-config.ts diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index df7b5e60ddd..8fbe58e7f22 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -1,4 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createSendCfgThreadingRuntime, + expectProvidedCfgSkipsRuntimeLoad, + expectRuntimeCfgFallback, +} from "../../test-utils/send-config.js"; import type { IrcClient } from "./client.js"; import type { CoreConfig } from "./types.js"; @@ -27,20 +32,7 @@ const hoisted = vi.hoisted(() => { }); vi.mock("./runtime.js", () => ({ - getIrcRuntime: () => ({ - config: { - loadConfig: hoisted.loadConfig, - }, - channel: { - text: { - resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, - convertMarkdownTables: hoisted.convertMarkdownTables, - }, - activity: { - record: hoisted.record, - }, - }, - }), + getIrcRuntime: () => createSendCfgThreadingRuntime(hoisted), })); vi.mock("./accounts.js", () => ({ @@ -87,8 +79,9 @@ describe("sendMessageIrc cfg threading", () => { accountId: "work", }); - expect(hoisted.loadConfig).not.toHaveBeenCalled(); - expect(hoisted.resolveIrcAccount).toHaveBeenCalledWith({ + expectProvidedCfgSkipsRuntimeLoad({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveIrcAccount, cfg: providedCfg, accountId: "work", }); @@ -106,8 +99,9 @@ describe("sendMessageIrc cfg threading", () => { await sendMessageIrc("#ops", "ping", { client }); - expect(hoisted.loadConfig).toHaveBeenCalledTimes(1); - expect(hoisted.resolveIrcAccount).toHaveBeenCalledWith({ + expectRuntimeCfgFallback({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveIrcAccount, cfg: runtimeCfg, accountId: undefined, }); diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index cebb82ef7e3..774f40f99fa 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -1,4 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + expectProvidedCfgSkipsRuntimeLoad, + expectRuntimeCfgFallback, +} from "../../../test-utils/send-config.js"; import { parseMattermostTarget, sendMessageMattermost } from "./send.js"; import { resetMattermostOpaqueTargetCacheForTests } from "./target-resolution.js"; @@ -107,8 +111,9 @@ describe("sendMessageMattermost", () => { accountId: "work", }); - expect(mockState.loadConfig).not.toHaveBeenCalled(); - expect(mockState.resolveMattermostAccount).toHaveBeenCalledWith({ + expectProvidedCfgSkipsRuntimeLoad({ + loadConfig: mockState.loadConfig, + resolveAccount: mockState.resolveMattermostAccount, cfg: providedCfg, accountId: "work", }); @@ -126,8 +131,9 @@ describe("sendMessageMattermost", () => { await sendMessageMattermost("channel:town-square", "hello"); - expect(mockState.loadConfig).toHaveBeenCalledTimes(1); - expect(mockState.resolveMattermostAccount).toHaveBeenCalledWith({ + expectRuntimeCfgFallback({ + loadConfig: mockState.loadConfig, + resolveAccount: mockState.resolveMattermostAccount, cfg: runtimeCfg, accountId: undefined, }); diff --git a/extensions/nextcloud-talk/src/send.test.ts b/extensions/nextcloud-talk/src/send.test.ts index 88133f9cbed..3ee178b815d 100644 --- a/extensions/nextcloud-talk/src/send.test.ts +++ b/extensions/nextcloud-talk/src/send.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createSendCfgThreadingRuntime, + expectProvidedCfgSkipsRuntimeLoad, + expectRuntimeCfgFallback, +} from "../../test-utils/send-config.js"; const hoisted = vi.hoisted(() => ({ loadConfig: vi.fn(), @@ -17,20 +22,7 @@ const hoisted = vi.hoisted(() => ({ })); vi.mock("./runtime.js", () => ({ - getNextcloudTalkRuntime: () => ({ - config: { - loadConfig: hoisted.loadConfig, - }, - channel: { - text: { - resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, - convertMarkdownTables: hoisted.convertMarkdownTables, - }, - activity: { - record: hoisted.record, - }, - }, - }), + getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted), })); vi.mock("./accounts.js", () => ({ @@ -72,8 +64,9 @@ describe("nextcloud-talk send cfg threading", () => { accountId: "work", }); - expect(hoisted.loadConfig).not.toHaveBeenCalled(); - expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({ + expectProvidedCfgSkipsRuntimeLoad({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveNextcloudTalkAccount, cfg, accountId: "work", }); @@ -95,8 +88,9 @@ describe("nextcloud-talk send cfg threading", () => { }); expect(result).toEqual({ ok: true }); - expect(hoisted.loadConfig).toHaveBeenCalledTimes(1); - expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({ + expectRuntimeCfgFallback({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveNextcloudTalkAccount, cfg: runtimeCfg, accountId: "default", }); diff --git a/extensions/test-utils/send-config.ts b/extensions/test-utils/send-config.ts new file mode 100644 index 00000000000..61c7e126b12 --- /dev/null +++ b/extensions/test-utils/send-config.ts @@ -0,0 +1,65 @@ +import { expect } from "vitest"; + +type MockFn = (...args: never[]) => unknown; + +type CfgThreadingAssertion = { + loadConfig: MockFn; + resolveAccount: MockFn; + cfg: TCfg; + accountId?: string; +}; + +type SendRuntimeState = { + loadConfig: MockFn; + resolveMarkdownTableMode: MockFn; + convertMarkdownTables: MockFn; + record: MockFn; +}; + +export function expectProvidedCfgSkipsRuntimeLoad({ + loadConfig, + resolveAccount, + cfg, + accountId, +}: CfgThreadingAssertion): void { + expect(loadConfig).not.toHaveBeenCalled(); + expect(resolveAccount).toHaveBeenCalledWith({ + cfg, + accountId, + }); +} + +export function expectRuntimeCfgFallback({ + loadConfig, + resolveAccount, + cfg, + accountId, +}: CfgThreadingAssertion): void { + expect(loadConfig).toHaveBeenCalledTimes(1); + expect(resolveAccount).toHaveBeenCalledWith({ + cfg, + accountId, + }); +} + +export function createSendCfgThreadingRuntime({ + loadConfig, + resolveMarkdownTableMode, + convertMarkdownTables, + record, +}: SendRuntimeState) { + return { + config: { + loadConfig, + }, + channel: { + text: { + resolveMarkdownTableMode, + convertMarkdownTables, + }, + activity: { + record, + }, + }, + }; +} From e885f1999f866aefa8bc1eb4b8e0af6e7c399e36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:05:27 +0000 Subject: [PATCH 04/23] refactor: reduce extension channel setup duplication --- extensions/irc/src/channel.ts | 7 ++----- .../matrix/src/matrix/monitor/handler.ts | 3 ++- .../mattermost/src/mattermost/slash-http.ts | 3 ++- extensions/nextcloud-talk/src/channel.ts | 7 ++----- extensions/shared/passive-monitor.ts | 18 ++++++++++++++++++ 5 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 extensions/shared/passive-monitor.ts diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index c598a9a0ef3..62d64fb0866 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -14,10 +14,10 @@ import { deleteAccountFromConfigSection, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, - runPassiveAccountLifecycle, setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/irc"; +import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { listIrcAccountIds, resolveDefaultIrcAccountId, @@ -367,7 +367,7 @@ export const ircPlugin: ChannelPlugin = { ctx.log?.info( `[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`, ); - await runPassiveAccountLifecycle({ + await runStoppablePassiveMonitor({ abortSignal: ctx.abortSignal, start: async () => await monitorIrcProvider({ @@ -377,9 +377,6 @@ export const ircPlugin: ChannelPlugin = { abortSignal: ctx.abortSignal, statusSink, }), - stop: async (monitor) => { - monitor.stop(); - }, }); }, }, diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 0adc9fa2886..22ee16275cf 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -686,6 +686,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam channel: "matrix", accountId: route.accountId, }); + const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId); const typingCallbacks = createTypingCallbacks({ start: () => sendTypingMatrix(roomId, true, undefined, client), stop: () => sendTypingMatrix(roomId, false, undefined, client), @@ -711,7 +712,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, - humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + humanDelay, typingCallbacks, deliver: async (payload) => { await deliverMatrixReplies({ diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 36a5643e3fd..468f5c3584c 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -475,6 +475,7 @@ async function handleSlashCommandAsync(params: { channel: "mattermost", accountId: account.accountId, }); + const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId); const typingCallbacks = createTypingCallbacks({ start: () => sendMattermostTyping(client, { channelId }), @@ -491,7 +492,7 @@ async function handleSlashCommandAsync(params: { const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, - humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + humanDelay, deliver: async (payload: ReplyPayload) => { await deliverMattermostReplyPayload({ core, diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 8a908b7e0ac..473299b74e0 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -5,7 +5,6 @@ import { createAccountStatusSink, formatAllowFromLowercase, mapAllowFromEntries, - runPassiveAccountLifecycle, } from "openclaw/plugin-sdk/compat"; import { applyAccountNameToChannelSection, @@ -21,6 +20,7 @@ import { type OpenClawConfig, type ChannelSetupInput, } from "openclaw/plugin-sdk/nextcloud-talk"; +import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, @@ -344,7 +344,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = setStatus: ctx.setStatus, }); - await runPassiveAccountLifecycle({ + await runStoppablePassiveMonitor({ abortSignal: ctx.abortSignal, start: async () => await monitorNextcloudTalkProvider({ @@ -354,9 +354,6 @@ export const nextcloudTalkPlugin: ChannelPlugin = abortSignal: ctx.abortSignal, statusSink, }), - stop: async (monitor) => { - monitor.stop(); - }, }); }, logoutAccount: async ({ accountId, cfg }) => { diff --git a/extensions/shared/passive-monitor.ts b/extensions/shared/passive-monitor.ts new file mode 100644 index 00000000000..e5ffb3f03ff --- /dev/null +++ b/extensions/shared/passive-monitor.ts @@ -0,0 +1,18 @@ +import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk"; + +type StoppableMonitor = { + stop: () => void; +}; + +export async function runStoppablePassiveMonitor(params: { + abortSignal: AbortSignal; + start: () => Promise; +}): Promise { + await runPassiveAccountLifecycle({ + abortSignal: params.abortSignal, + start: params.start, + stop: async (monitor) => { + monitor.stop(); + }, + }); +} From 97dc493e2a5a84fef81d0ebef821a2451677eca9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:07:04 +0000 Subject: [PATCH 05/23] refactor: share extension channel status summaries --- extensions/googlechat/src/channel.ts | 23 ++++++-------- extensions/imessage/src/channel.ts | 17 ++++------- extensions/mattermost/src/channel.ts | 19 +++++------- extensions/nostr/src/channel.ts | 13 +++----- extensions/shared/channel-status-summary.ts | 34 +++++++++++++++++++++ extensions/slack/src/channel.ts | 17 ++++------- extensions/twitch/src/plugin.ts | 12 ++------ extensions/zalouser/src/channel.ts | 11 ++----- 8 files changed, 72 insertions(+), 74 deletions(-) create mode 100644 extensions/shared/channel-status-summary.ts diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 47980f97d92..3ae992d3e9e 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -30,6 +30,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/googlechat"; import { GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat"; +import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId, @@ -473,20 +474,14 @@ export const googlechatPlugin: ChannelPlugin = { } return issues; }), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - credentialSource: snapshot.credentialSource ?? "none", - audienceType: snapshot.audienceType ?? null, - audience: snapshot.audience ?? null, - webhookPath: snapshot.webhookPath ?? null, - webhookUrl: snapshot.webhookUrl ?? null, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildPassiveProbedChannelStatusSummary(snapshot, { + credentialSource: snapshot.credentialSource ?? "none", + audienceType: snapshot.audienceType ?? null, + audience: snapshot.audience ?? null, + webhookPath: snapshot.webhookPath ?? null, + webhookUrl: snapshot.webhookUrl ?? null, + }), probeAccount: async ({ account }) => probeGoogleChat(account), buildAccountSnapshot: ({ account, runtime, probe }) => { const base = buildComputedAccountStatusSnapshot({ diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 22c45cf6072..17023599eb1 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -29,6 +29,7 @@ import { type ChannelPlugin, type ResolvedIMessageAccount, } from "openclaw/plugin-sdk/imessage"; +import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getIMessageRuntime } from "./runtime.js"; const meta = getChatChannelMeta("imessage"); @@ -264,17 +265,11 @@ export const imessagePlugin: ChannelPlugin = { dbPath: null, }, collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - cliPath: snapshot.cliPath ?? null, - dbPath: snapshot.dbPath ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildPassiveProbedChannelStatusSummary(snapshot, { + cliPath: snapshot.cliPath ?? null, + dbPath: snapshot.dbPath ?? null, + }), probeAccount: async ({ timeoutMs }) => getIMessageRuntime().channel.imessage.probeIMessage(timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => ({ diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index f8116e127b3..c872b8d5085 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -21,6 +21,7 @@ import { type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk/mattermost"; +import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; import { @@ -419,18 +420,12 @@ export const mattermostPlugin: ChannelPlugin = { lastStopAt: null, lastError: null, }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - botTokenSource: snapshot.botTokenSource ?? "none", - running: snapshot.running ?? false, - connected: snapshot.connected ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - baseUrl: snapshot.baseUrl ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildPassiveProbedChannelStatusSummary(snapshot, { + botTokenSource: snapshot.botTokenSource ?? "none", + connected: snapshot.connected ?? false, + baseUrl: snapshot.baseUrl ?? null, + }), probeAccount: async ({ account, timeoutMs }) => { const token = account.botToken?.trim(); const baseUrl = account.baseUrl?.trim(); diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 20de320a3d1..43137b23827 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -7,6 +7,7 @@ import { mapAllowFromEntries, type ChannelPlugin, } from "openclaw/plugin-sdk/nostr"; +import { buildPassiveChannelStatusSummary } from "../../shared/channel-status-summary.js"; import type { NostrProfile } from "./config-schema.js"; import { NostrConfigSchema } from "./config-schema.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; @@ -160,14 +161,10 @@ export const nostrPlugin: ChannelPlugin = { status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - publicKey: snapshot.publicKey ?? null, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildPassiveChannelStatusSummary(snapshot, { + publicKey: snapshot.publicKey ?? null, + }), buildAccountSnapshot: ({ account, runtime }) => ({ accountId: account.accountId, name: account.name, diff --git a/extensions/shared/channel-status-summary.ts b/extensions/shared/channel-status-summary.ts new file mode 100644 index 00000000000..f2671704ac5 --- /dev/null +++ b/extensions/shared/channel-status-summary.ts @@ -0,0 +1,34 @@ +type PassiveChannelStatusSnapshot = { + configured?: boolean; + running?: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: unknown; + lastProbeAt?: number | null; +}; + +export function buildPassiveChannelStatusSummary( + snapshot: PassiveChannelStatusSnapshot, + extra?: TExtra, +) { + return { + configured: snapshot.configured ?? false, + ...(extra ?? ({} as TExtra)), + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + }; +} + +export function buildPassiveProbedChannelStatusSummary( + snapshot: PassiveChannelStatusSnapshot, + extra?: TExtra, +) { + return { + ...buildPassiveChannelStatusSummary(snapshot, extra), + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }; +} diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 73c844a1cc0..17209b6e4d1 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,6 +38,7 @@ import { type ChannelPlugin, type ResolvedSlackAccount, } from "openclaw/plugin-sdk/slack"; +import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; const meta = getChatChannelMeta("slack"); @@ -421,17 +422,11 @@ export const slackPlugin: ChannelPlugin = { lastStopAt: null, lastError: null, }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - botTokenSource: snapshot.botTokenSource ?? "none", - appTokenSource: snapshot.appTokenSource ?? "none", - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildPassiveProbedChannelStatusSummary(snapshot, { + botTokenSource: snapshot.botTokenSource ?? "none", + appTokenSource: snapshot.appTokenSource ?? "none", + }), probeAccount: async ({ account, timeoutMs }) => { const token = account.botToken?.trim(); if (!token) { diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index f6cf576b6a0..11cf90b8893 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; import { buildChannelConfigSchema } from "openclaw/plugin-sdk/twitch"; +import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { twitchMessageActions } from "./actions.js"; import { removeClientManager } from "./client-manager-registry.js"; import { TwitchConfigSchema } from "./config-schema.js"; @@ -169,15 +170,8 @@ export const twitchPlugin: ChannelPlugin = { }, /** Build channel summary from snapshot */ - buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({ - configured: snapshot.configured ?? false, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => + buildPassiveProbedChannelStatusSummary(snapshot), /** Probe account connection */ probeAccount: async ({ diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index d2f7a714537..81fce5e3ab9 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -29,6 +29,7 @@ import { sendPayloadWithChunkedTextAndMedia, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalouser"; +import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, @@ -652,15 +653,7 @@ export const zalouserPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectZalouserStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs), buildAccountSnapshot: async ({ account, runtime }) => { const configured = await checkZcaAuthenticated(account.profile); From c3e78908c747c88961bdbd790f3ec8aa9871a077 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:10:48 +0000 Subject: [PATCH 06/23] test: share feishu startup mock modules --- extensions/feishu/src/monitor.startup.test.ts | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index 18e5d7758ea..96dbd52b8ef 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -8,26 +8,14 @@ vi.mock("./probe.js", () => ({ probeFeishu: probeFeishuMock, })); -vi.mock("./client.js", () => ({ - createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), - createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), -})); -vi.mock("./runtime.js", () => ({ - getFeishuRuntime: () => ({ - channel: { - debounce: { - resolveInboundDebounceMs: () => 0, - createInboundDebouncer: () => ({ - enqueue: async () => {}, - flushKey: async () => {}, - }), - }, - text: { - hasControlCommand: () => false, - }, - }, - }), -})); +vi.mock("./client.js", async () => { + const { createFeishuClientMockModule } = await import("./monitor.test-mocks.js"); + return createFeishuClientMockModule(); +}); +vi.mock("./runtime.js", async () => { + const { createFeishuRuntimeMockModule } = await import("./monitor.test-mocks.js"); + return createFeishuRuntimeMockModule(); +}); function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig { return { From 6decaebcf2ce59482082c46b68ce50952a17f767 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:11:55 +0000 Subject: [PATCH 07/23] test: share plugin api test harness --- extensions/diffs/index.test.ts | 116 ++++++++++--------------- extensions/diffs/src/tool.test.ts | 24 +---- extensions/phone-control/index.test.ts | 19 +--- extensions/test-utils/plugin-api.ts | 43 +++++++++ 4 files changed, 93 insertions(+), 109 deletions(-) create mode 100644 extensions/test-utils/plugin-api.ts diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index df0a0a79192..fe7533683ec 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -1,6 +1,7 @@ import type { IncomingMessage } from "node:http"; import { describe, expect, it, vi } from "vitest"; import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js"; +import { createTestPluginApi } from "../test-utils/plugin-api.js"; import plugin from "./index.js"; describe("diffs plugin registration", () => { @@ -9,33 +10,19 @@ describe("diffs plugin registration", () => { const registerHttpRoute = vi.fn(); const on = vi.fn(); - plugin.register?.({ - id: "diffs", - name: "Diffs", - description: "Diffs", - source: "test", - config: {}, - runtime: {} as never, - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool, - registerHook() {}, - registerHttpRoute, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on, - }); + plugin.register?.( + createTestPluginApi({ + id: "diffs", + name: "Diffs", + description: "Diffs", + source: "test", + config: {}, + runtime: {} as never, + registerTool, + registerHttpRoute, + on, + }), + ); expect(registerTool).toHaveBeenCalledTimes(1); expect(registerHttpRoute).toHaveBeenCalledTimes(1); @@ -65,53 +52,38 @@ describe("diffs plugin registration", () => { ) => Promise) | undefined; - plugin.register?.({ - id: "diffs", - name: "Diffs", - description: "Diffs", - source: "test", - config: { - gateway: { - port: 18789, - bind: "loopback", + plugin.register?.( + createTestPluginApi({ + id: "diffs", + name: "Diffs", + description: "Diffs", + source: "test", + config: { + gateway: { + port: 18789, + bind: "loopback", + }, }, - }, - pluginConfig: { - defaults: { - mode: "view", - theme: "light", - background: false, - layout: "split", - showLineNumbers: false, - diffIndicators: "classic", - lineSpacing: 2, + pluginConfig: { + defaults: { + mode: "view", + theme: "light", + background: false, + layout: "split", + showLineNumbers: false, + diffIndicators: "classic", + lineSpacing: 2, + }, }, - }, - runtime: {} as never, - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool(tool) { - registeredTool = typeof tool === "function" ? undefined : tool; - }, - registerHook() {}, - registerHttpRoute(params) { - registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler; - }, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on() {}, - }); + runtime: {} as never, + registerTool(tool) { + registeredTool = typeof tool === "function" ? undefined : tool; + }, + registerHttpRoute(params) { + registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler; + }, + }), + ); const result = await registeredTool?.execute?.("tool-1", { before: "one\n", diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 210586ad381..056b10c0643 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test-utils/plugin-api.js"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; @@ -383,7 +384,7 @@ describe("diffs tool", () => { }); function createApi(): OpenClawPluginApi { - return { + return createTestPluginApi({ id: "diffs", name: "Diffs", description: "Diffs", @@ -395,26 +396,7 @@ function createApi(): OpenClawPluginApi { }, }, runtime: {} as OpenClawPluginApi["runtime"], - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool() {}, - registerHook() {}, - registerHttpRoute() {}, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on() {}, - }; + }) as OpenClawPluginApi; } function createToolWithScreenshotter( diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index 9259092b153..2c3462c82a9 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -7,6 +7,7 @@ import type { PluginCommandContext, } from "openclaw/plugin-sdk/phone-control"; import { describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../test-utils/plugin-api.js"; import registerPhoneControl from "./index.js"; function createApi(params: { @@ -15,7 +16,7 @@ function createApi(params: { writeConfig: (next: Record) => Promise; registerCommand: (command: OpenClawPluginCommandDefinition) => void; }): OpenClawPluginApi { - return { + return createTestPluginApi({ id: "phone-control", name: "phone-control", source: "test", @@ -30,22 +31,8 @@ function createApi(params: { writeConfigFile: (next: Record) => params.writeConfig(next), }, } as OpenClawPluginApi["runtime"], - logger: { info() {}, warn() {}, error() {} }, - registerTool() {}, - registerHook() {}, - registerHttpRoute() {}, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerContextEngine() {}, registerCommand: params.registerCommand, - resolvePath(input: string) { - return input; - }, - on() {}, - }; + }) as OpenClawPluginApi; } function createCommandContext(args: string): PluginCommandContext { diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts new file mode 100644 index 00000000000..4f28d86c490 --- /dev/null +++ b/extensions/test-utils/plugin-api.ts @@ -0,0 +1,43 @@ +type TestLogger = { + info: () => void; + warn: () => void; + error: () => void; + debug?: () => void; +}; + +type TestPluginApiDefaults = { + logger: TestLogger; + registerTool: () => void; + registerHook: () => void; + registerHttpRoute: () => void; + registerChannel: () => void; + registerGatewayMethod: () => void; + registerCli: () => void; + registerService: () => void; + registerProvider: () => void; + registerCommand: () => void; + registerContextEngine: () => void; + resolvePath: (input: string) => string; + on: () => void; +}; + +export function createTestPluginApi(api: T): T & TestPluginApiDefaults { + return { + logger: { info() {}, warn() {}, error() {} }, + registerTool() {}, + registerHook() {}, + registerHttpRoute() {}, + registerChannel() {}, + registerGatewayMethod() {}, + registerCli() {}, + registerService() {}, + registerProvider() {}, + registerCommand() {}, + registerContextEngine() {}, + resolvePath(input: string) { + return input; + }, + on() {}, + ...api, + }; +} From 1ac4bac8b1abb67b35d3030ba0badf82851dda30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:13:07 +0000 Subject: [PATCH 08/23] refactor: share extension monitor runtime setup --- extensions/irc/src/monitor.ts | 13 ++++++------- extensions/nextcloud-talk/src/monitor.ts | 12 +++++------- extensions/shared/runtime.ts | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 extensions/shared/runtime.ts diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index e416d95f8eb..2eec74a73d4 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -1,4 +1,5 @@ -import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/irc"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/irc"; +import { resolveLoggerBackedRuntime } from "../../shared/runtime.js"; import { resolveIrcAccount } from "./accounts.js"; import { connectIrcClient, type IrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; @@ -39,12 +40,10 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto accountId: opts.accountId, }); - const runtime: RuntimeEnv = - opts.runtime ?? - createLoggerBackedRuntime({ - logger: core.logging.getChildLogger(), - exitError: () => new Error("Runtime exit not available"), - }); + const runtime: RuntimeEnv = resolveLoggerBackedRuntime( + opts.runtime, + core.logging.getChildLogger(), + ); if (!account.configured) { throw new Error( diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index f940195a28b..93c66ade4b5 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,12 +1,12 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import os from "node:os"; import { - createLoggerBackedRuntime, type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, } from "openclaw/plugin-sdk/nextcloud-talk"; +import { resolveLoggerBackedRuntime } from "../../shared/runtime.js"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { handleNextcloudTalkInbound } from "./inbound.js"; import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; @@ -318,12 +318,10 @@ export async function monitorNextcloudTalkProvider( cfg, accountId: opts.accountId, }); - const runtime: RuntimeEnv = - opts.runtime ?? - createLoggerBackedRuntime({ - logger: core.logging.getChildLogger(), - exitError: () => new Error("Runtime exit not available"), - }); + const runtime: RuntimeEnv = resolveLoggerBackedRuntime( + opts.runtime, + core.logging.getChildLogger(), + ); if (!account.secret) { throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); diff --git a/extensions/shared/runtime.ts b/extensions/shared/runtime.ts new file mode 100644 index 00000000000..a1950ba6be0 --- /dev/null +++ b/extensions/shared/runtime.ts @@ -0,0 +1,14 @@ +import { createLoggerBackedRuntime } from "openclaw/plugin-sdk"; + +export function resolveLoggerBackedRuntime( + runtime: TRuntime | undefined, + logger: Parameters[0]["logger"], +): TRuntime { + return ( + runtime ?? + (createLoggerBackedRuntime({ + logger, + exitError: () => new Error("Runtime exit not available"), + }) as TRuntime) + ); +} From 6a61d5504c5fb483289362de16896921930ebd2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:16:19 +0000 Subject: [PATCH 09/23] refactor: share extension deferred and runtime helpers --- extensions/irc/src/onboarding.test.ts | 9 ++------- extensions/matrix/src/channel.directory.test.ts | 9 ++------- extensions/matrix/src/channel.ts | 4 ++-- extensions/matrix/src/matrix/send-queue.test.ts | 15 +++------------ extensions/nostr/src/channel.ts | 8 +++++--- extensions/shared/channel-status-summary.ts | 14 ++++++++++++++ extensions/shared/deferred.ts | 9 +++++++++ extensions/zalouser/src/monitor.ts | 11 +---------- 8 files changed, 38 insertions(+), 41 deletions(-) create mode 100644 extensions/shared/deferred.ts diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/onboarding.test.ts index 21f3e978c1a..613503700f3 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/onboarding.test.ts @@ -1,5 +1,6 @@ import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/irc"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { ircOnboardingAdapter } from "./onboarding.js"; import type { CoreConfig } from "./types.js"; @@ -63,13 +64,7 @@ describe("irc onboarding", () => { }), }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; + const runtime: RuntimeEnv = createRuntimeEnv(); const result = await ircOnboardingAdapter.configure({ cfg: {} as CoreConfig, diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 71c9f1c31b1..2c5bc9533f3 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,5 +1,6 @@ import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { matrixPlugin } from "./channel.js"; import { setMatrixRuntime } from "./runtime.js"; import { createMatrixBotSdkMock } from "./test-mocks.js"; @@ -10,13 +11,7 @@ vi.mock("@vector-im/matrix-bot-sdk", () => ); describe("matrix directory", () => { - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; + const runtimeEnv: RuntimeEnv = createRuntimeEnv(); beforeEach(() => { setMatrixRuntime({ diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index a024b3f3e8a..bad3322f8d0 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -15,6 +15,7 @@ import { PAIRING_APPROVED_MESSAGE, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; +import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; @@ -410,8 +411,7 @@ export const matrixPlugin: ChannelPlugin = { lastError: runtime?.lastError ?? null, probe, lastProbeAt: runtime?.lastProbeAt ?? null, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, + ...buildTrafficStatusSummary(runtime), }), }, gateway: { diff --git a/extensions/matrix/src/matrix/send-queue.test.ts b/extensions/matrix/src/matrix/send-queue.test.ts index aa4765eaab3..240dd8ee71d 100644 --- a/extensions/matrix/src/matrix/send-queue.test.ts +++ b/extensions/matrix/src/matrix/send-queue.test.ts @@ -1,16 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDeferred } from "../../../shared/deferred.js"; import { DEFAULT_SEND_GAP_MS, enqueueSend } from "./send-queue.js"; -function deferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - describe("enqueueSend", () => { beforeEach(() => { vi.useFakeTimers(); @@ -21,7 +12,7 @@ describe("enqueueSend", () => { }); it("serializes sends per room", async () => { - const gate = deferred(); + const gate = createDeferred(); const events: string[] = []; const first = enqueueSend("!room:example.org", async () => { @@ -91,7 +82,7 @@ describe("enqueueSend", () => { }); it("continues queued work when the head task fails", async () => { - const gate = deferred(); + const gate = createDeferred(); const events: string[] = []; const first = enqueueSend("!room:example.org", async () => { diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 43137b23827..937c698bd47 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -7,7 +7,10 @@ import { mapAllowFromEntries, type ChannelPlugin, } from "openclaw/plugin-sdk/nostr"; -import { buildPassiveChannelStatusSummary } from "../../shared/channel-status-summary.js"; +import { + buildPassiveChannelStatusSummary, + buildTrafficStatusSummary, +} from "../../shared/channel-status-summary.js"; import type { NostrProfile } from "./config-schema.js"; import { NostrConfigSchema } from "./config-schema.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; @@ -176,8 +179,7 @@ export const nostrPlugin: ChannelPlugin = { lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, + ...buildTrafficStatusSummary(runtime), }), }, diff --git a/extensions/shared/channel-status-summary.ts b/extensions/shared/channel-status-summary.ts index f2671704ac5..5ebdb067596 100644 --- a/extensions/shared/channel-status-summary.ts +++ b/extensions/shared/channel-status-summary.ts @@ -8,6 +8,11 @@ type PassiveChannelStatusSnapshot = { lastProbeAt?: number | null; }; +type TrafficStatusSnapshot = { + lastInboundAt?: number | null; + lastOutboundAt?: number | null; +}; + export function buildPassiveChannelStatusSummary( snapshot: PassiveChannelStatusSnapshot, extra?: TExtra, @@ -32,3 +37,12 @@ export function buildPassiveProbedChannelStatusSummary( lastProbeAt: snapshot.lastProbeAt ?? null, }; } + +export function buildTrafficStatusSummary( + snapshot?: TSnapshot | null, +) { + return { + lastInboundAt: snapshot?.lastInboundAt ?? null, + lastOutboundAt: snapshot?.lastOutboundAt ?? null, + }; +} diff --git a/extensions/shared/deferred.ts b/extensions/shared/deferred.ts new file mode 100644 index 00000000000..1a874100916 --- /dev/null +++ b/extensions/shared/deferred.ts @@ -0,0 +1,9 @@ +export function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 3ba7e80d2b9..2bfa1be8aa4 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -31,6 +31,7 @@ import { summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/zalouser"; +import { createDeferred } from "../../shared/deferred.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry, @@ -129,16 +130,6 @@ function resolveInboundQueueKey(message: ZaloInboundMessage): string { return `direct:${senderId || threadId}`; } -function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - function resolveZalouserDmSessionScope(config: OpenClawConfig) { const configured = config.session?.dmScope; return configured === "main" || !configured ? "per-channel-peer" : configured; From 013ad58f3caac7bf7032af6a24847ae5ff8ea6e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:18:40 +0000 Subject: [PATCH 10/23] test: share sandbox fs bridge seeded workspace --- .../sandbox/fs-bridge.anchored-ops.test.ts | 13 ++------ src/agents/sandbox/fs-bridge.shell.test.ts | 13 ++------ src/agents/sandbox/fs-bridge.test-helpers.ts | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts index 48e7e9e23f8..f92e99cc3c6 100644 --- a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts +++ b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { createSandbox, createSandboxFsBridge, + createSeededSandboxFsBridge, dockerExecResult, findCallsByScriptFragment, findCallByDockerArg, @@ -103,17 +104,7 @@ describe("sandbox fs bridge anchored ops", () => { it.each(pinnedCases)("$name", async (testCase) => { await withTempDir("openclaw-fs-bridge-contract-write-", async (stateDir) => { - const workspaceDir = path.join(stateDir, "workspace"); - await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true }); - await fs.writeFile(path.join(workspaceDir, "from.txt"), "hello", "utf8"); - await fs.writeFile(path.join(workspaceDir, "nested", "file.txt"), "bye", "utf8"); - - const bridge = createSandboxFsBridge({ - sandbox: createSandbox({ - workspaceDir, - agentWorkspaceDir: workspaceDir, - }), - }); + const { bridge } = await createSeededSandboxFsBridge(stateDir); await testCase.invoke(bridge); diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts index 1685759ad38..1e870ef0268 100644 --- a/src/agents/sandbox/fs-bridge.shell.test.ts +++ b/src/agents/sandbox/fs-bridge.shell.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { createSandbox, createSandboxFsBridge, + createSeededSandboxFsBridge, getScriptsFromCalls, installFsBridgeTestHarness, mockedExecDockerRaw, @@ -140,16 +141,8 @@ describe("sandbox fs bridge shell compatibility", () => { it("routes mkdirp, remove, and rename through the pinned mutation helper", async () => { await withTempDir("openclaw-fs-bridge-shell-write-", async (stateDir) => { - const workspaceDir = path.join(stateDir, "workspace"); - await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true }); - await fs.writeFile(path.join(workspaceDir, "a.txt"), "hello", "utf8"); - await fs.writeFile(path.join(workspaceDir, "nested", "file.txt"), "bye", "utf8"); - - const bridge = createSandboxFsBridge({ - sandbox: createSandbox({ - workspaceDir, - agentWorkspaceDir: workspaceDir, - }), + const { bridge } = await createSeededSandboxFsBridge(stateDir, { + rootFileName: "a.txt", }); await bridge.mkdirp({ filePath: "nested" }); diff --git a/src/agents/sandbox/fs-bridge.test-helpers.ts b/src/agents/sandbox/fs-bridge.test-helpers.ts index 87a184154af..0747371478d 100644 --- a/src/agents/sandbox/fs-bridge.test-helpers.ts +++ b/src/agents/sandbox/fs-bridge.test-helpers.ts @@ -79,6 +79,36 @@ export function createSandbox(overrides?: Partial): SandboxConte }); } +export async function createSeededSandboxFsBridge( + stateDir: string, + params?: { + rootFileName?: string; + rootContents?: string; + nestedFileName?: string; + nestedContents?: string; + }, +) { + const workspaceDir = path.join(stateDir, "workspace"); + await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true }); + await fs.writeFile( + path.join(workspaceDir, params?.rootFileName ?? "from.txt"), + params?.rootContents ?? "hello", + "utf8", + ); + await fs.writeFile( + path.join(workspaceDir, "nested", params?.nestedFileName ?? "file.txt"), + params?.nestedContents ?? "bye", + "utf8", + ); + const bridge = createSandboxFsBridge({ + sandbox: createSandbox({ + workspaceDir, + agentWorkspaceDir: workspaceDir, + }), + }); + return { workspaceDir, bridge }; +} + export async function withTempDir( prefix: string, run: (stateDir: string) => Promise, From 7aedb6d4420c05dfffc0312b10feae098625a728 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:21:13 +0000 Subject: [PATCH 11/23] test: share subagent gateway mock setup --- src/agents/subagent-spawn.workspace.test.ts | 77 ++++++++------------- src/agents/test-helpers/subagent-gateway.ts | 9 +++ 2 files changed, 38 insertions(+), 48 deletions(-) create mode 100644 src/agents/test-helpers/subagent-gateway.ts diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts index fef6bc7515c..9955e587c89 100644 --- a/src/agents/subagent-spawn.workspace.test.ts +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { spawnSubagentDirect } from "./subagent-spawn.js"; +import { installAcceptedSubagentGatewayMock } from "./test-helpers/subagent-gateway.js"; type TestAgentConfig = { id?: string; @@ -100,20 +101,7 @@ function createConfigOverride(overrides?: Record) { } function setupGatewayMock() { - hoisted.callGatewayMock.mockImplementation( - async (opts: { method?: string; params?: Record }) => { - if (opts.method === "sessions.patch") { - return { ok: true }; - } - if (opts.method === "sessions.delete") { - return { ok: true }; - } - if (opts.method === "agent") { - return { runId: "run-1" }; - } - return {}; - }, - ); + installAcceptedSubagentGatewayMock(hoisted.callGatewayMock); } function getRegisteredRun() { @@ -122,6 +110,27 @@ function getRegisteredRun() { | undefined; } +async function expectAcceptedWorkspace(params: { agentId: string; expectedWorkspaceDir: string }) { + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: params.agentId, + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: params.expectedWorkspaceDir, + }); +} + describe("spawnSubagentDirect workspace inheritance", () => { beforeEach(() => { hoisted.callGatewayMock.mockClear(); @@ -149,44 +158,16 @@ describe("spawnSubagentDirect workspace inheritance", () => { }, }); - const result = await spawnSubagentDirect( - { - task: "inspect workspace", - agentId: "ops", - }, - { - agentSessionKey: "agent:main:main", - agentChannel: "telegram", - agentAccountId: "123", - agentTo: "456", - workspaceDir: "/tmp/requester-workspace", - }, - ); - - expect(result.status).toBe("accepted"); - expect(getRegisteredRun()).toMatchObject({ - workspaceDir: "/tmp/workspace-ops", + await expectAcceptedWorkspace({ + agentId: "ops", + expectedWorkspaceDir: "/tmp/workspace-ops", }); }); it("preserves the inherited workspace for same-agent spawns", async () => { - const result = await spawnSubagentDirect( - { - task: "inspect workspace", - agentId: "main", - }, - { - agentSessionKey: "agent:main:main", - agentChannel: "telegram", - agentAccountId: "123", - agentTo: "456", - workspaceDir: "/tmp/requester-workspace", - }, - ); - - expect(result.status).toBe("accepted"); - expect(getRegisteredRun()).toMatchObject({ - workspaceDir: "/tmp/requester-workspace", + await expectAcceptedWorkspace({ + agentId: "main", + expectedWorkspaceDir: "/tmp/requester-workspace", }); }); }); diff --git a/src/agents/test-helpers/subagent-gateway.ts b/src/agents/test-helpers/subagent-gateway.ts new file mode 100644 index 00000000000..9491d971c33 --- /dev/null +++ b/src/agents/test-helpers/subagent-gateway.ts @@ -0,0 +1,9 @@ +export function installAcceptedSubagentGatewayMock(mock: { + mockImplementation: ( + impl: (opts: { method?: string; params?: unknown }) => Promise, + ) => unknown; +}) { + mock.mockImplementation(async ({ method }) => + method === "agent" ? { runId: "run-1" } : method?.startsWith("sessions.") ? { ok: true } : {}, + ); +} From 8622395c8b1ea23250ac94836dcc1e2eb8169c6a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:24:58 +0000 Subject: [PATCH 12/23] test: share models config merge helpers --- ...ssing-provider-apikey-from-env-var.test.ts | 195 +++++++++--------- 1 file changed, 98 insertions(+), 97 deletions(-) diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 36944d67601..036f4d00824 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -60,13 +60,31 @@ function createMergeConfigProvider() { }; } -async function runCustomProviderMergeTest(params: { - seedProvider: { - baseUrl: string; - apiKey: string; - api: string; - models: Array<{ id: string; name: string; input: string[]; api?: string }>; +type MergeSeedProvider = { + baseUrl: string; + apiKey: string; + api: string; + models: Array<{ id: string; name: string; input: string[]; api?: string }>; +}; + +type MergeConfigApiKeyRef = { + source: "env"; + provider: "default"; + id: string; +}; + +function createAgentSeedProvider(overrides: Partial = {}): MergeSeedProvider { + return { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", // pragma: allowlist secret + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + ...overrides, }; +} + +async function runCustomProviderMergeTest(params: { + seedProvider: MergeSeedProvider; existingProviderKey?: string; configProviderKey?: string; }) { @@ -86,6 +104,56 @@ async function runCustomProviderMergeTest(params: { }>(); } +async function expectCustomProviderMergeResult(params: { + seedProvider?: MergeSeedProvider; + existingProviderKey?: string; + configProviderKey?: string; + expectedApiKey: string; + expectedBaseUrl: string; +}) { + await withTempHome(async () => { + const parsed = await runCustomProviderMergeTest({ + seedProvider: params.seedProvider ?? createAgentSeedProvider(), + existingProviderKey: params.existingProviderKey, + configProviderKey: params.configProviderKey, + }); + expect(parsed.providers.custom?.apiKey).toBe(params.expectedApiKey); + expect(parsed.providers.custom?.baseUrl).toBe(params.expectedBaseUrl); + }); +} + +async function expectCustomProviderApiKeyRewrite(params: { + existingApiKey: string; + configuredApiKey: string | MergeConfigApiKeyRef; + expectedApiKey: string; +}) { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + custom: createAgentSeedProvider({ apiKey: params.existingApiKey }), + }, + }); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + ...createMergeConfigProvider(), + apiKey: params.configuredApiKey, + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe(params.expectedApiKey); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); +} + function createMoonshotConfig(overrides: { contextWindow: number; maxTokens: number; @@ -301,49 +369,26 @@ describe("models-config", () => { }); it("preserves non-empty agent apiKey but lets explicit config baseUrl win in merge mode", async () => { - await withTempHome(async () => { - const parsed = await runCustomProviderMergeTest({ - seedProvider: { - baseUrl: "https://agent.example/v1", - apiKey: "AGENT_KEY", // pragma: allowlist secret - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], - }, - }); - expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); - expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + await expectCustomProviderMergeResult({ + expectedApiKey: "AGENT_KEY", + expectedBaseUrl: "https://config.example/v1", }); }); it("lets explicit config baseUrl win in merge mode when the config provider key is normalized", async () => { - await withTempHome(async () => { - const parsed = await runCustomProviderMergeTest({ - seedProvider: { - baseUrl: "https://agent.example/v1", - apiKey: "AGENT_KEY", // pragma: allowlist secret - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], - }, - existingProviderKey: "custom", - configProviderKey: " custom ", - }); - expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); - expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + await expectCustomProviderMergeResult({ + existingProviderKey: "custom", + configProviderKey: " custom ", + expectedApiKey: "AGENT_KEY", + expectedBaseUrl: "https://config.example/v1", }); }); it("replaces stale merged baseUrl when the provider api changes", async () => { - await withTempHome(async () => { - const parsed = await runCustomProviderMergeTest({ - seedProvider: { - baseUrl: "https://agent.example/v1", - apiKey: "AGENT_KEY", // pragma: allowlist secret - api: "openai-completions", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], - }, - }); - expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); - expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + await expectCustomProviderMergeResult({ + seedProvider: createAgentSeedProvider({ api: "openai-completions" }), + expectedApiKey: "AGENT_KEY", + expectedBaseUrl: "https://config.example/v1", }); }); @@ -370,34 +415,14 @@ describe("models-config", () => { }); it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => { - await withTempHome(async () => { - await writeAgentModelsJson({ - providers: { - custom: { - baseUrl: "https://agent.example/v1", - apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], - }, - }, - }); - await ensureOpenClawModelsJson({ - models: { - mode: "merge", - providers: { - custom: { - ...createMergeConfigProvider(), - apiKey: { source: "env", provider: "default", id: "CUSTOM_PROVIDER_API_KEY" }, // pragma: allowlist secret - }, - }, - }, - }); - - const parsed = await readGeneratedModelsJson<{ - providers: Record; - }>(); - expect(parsed.providers.custom?.apiKey).toBe("CUSTOM_PROVIDER_API_KEY"); // pragma: allowlist secret - expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + await expectCustomProviderApiKeyRewrite({ + existingApiKey: "STALE_AGENT_KEY", // pragma: allowlist secret + configuredApiKey: { + source: "env", + provider: "default", + id: "CUSTOM_PROVIDER_API_KEY", // pragma: allowlist secret + }, + expectedApiKey: "CUSTOM_PROVIDER_API_KEY", // pragma: allowlist secret }); }); @@ -449,34 +474,10 @@ describe("models-config", () => { }); it("replaces stale non-env marker when provider transitions back to plaintext config", async () => { - await withTempHome(async () => { - await writeAgentModelsJson({ - providers: { - custom: { - baseUrl: "https://agent.example/v1", - apiKey: NON_ENV_SECRETREF_MARKER, - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], - }, - }, - }); - - await ensureOpenClawModelsJson({ - models: { - mode: "merge", - providers: { - custom: { - ...createMergeConfigProvider(), - apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret - }, - }, - }, - }); - - const parsed = await readGeneratedModelsJson<{ - providers: Record; - }>(); - expect(parsed.providers.custom?.apiKey).toBe("ALLCAPS_SAMPLE"); + await expectCustomProviderApiKeyRewrite({ + existingApiKey: NON_ENV_SECRETREF_MARKER, + configuredApiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + expectedApiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret }); }); From f0179d3b4af77c5fcad096dcdce4c4f31214f30f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:26:01 +0000 Subject: [PATCH 13/23] test: share workspace skills snapshot helpers --- ...skills.buildworkspaceskillsnapshot.test.ts | 127 +++++++++--------- 1 file changed, 67 insertions(+), 60 deletions(-) diff --git a/src/agents/skills.buildworkspaceskillsnapshot.test.ts b/src/agents/skills.buildworkspaceskillsnapshot.test.ts index aec0da8b49a..1292841ed13 100644 --- a/src/agents/skills.buildworkspaceskillsnapshot.test.ts +++ b/src/agents/skills.buildworkspaceskillsnapshot.test.ts @@ -43,22 +43,44 @@ function withWorkspaceHome(workspaceDir: string, cb: () => T): T { return withEnv({ HOME: workspaceDir, PATH: "" }, cb); } +function buildSnapshot( + workspaceDir: string, + options?: Parameters[1], +) { + return withWorkspaceHome(workspaceDir, () => + buildWorkspaceSkillSnapshot(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: path.join(workspaceDir, ".bundled"), + ...options, + }), + ); +} + async function cloneTemplateDir(templateDir: string, prefix: string): Promise { const cloned = await fixtureSuite.createCaseDir(prefix); await fs.cp(templateDir, cloned, { recursive: true }); return cloned; } +function expectSnapshotNamesAndPrompt( + snapshot: ReturnType, + params: { contains?: string[]; omits?: string[] }, +) { + for (const name of params.contains ?? []) { + expect(snapshot.skills.map((skill) => skill.name)).toContain(name); + expect(snapshot.prompt).toContain(name); + } + for (const name of params.omits ?? []) { + expect(snapshot.skills.map((skill) => skill.name)).not.toContain(name); + expect(snapshot.prompt).not.toContain(name); + } +} + describe("buildWorkspaceSkillSnapshot", () => { it("returns an empty snapshot when skills dirs are missing", async () => { const workspaceDir = await fixtureSuite.createCaseDir("workspace"); - const snapshot = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, { - managedSkillsDir: path.join(workspaceDir, ".managed"), - bundledSkillsDir: path.join(workspaceDir, ".bundled"), - }), - ); + const snapshot = buildSnapshot(workspaceDir); expect(snapshot.prompt).toBe(""); expect(snapshot.skills).toEqual([]); @@ -78,12 +100,7 @@ describe("buildWorkspaceSkillSnapshot", () => { frontmatterExtra: "disable-model-invocation: true", }); - const snapshot = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, { - managedSkillsDir: path.join(workspaceDir, ".managed"), - bundledSkillsDir: path.join(workspaceDir, ".bundled"), - }), - ); + const snapshot = buildSnapshot(workspaceDir); expect(snapshot.prompt).toContain("visible-skill"); expect(snapshot.prompt).not.toContain("hidden-skill"); @@ -204,24 +221,20 @@ describe("buildWorkspaceSkillSnapshot", () => { body: "x".repeat(5_000), }); - const snapshot = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, { - config: { - skills: { - limits: { - maxSkillFileBytes: 1000, - }, + const snapshot = buildSnapshot(workspaceDir, { + config: { + skills: { + limits: { + maxSkillFileBytes: 1000, }, }, - managedSkillsDir: path.join(workspaceDir, ".managed"), - bundledSkillsDir: path.join(workspaceDir, ".bundled"), - }), - ); + }, + }); - expect(snapshot.skills.map((s) => s.name)).toContain("small-skill"); - expect(snapshot.skills.map((s) => s.name)).not.toContain("big-skill"); - expect(snapshot.prompt).toContain("small-skill"); - expect(snapshot.prompt).not.toContain("big-skill"); + expectSnapshotNamesAndPrompt(snapshot, { + contains: ["small-skill"], + omits: ["big-skill"], + }); }); it("detects nested skills roots beyond the first 25 entries", async () => { @@ -241,26 +254,23 @@ describe("buildWorkspaceSkillSnapshot", () => { description: "Nested skill discovered late", }); - const snapshot = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, { - config: { - skills: { - load: { - extraDirs: [repoDir], - }, - limits: { - maxCandidatesPerRoot: 30, - maxSkillsLoadedPerSource: 30, - }, + const snapshot = buildSnapshot(workspaceDir, { + config: { + skills: { + load: { + extraDirs: [repoDir], + }, + limits: { + maxCandidatesPerRoot: 30, + maxSkillsLoadedPerSource: 30, }, }, - managedSkillsDir: path.join(workspaceDir, ".managed"), - bundledSkillsDir: path.join(workspaceDir, ".bundled"), - }), - ); + }, + }); - expect(snapshot.skills.map((s) => s.name)).toContain("late-skill"); - expect(snapshot.prompt).toContain("late-skill"); + expectSnapshotNamesAndPrompt(snapshot, { + contains: ["late-skill"], + }); }); it("enforces maxSkillFileBytes for root-level SKILL.md", async () => { @@ -274,24 +284,21 @@ describe("buildWorkspaceSkillSnapshot", () => { body: "x".repeat(5_000), }); - const snapshot = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, { - config: { - skills: { - load: { - extraDirs: [rootSkillDir], - }, - limits: { - maxSkillFileBytes: 1000, - }, + const snapshot = buildSnapshot(workspaceDir, { + config: { + skills: { + load: { + extraDirs: [rootSkillDir], + }, + limits: { + maxSkillFileBytes: 1000, }, }, - managedSkillsDir: path.join(workspaceDir, ".managed"), - bundledSkillsDir: path.join(workspaceDir, ".bundled"), - }), - ); + }, + }); - expect(snapshot.skills.map((s) => s.name)).not.toContain("root-big-skill"); - expect(snapshot.prompt).not.toContain("root-big-skill"); + expectSnapshotNamesAndPrompt(snapshot, { + omits: ["root-big-skill"], + }); }); }); From dfcc2fae9f89bda5a2c007b2d6a7b3e53943394f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:26:52 +0000 Subject: [PATCH 14/23] test: share context lookup helpers --- src/agents/context.lookup.test.ts | 102 ++++++++++++------------------ 1 file changed, 42 insertions(+), 60 deletions(-) diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 428d47759bc..a395f0b3089 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -1,8 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -function mockContextModuleDeps(loadConfigImpl: () => unknown) { +type DiscoveredModel = { id: string; contextWindow: number }; + +function mockContextDeps(params: { + loadConfig: () => unknown; + discoveredModels?: DiscoveredModel[]; +}) { vi.doMock("../config/config.js", () => ({ - loadConfig: loadConfigImpl, + loadConfig: params.loadConfig, })); vi.doMock("./models-config.js", () => ({ ensureOpenClawModelsJson: vi.fn(async () => {}), @@ -13,29 +18,42 @@ function mockContextModuleDeps(loadConfigImpl: () => unknown) { vi.doMock("./pi-model-discovery.js", () => ({ discoverAuthStorage: vi.fn(() => ({})), discoverModels: vi.fn(() => ({ - getAll: () => [], + getAll: () => params.discoveredModels ?? [], })), })); } +function mockContextModuleDeps(loadConfigImpl: () => unknown) { + mockContextDeps({ loadConfig: loadConfigImpl }); +} + // Shared mock setup used by multiple tests. function mockDiscoveryDeps( - models: Array<{ id: string; contextWindow: number }>, + models: DiscoveredModel[], configModels?: Record }>, ) { - vi.doMock("../config/config.js", () => ({ + mockContextDeps({ loadConfig: () => ({ models: configModels ? { providers: configModels } : {} }), - })); - vi.doMock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), - })); - vi.doMock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", - })); - vi.doMock("./pi-model-discovery.js", () => ({ - discoverAuthStorage: vi.fn(() => ({})), - discoverModels: vi.fn(() => ({ getAll: () => models })), - })); + discoveredModels: models, + }); +} + +function createContextOverrideConfig(provider: string, model: string, contextWindow: number) { + return { + models: { + providers: { + [provider]: { + models: [{ id: model, contextWindow }], + }, + }, + }, + }; +} + +async function importResolveContextTokensForModel() { + const { resolveContextTokensForModel } = await import("./context.js"); + await new Promise((r) => setTimeout(r, 0)); + return resolveContextTokensForModel; } describe("lookupContextTokens", () => { @@ -150,18 +168,8 @@ describe("lookupContextTokens", () => { { id: "google-gemini-cli/gemini-3.1-pro-preview", contextWindow: 1_048_576 }, ]); - const cfg = { - models: { - providers: { - "google-gemini-cli": { - models: [{ id: "gemini-3.1-pro-preview", contextWindow: 200_000 }], - }, - }, - }, - }; - - const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + const cfg = createContextOverrideConfig("google-gemini-cli", "gemini-3.1-pro-preview", 200_000); + const resolveContextTokensForModel = await importResolveContextTokensForModel(); const result = resolveContextTokensForModel({ cfg: cfg as never, @@ -174,18 +182,8 @@ describe("lookupContextTokens", () => { it("resolveContextTokensForModel honors configured overrides when provider keys use mixed case", async () => { mockDiscoveryDeps([{ id: "openrouter/anthropic/claude-sonnet-4-5", contextWindow: 1_048_576 }]); - const cfg = { - models: { - providers: { - " OpenRouter ": { - models: [{ id: "anthropic/claude-sonnet-4-5", contextWindow: 200_000 }], - }, - }, - }, - }; - - const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + const cfg = createContextOverrideConfig(" OpenRouter ", "anthropic/claude-sonnet-4-5", 200_000); + const resolveContextTokensForModel = await importResolveContextTokensForModel(); const result = resolveContextTokensForModel({ cfg: cfg as never, @@ -202,16 +200,8 @@ describe("lookupContextTokens", () => { // Real callers (status.summary.ts) always pass cfg when provider is explicit. mockDiscoveryDeps([{ id: "google/gemini-2.5-pro", contextWindow: 999_000 }]); - const cfg = { - models: { - providers: { - google: { models: [{ id: "gemini-2.5-pro", contextWindow: 2_000_000 }] }, - }, - }, - }; - - const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + const cfg = createContextOverrideConfig("google", "gemini-2.5-pro", 2_000_000); + const resolveContextTokensForModel = await importResolveContextTokensForModel(); // Google with explicit cfg: config direct scan wins before any cache lookup. const googleResult = resolveContextTokensForModel({ @@ -272,16 +262,8 @@ describe("lookupContextTokens", () => { // window and misreport context limits for the OpenRouter session. mockDiscoveryDeps([{ id: "google/gemini-2.5-pro", contextWindow: 999_000 }]); - const cfg = { - models: { - providers: { - google: { models: [{ id: "gemini-2.5-pro", contextWindow: 2_000_000 }] }, - }, - }, - }; - - const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + const cfg = createContextOverrideConfig("google", "gemini-2.5-pro", 2_000_000); + const resolveContextTokensForModel = await importResolveContextTokensForModel(); // model-only call (no explicit provider) must NOT apply config direct scan. // Falls through to bare cache lookup: "google/gemini-2.5-pro" → 999k ✓. From 0e6f150c3b3401151bb81fc45d90c776b2ccf8fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:27:34 +0000 Subject: [PATCH 15/23] test: share timeout failover assertions --- ...dded-helpers.isbillingerrormessage.test.ts | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index e8578c7feb2..8c0a0b1994d 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -55,6 +55,14 @@ function expectMessageMatches( } } +function expectTimeoutFailoverSamples(samples: readonly string[]) { + for (const sample of samples) { + expect(isTimeoutErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("timeout"); + expect(isFailoverErrorMessage(sample)).toBe(true); + } +} + describe("isAuthPermanentErrorMessage", () => { it.each([ { @@ -567,36 +575,26 @@ describe("isFailoverErrorMessage", () => { }); it("matches abort stop-reason timeout variants", () => { - const samples = [ + expectTimeoutFailoverSamples([ "Unhandled stop reason: abort", "Unhandled stop reason: error", "stop reason: abort", "stop reason: error", "reason: abort", "reason: error", - ]; - for (const sample of samples) { - expect(isTimeoutErrorMessage(sample)).toBe(true); - expect(classifyFailoverReason(sample)).toBe("timeout"); - expect(isFailoverErrorMessage(sample)).toBe(true); - } + ]); }); it("matches Gemini MALFORMED_RESPONSE stop reason as timeout (#42149)", () => { - const samples = [ + expectTimeoutFailoverSamples([ "Unhandled stop reason: MALFORMED_RESPONSE", "Unhandled stop reason: malformed_response", "stop reason: MALFORMED_RESPONSE", - ]; - for (const sample of samples) { - expect(isTimeoutErrorMessage(sample)).toBe(true); - expect(classifyFailoverReason(sample)).toBe("timeout"); - expect(isFailoverErrorMessage(sample)).toBe(true); - } + ]); }); it("matches network errno codes in serialized error messages", () => { - const samples = [ + expectTimeoutFailoverSamples([ "Error: connect ETIMEDOUT 10.0.0.1:443", "Error: connect ESOCKETTIMEDOUT 10.0.0.1:443", "Error: connect EHOSTUNREACH 10.0.0.1:443", @@ -604,25 +602,15 @@ describe("isFailoverErrorMessage", () => { "Error: write EPIPE", "Error: read ENETRESET", "Error: connect EHOSTDOWN 192.168.1.1:443", - ]; - for (const sample of samples) { - expect(isTimeoutErrorMessage(sample)).toBe(true); - expect(classifyFailoverReason(sample)).toBe("timeout"); - expect(isFailoverErrorMessage(sample)).toBe(true); - } + ]); }); it("matches z.ai network_error stop reason as timeout", () => { - const samples = [ + expectTimeoutFailoverSamples([ "Unhandled stop reason: network_error", "stop reason: network_error", "reason: network_error", - ]; - for (const sample of samples) { - expect(isTimeoutErrorMessage(sample)).toBe(true); - expect(classifyFailoverReason(sample)).toBe("timeout"); - expect(isFailoverErrorMessage(sample)).toBe(true); - } + ]); }); it("does not classify MALFORMED_FUNCTION_CALL as timeout", () => { From e474ac882e10ecc016ea334c667923babf5f87db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:28:53 +0000 Subject: [PATCH 16/23] test: share model selection config helpers --- src/agents/model-selection.test.ts | 197 +++++++++++++---------------- 1 file changed, 86 insertions(+), 111 deletions(-) diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index e2d90f355bc..bf4c3aee03e 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -50,6 +50,60 @@ function resolveAnthropicOpusThinking(cfg: OpenClawConfig) { }); } +function createAgentFallbackConfig(params: { + primary?: string; + fallbacks?: string[]; + agentFallbacks?: string[]; +}) { + return { + agents: { + defaults: { + models: { + "openai/gpt-4o": {}, + }, + model: { + primary: params.primary ?? "openai/gpt-4o", + fallbacks: params.fallbacks ?? [], + }, + }, + ...(params.agentFallbacks + ? { + list: [ + { + id: "coder", + model: { + primary: params.primary ?? "openai/gpt-4o", + fallbacks: params.agentFallbacks, + }, + }, + ], + } + : {}), + }, + } as OpenClawConfig; +} + +function createProviderWithModelsConfig(provider: string, models: Array>) { + return { + models: { + providers: { + [provider]: { + baseUrl: `https://${provider}.example.com`, + models, + }, + }, + }, + } as Partial; +} + +function resolveConfiguredRefForTest(cfg: Partial) { + return resolveConfiguredModelRef({ + cfg: cfg as OpenClawConfig, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }); +} + describe("model-selection", () => { describe("normalizeProviderId", () => { it("should normalize provider names", () => { @@ -326,19 +380,9 @@ describe("model-selection", () => { }); it("includes fallback models in allowed set", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - models: { - "openai/gpt-4o": {}, - }, - model: { - primary: "openai/gpt-4o", - fallbacks: ["anthropic/claude-sonnet-4-6", "google/gemini-3-pro"], - }, - }, - }, - } as OpenClawConfig; + const cfg = createAgentFallbackConfig({ + fallbacks: ["anthropic/claude-sonnet-4-6", "google/gemini-3-pro"], + }); const result = buildAllowedModelSet({ cfg, @@ -354,19 +398,7 @@ describe("model-selection", () => { }); it("handles empty fallbacks gracefully", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - models: { - "openai/gpt-4o": {}, - }, - model: { - primary: "openai/gpt-4o", - fallbacks: [], - }, - }, - }, - } as OpenClawConfig; + const cfg = createAgentFallbackConfig({}); const result = buildAllowedModelSet({ cfg, @@ -380,28 +412,10 @@ describe("model-selection", () => { }); it("prefers per-agent fallback overrides when agentId is provided", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - models: { - "openai/gpt-4o": {}, - }, - model: { - primary: "openai/gpt-4o", - fallbacks: ["google/gemini-3-pro"], - }, - }, - list: [ - { - id: "coder", - model: { - primary: "openai/gpt-4o", - fallbacks: ["anthropic/claude-sonnet-4-6"], - }, - }, - ], - }, - } as OpenClawConfig; + const cfg = createAgentFallbackConfig({ + fallbacks: ["google/gemini-3-pro"], + agentFallbacks: ["anthropic/claude-sonnet-4-6"], + }); const result = buildAllowedModelSet({ cfg, @@ -632,79 +646,40 @@ describe("model-selection", () => { }); it("should prefer configured custom provider when default provider is not in models.providers", () => { - const cfg: Partial = { - models: { - providers: { - n1n: { - baseUrl: "https://n1n.example.com", - models: [ - { - id: "gpt-5.4", - name: "GPT 5.4", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 4096, - }, - ], - }, - }, + const cfg = createProviderWithModelsConfig("n1n", [ + { + id: "gpt-5.4", + name: "GPT 5.4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, }, - }; - const result = resolveConfiguredModelRef({ - cfg: cfg as OpenClawConfig, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - }); + ]); + const result = resolveConfiguredRefForTest(cfg); expect(result).toEqual({ provider: "n1n", model: "gpt-5.4" }); }); it("should keep default provider when it is in models.providers", () => { - const cfg: Partial = { - models: { - providers: { - anthropic: { - baseUrl: "https://api.anthropic.com", - models: [ - { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 4096, - }, - ], - }, - }, + const cfg = createProviderWithModelsConfig("anthropic", [ + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 4096, }, - }; - const result = resolveConfiguredModelRef({ - cfg: cfg as OpenClawConfig, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - }); + ]); + const result = resolveConfiguredRefForTest(cfg); expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); }); it("should fall back to hardcoded default when no custom providers have models", () => { - const cfg: Partial = { - models: { - providers: { - "empty-provider": { - baseUrl: "https://example.com", - models: [], - }, - }, - }, - }; - const result = resolveConfiguredModelRef({ - cfg: cfg as OpenClawConfig, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - }); + const cfg = createProviderWithModelsConfig("empty-provider", []); + const result = resolveConfiguredRefForTest(cfg); expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); }); From 95b4132674ed531a33fe2124847b8bf21f5c15f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:31:28 +0000 Subject: [PATCH 17/23] test: share provider discovery auth fixtures --- ...ls-config.providers.discovery-auth.test.ts | 108 +++++++----------- 1 file changed, 42 insertions(+), 66 deletions(-) diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts index e6aebc0d7cb..6fc492c1565 100644 --- a/src/agents/models-config.providers.discovery-auth.test.ts +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -6,6 +6,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +type AuthProfilesFile = { + version: 1; + profiles: Record>; +}; + describe("provider discovery auth marker guardrails", () => { let originalVitest: string | undefined; let originalNodeEnv: string | undefined; @@ -35,33 +40,35 @@ describe("provider discovery auth marker guardrails", () => { delete process.env.NODE_ENV; } - it("does not send marker value as vLLM bearer token during discovery", async () => { - enableDiscovery(); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ data: [] }), - }); + function installFetchMock(response?: unknown) { + const fetchMock = + response === undefined + ? vi.fn() + : vi.fn().mockResolvedValue({ ok: true, json: async () => response }); globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; + } + async function createAgentDirWithAuthProfiles(profiles: AuthProfilesFile["profiles"]) { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); await writeFile( join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "vllm:default": { - type: "api_key", - provider: "vllm", - keyRef: { source: "file", provider: "vault", id: "/vllm/apiKey" }, - }, - }, - }, - null, - 2, - ), + JSON.stringify({ version: 1, profiles } satisfies AuthProfilesFile, null, 2), "utf8", ); + return agentDir; + } + + it("does not send marker value as vLLM bearer token during discovery", async () => { + enableDiscovery(); + const fetchMock = installFetchMock({ data: [] }); + const agentDir = await createAgentDirWithAuthProfiles({ + "vllm:default": { + type: "api_key", + provider: "vllm", + keyRef: { source: "file", provider: "vault", id: "/vllm/apiKey" }, + }, + }); const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); @@ -73,28 +80,14 @@ describe("provider discovery auth marker guardrails", () => { it("does not call Hugging Face discovery with marker-backed credentials", async () => { enableDiscovery(); - const fetchMock = vi.fn(); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await writeFile( - join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "huggingface:default": { - type: "api_key", - provider: "huggingface", - keyRef: { source: "exec", provider: "vault", id: "providers/hf/token" }, - }, - }, - }, - null, - 2, - ), - "utf8", - ); + const fetchMock = installFetchMock(); + const agentDir = await createAgentDirWithAuthProfiles({ + "huggingface:default": { + type: "api_key", + provider: "huggingface", + keyRef: { source: "exec", provider: "vault", id: "providers/hf/token" }, + }, + }); const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); @@ -106,31 +99,14 @@ describe("provider discovery auth marker guardrails", () => { it("keeps all-caps plaintext API keys for authenticated discovery", async () => { enableDiscovery(); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ data: [{ id: "vllm/test-model" }] }), + const fetchMock = installFetchMock({ data: [{ id: "vllm/test-model" }] }); + const agentDir = await createAgentDirWithAuthProfiles({ + "vllm:default": { + type: "api_key", + provider: "vllm", + key: "ALLCAPS_SAMPLE", + }, }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await writeFile( - join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "vllm:default": { - type: "api_key", - provider: "vllm", - key: "ALLCAPS_SAMPLE", - }, - }, - }, - null, - 2, - ), - "utf8", - ); await resolveImplicitProvidersForTest({ agentDir, env: {} }); const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000")); From 6ad675c1e9d13b94de246e794b90d6d9cefd761a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:32:31 +0000 Subject: [PATCH 18/23] test: share subagent announce timeout helpers --- src/agents/subagent-announce.timeout.test.ts | 45 +++++++++----------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index b003276e56e..5fae988fe73 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -120,6 +120,21 @@ function findGatewayCall(predicate: (call: GatewayCall) => boolean): GatewayCall return gatewayCalls.find(predicate); } +function findFinalDirectAgentCall(): GatewayCall | undefined { + return findGatewayCall((call) => call.method === "agent" && call.expectFinal === true); +} + +function setupParentSessionFallback(parentSessionKey: string): void { + requesterDepthResolver = (sessionKey?: string) => + sessionKey === parentSessionKey ? 1 : sessionKey?.includes(":subagent:") ? 1 : 0; + subagentSessionRunActive = false; + shouldIgnorePostCompletion = false; + fallbackRequesterResolution = { + requesterSessionKey: "agent:main:main", + requesterOrigin: { channel: "discord", to: "chan-main", accountId: "acct-main" }, + }; +} + describe("subagent announce timeout config", () => { beforeEach(() => { gatewayCalls.length = 0; @@ -244,9 +259,7 @@ describe("subagent announce timeout config", () => { requesterOrigin: { channel: "discord", to: "channel:cron-results", accountId: "acct-1" }, }); - const directAgentCall = findGatewayCall( - (call) => call.method === "agent" && call.expectFinal === true, - ); + const directAgentCall = findFinalDirectAgentCall(); expect(directAgentCall?.params?.sessionKey).toBe(cronSessionKey); expect(directAgentCall?.params?.deliver).toBe(false); expect(directAgentCall?.params?.channel).toBeUndefined(); @@ -256,14 +269,7 @@ describe("subagent announce timeout config", () => { it("regression, routes child announce to parent session instead of grandparent when parent session still exists", async () => { const parentSessionKey = "agent:main:subagent:parent"; - requesterDepthResolver = (sessionKey?: string) => - sessionKey === parentSessionKey ? 1 : sessionKey?.includes(":subagent:") ? 1 : 0; - subagentSessionRunActive = false; - shouldIgnorePostCompletion = false; - fallbackRequesterResolution = { - requesterSessionKey: "agent:main:main", - requesterOrigin: { channel: "discord", to: "chan-main", accountId: "acct-main" }, - }; + setupParentSessionFallback(parentSessionKey); // No sessionId on purpose: existence in store should still count as alive. sessionStore[parentSessionKey] = { updatedAt: Date.now() }; @@ -273,23 +279,14 @@ describe("subagent announce timeout config", () => { childSessionKey: `${parentSessionKey}:subagent:child`, }); - const directAgentCall = findGatewayCall( - (call) => call.method === "agent" && call.expectFinal === true, - ); + const directAgentCall = findFinalDirectAgentCall(); expect(directAgentCall?.params?.sessionKey).toBe(parentSessionKey); expect(directAgentCall?.params?.deliver).toBe(false); }); it("regression, falls back to grandparent only when parent subagent session is missing", async () => { const parentSessionKey = "agent:main:subagent:parent-missing"; - requesterDepthResolver = (sessionKey?: string) => - sessionKey === parentSessionKey ? 1 : sessionKey?.includes(":subagent:") ? 1 : 0; - subagentSessionRunActive = false; - shouldIgnorePostCompletion = false; - fallbackRequesterResolution = { - requesterSessionKey: "agent:main:main", - requesterOrigin: { channel: "discord", to: "chan-main", accountId: "acct-main" }, - }; + setupParentSessionFallback(parentSessionKey); await runAnnounceFlowForTest("run-parent-fallback", { requesterSessionKey: parentSessionKey, @@ -297,9 +294,7 @@ describe("subagent announce timeout config", () => { childSessionKey: `${parentSessionKey}:subagent:child`, }); - const directAgentCall = findGatewayCall( - (call) => call.method === "agent" && call.expectFinal === true, - ); + const directAgentCall = findFinalDirectAgentCall(); expect(directAgentCall?.params?.sessionKey).toBe("agent:main:main"); expect(directAgentCall?.params?.deliver).toBe(true); expect(directAgentCall?.params?.channel).toBe("discord"); From 3bc9d9177d1677df601f1f93e550513b16ddc589 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:33:47 +0000 Subject: [PATCH 19/23] test: share workspace skill test helpers --- ...erged-skills-into-target-workspace.test.ts | 78 ++++++++++--------- src/agents/skills.test.ts | 34 ++++---- 2 files changed, 53 insertions(+), 59 deletions(-) diff --git a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts index 0ee8a39a0b0..1f4da5163e1 100644 --- a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts @@ -25,6 +25,33 @@ async function createCaseDir(prefix: string): Promise { return dir; } +async function syncSourceSkillsToTarget(sourceWorkspace: string, targetWorkspace: string) { + await withEnv({ HOME: sourceWorkspace, PATH: "" }, () => + syncSkillsToWorkspace({ + sourceWorkspaceDir: sourceWorkspace, + targetWorkspaceDir: targetWorkspace, + bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), + managedSkillsDir: path.join(sourceWorkspace, ".managed"), + }), + ); +} + +async function expectSyncedSkillConfinement(params: { + sourceWorkspace: string; + targetWorkspace: string; + safeSkillDirName: string; + escapedDest: string; +}) { + expect(await pathExists(params.escapedDest)).toBe(false); + await syncSourceSkillsToTarget(params.sourceWorkspace, params.targetWorkspace); + expect( + await pathExists( + path.join(params.targetWorkspace, "skills", params.safeSkillDirName, "SKILL.md"), + ), + ).toBe(true); + expect(await pathExists(params.escapedDest)).toBe(false); +} + beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-sync-suite-")); syncSourceTemplateDir = await createCaseDir("source-template"); @@ -115,14 +142,7 @@ describe("buildWorkspaceSkillsPrompt", () => { "dir", ); - await withEnv({ HOME: sourceWorkspace, PATH: "" }, () => - syncSkillsToWorkspace({ - sourceWorkspaceDir: sourceWorkspace, - targetWorkspaceDir: targetWorkspace, - bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), - managedSkillsDir: path.join(sourceWorkspace, ".managed"), - }), - ); + await syncSourceSkillsToTarget(sourceWorkspace, targetWorkspace); const prompt = buildPrompt(targetWorkspace, { bundledSkillsDir: path.join(targetWorkspace, ".bundled"), @@ -151,21 +171,12 @@ describe("buildWorkspaceSkillsPrompt", () => { expect(path.relative(path.join(targetWorkspace, "skills"), escapedDest).startsWith("..")).toBe( true, ); - expect(await pathExists(escapedDest)).toBe(false); - - await withEnv({ HOME: sourceWorkspace, PATH: "" }, () => - syncSkillsToWorkspace({ - sourceWorkspaceDir: sourceWorkspace, - targetWorkspaceDir: targetWorkspace, - bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), - managedSkillsDir: path.join(sourceWorkspace, ".managed"), - }), - ); - - expect( - await pathExists(path.join(targetWorkspace, "skills", "safe-traversal-skill", "SKILL.md")), - ).toBe(true); - expect(await pathExists(escapedDest)).toBe(false); + await expectSyncedSkillConfinement({ + sourceWorkspace, + targetWorkspace, + safeSkillDirName: "safe-traversal-skill", + escapedDest, + }); }); it("keeps synced skills confined under target workspace when frontmatter name is absolute", async () => { const sourceWorkspace = await createCaseDir("source"); @@ -180,21 +191,12 @@ describe("buildWorkspaceSkillsPrompt", () => { description: "Absolute skill", }); - expect(await pathExists(absoluteDest)).toBe(false); - - await withEnv({ HOME: sourceWorkspace, PATH: "" }, () => - syncSkillsToWorkspace({ - sourceWorkspaceDir: sourceWorkspace, - targetWorkspaceDir: targetWorkspace, - bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), - managedSkillsDir: path.join(sourceWorkspace, ".managed"), - }), - ); - - expect( - await pathExists(path.join(targetWorkspace, "skills", "safe-absolute-skill", "SKILL.md")), - ).toBe(true); - expect(await pathExists(absoluteDest)).toBe(false); + await expectSyncedSkillConfinement({ + sourceWorkspace, + targetWorkspace, + safeSkillDirName: "safe-absolute-skill", + escapedDest: absoluteDest, + }); }); it("filters skills based on env/config gates", async () => { const workspaceDir = await createCaseDir("workspace"); diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 394f476ffa8..c5c8c2077d9 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -49,6 +49,16 @@ const withClearedEnv = ( } }; +async function writeEnvSkill(workspaceDir: string) { + const skillDir = path.join(workspaceDir, "skills", "env-skill"); + await writeSkill({ + dir: skillDir, + name: "env-skill", + description: "Needs env", + metadata: '{"openclaw":{"requires":{"env":["ENV_KEY"]},"primaryEnv":"ENV_KEY"}}', + }); +} + beforeAll(async () => { await fixtureSuite.setup(); tempHome = await createTempHomeEnv("openclaw-skills-home-"); @@ -240,13 +250,7 @@ describe("buildWorkspaceSkillsPrompt", () => { describe("applySkillEnvOverrides", () => { it("sets and restores env vars", async () => { const workspaceDir = await makeWorkspace(); - const skillDir = path.join(workspaceDir, "skills", "env-skill"); - await writeSkill({ - dir: skillDir, - name: "env-skill", - description: "Needs env", - metadata: '{"openclaw":{"requires":{"env":["ENV_KEY"]},"primaryEnv":"ENV_KEY"}}', - }); + await writeEnvSkill(workspaceDir); const entries = loadWorkspaceSkillEntries(workspaceDir, resolveTestSkillDirs(workspaceDir)); @@ -269,13 +273,7 @@ describe("applySkillEnvOverrides", () => { it("keeps env keys tracked until all overlapping overrides restore", async () => { const workspaceDir = await makeWorkspace(); - const skillDir = path.join(workspaceDir, "skills", "env-skill"); - await writeSkill({ - dir: skillDir, - name: "env-skill", - description: "Needs env", - metadata: '{"openclaw":{"requires":{"env":["ENV_KEY"]},"primaryEnv":"ENV_KEY"}}', - }); + await writeEnvSkill(workspaceDir); const entries = loadWorkspaceSkillEntries(workspaceDir, resolveTestSkillDirs(workspaceDir)); @@ -301,13 +299,7 @@ describe("applySkillEnvOverrides", () => { it("applies env overrides from snapshots", async () => { const workspaceDir = await makeWorkspace(); - const skillDir = path.join(workspaceDir, "skills", "env-skill"); - await writeSkill({ - dir: skillDir, - name: "env-skill", - description: "Needs env", - metadata: '{"openclaw":{"requires":{"env":["ENV_KEY"]},"primaryEnv":"ENV_KEY"}}', - }); + await writeEnvSkill(workspaceDir); const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { ...resolveTestSkillDirs(workspaceDir), From 6720bf5be060a75913aff06e1f067490b25adfd0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:37:43 +0000 Subject: [PATCH 20/23] refactor: share exec host approval helpers --- src/agents/bash-tools.exec-host-gateway.ts | 177 ++++++++------------ src/agents/bash-tools.exec-host-node.ts | 186 ++++++++------------- src/agents/bash-tools.exec-host-shared.ts | 142 ++++++++++++++++ 3 files changed, 279 insertions(+), 226 deletions(-) diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index ac6ed57aa72..149a4785dd5 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,5 +1,4 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; import { addAllowlistEntry, type ExecAsk, @@ -14,20 +13,22 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; import { logInfo } from "../logger.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; -import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { buildExecApprovalRequesterContext, buildExecApprovalTurnSourceContext, registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; import { + buildDefaultExecApprovalRequestArgs, + buildExecApprovalFollowupTarget, + buildExecApprovalPendingToolResult, + createExecApprovalDecisionState, createAndRegisterDefaultExecApprovalRequest, - resolveBaseExecApprovalDecision, resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, + sendExecApprovalFollowupResult, } from "./bash-tools.exec-host-shared.js"; import { - buildApprovalPendingMessage, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, normalizeNotifyOutput, @@ -140,6 +141,28 @@ export async function processGatewayAllowlist( } if (requiresAsk) { + const requestArgs = buildDefaultExecApprovalRequestArgs({ + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug, + turnSourceChannel: params.turnSourceChannel, + turnSourceAccountId: params.turnSourceAccountId, + }); + const registerGatewayApproval = async (approvalId: string) => + await registerExecApprovalRequestForHostOrThrow({ + approvalId, + command: params.command, + workdir: params.workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + ...buildExecApprovalRequesterContext({ + agentId: params.agentId, + sessionKey: params.sessionKey, + }), + resolvedPath: allowlistEval.segments[0]?.resolution?.resolvedPath, + ...buildExecApprovalTurnSourceContext(params), + }); const { approvalId, approvalSlug, @@ -150,57 +173,46 @@ export async function processGatewayAllowlist( sentApproverDms, unavailableReason, } = await createAndRegisterDefaultExecApprovalRequest({ - warnings: params.warnings, - approvalRunningNoticeMs: params.approvalRunningNoticeMs, - createApprovalSlug, - turnSourceChannel: params.turnSourceChannel, - turnSourceAccountId: params.turnSourceAccountId, - register: async (approvalId) => - await registerExecApprovalRequestForHostOrThrow({ - approvalId, - command: params.command, - workdir: params.workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - ...buildExecApprovalRequesterContext({ - agentId: params.agentId, - sessionKey: params.sessionKey, - }), - resolvedPath: allowlistEval.segments[0]?.resolution?.resolvedPath, - ...buildExecApprovalTurnSourceContext(params), - }), + ...requestArgs, + register: registerGatewayApproval, }); const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; const effectiveTimeout = typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; + const followupTarget = buildExecApprovalFollowupTarget({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + }); void (async () => { const decision = await resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - void sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, - }), + void sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, + ), }); if (decision === undefined) { return; } - const baseDecision = resolveBaseExecApprovalDecision({ + const { + baseDecision, + approvedByAsk: initialApprovedByAsk, + deniedReason: initialDeniedReason, + } = createExecApprovalDecisionState({ decision, askFallback, obfuscationDetected: obfuscation.detected, }); - let approvedByAsk = baseDecision.approvedByAsk; - let deniedReason = baseDecision.deniedReason; + let approvedByAsk = initialApprovedByAsk; + let deniedReason = initialDeniedReason; if (baseDecision.timedOut && askFallback === "allowlist") { if (!analysisOk || !allowlistSatisfied) { @@ -232,15 +244,10 @@ export async function processGatewayAllowlist( } if (deniedReason) { - await sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, - }).catch(() => {}); + await sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, + ); return; } @@ -266,15 +273,10 @@ export async function processGatewayAllowlist( timeoutSec: effectiveTimeout, }); } catch { - await sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, - }).catch(() => {}); + await sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, + ); return; } @@ -288,63 +290,22 @@ export async function processGatewayAllowlist( const summary = output ? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}` : `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`; - await sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: summary, - }).catch(() => {}); + await sendExecApprovalFollowupResult(followupTarget, summary); })(); return { - pendingResult: { - content: [ - { - type: "text", - text: - unavailableReason !== null - ? (buildExecApprovalUnavailableReplyPayload({ - warningText, - reason: unavailableReason, - channelLabel: initiatingSurface.channelLabel, - sentApproverDms, - }).text ?? "") - : buildApprovalPendingMessage({ - warningText, - approvalSlug, - approvalId, - command: params.command, - cwd: params.workdir, - host: "gateway", - }), - }, - ], - details: - unavailableReason !== null - ? ({ - status: "approval-unavailable", - reason: unavailableReason, - channelLabel: initiatingSurface.channelLabel, - sentApproverDms, - host: "gateway", - command: params.command, - cwd: params.workdir, - warningText, - } satisfies ExecToolDetails) - : ({ - status: "approval-pending", - approvalId, - approvalSlug, - expiresAtMs, - host: "gateway", - command: params.command, - cwd: params.workdir, - warningText, - } satisfies ExecToolDetails), - }, + pendingResult: buildExecApprovalPendingToolResult({ + host: "gateway", + command: params.command, + cwd: params.workdir, + warningText, + approvalId, + approvalSlug, + expiresAtMs, + initiatingSurface, + sentApproverDms, + unavailableReason, + }), }; } diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 6f5fc25f966..16af23590b4 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -1,6 +1,5 @@ import crypto from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; import { type ExecApprovalsFile, type ExecAsk, @@ -13,20 +12,13 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js"; import { logInfo } from "../logger.js"; -import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { buildExecApprovalRequesterContext, buildExecApprovalTurnSourceContext, registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; +import * as execHostShared from "./bash-tools.exec-host-shared.js"; import { - createAndRegisterDefaultExecApprovalRequest, - resolveBaseExecApprovalDecision, - resolveApprovalDecisionOrUndefined, - resolveExecHostApprovalContext, -} from "./bash-tools.exec-host-shared.js"; -import { - buildApprovalPendingMessage, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, normalizeNotifyOutput, @@ -61,7 +53,7 @@ export type ExecuteNodeHostCommandParams = { export async function executeNodeHostCommand( params: ExecuteNodeHostCommandParams, ): Promise> { - const { hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({ + const { hostSecurity, hostAsk, askFallback } = execHostShared.resolveExecHostApprovalContext({ agentId: params.agentId, security: params.security, ask: params.ask, @@ -216,6 +208,29 @@ export async function executeNodeHostCommand( }) satisfies Record; if (requiresAsk) { + const requestArgs = execHostShared.buildDefaultExecApprovalRequestArgs({ + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug, + turnSourceChannel: params.turnSourceChannel, + turnSourceAccountId: params.turnSourceAccountId, + }); + const registerNodeApproval = async (approvalId: string) => + await registerExecApprovalRequestForHostOrThrow({ + approvalId, + systemRunPlan: prepared.plan, + env: nodeEnv, + workdir: runCwd, + host: "node", + nodeId, + security: hostSecurity, + ask: hostAsk, + ...buildExecApprovalRequesterContext({ + agentId: runAgentId, + sessionKey: runSessionKey, + }), + ...buildExecApprovalTurnSourceContext(params), + }); const { approvalId, approvalSlug, @@ -225,57 +240,45 @@ export async function executeNodeHostCommand( initiatingSurface, sentApproverDms, unavailableReason, - } = await createAndRegisterDefaultExecApprovalRequest({ - warnings: params.warnings, - approvalRunningNoticeMs: params.approvalRunningNoticeMs, - createApprovalSlug, + } = await execHostShared.createAndRegisterDefaultExecApprovalRequest({ + ...requestArgs, + register: registerNodeApproval, + }); + const followupTarget = execHostShared.buildExecApprovalFollowupTarget({ + approvalId, + sessionKey: params.notifySessionKey, turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, turnSourceAccountId: params.turnSourceAccountId, - register: async (approvalId) => - await registerExecApprovalRequestForHostOrThrow({ - approvalId, - systemRunPlan: prepared.plan, - env: nodeEnv, - workdir: runCwd, - host: "node", - nodeId, - security: hostSecurity, - ask: hostAsk, - ...buildExecApprovalRequesterContext({ - agentId: runAgentId, - sessionKey: runSessionKey, - }), - ...buildExecApprovalTurnSourceContext(params), - }), + turnSourceThreadId: params.turnSourceThreadId, }); void (async () => { - const decision = await resolveApprovalDecisionOrUndefined({ + const decision = await execHostShared.resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - void sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, - }), + void execHostShared.sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, + ), }); if (decision === undefined) { return; } - const baseDecision = resolveBaseExecApprovalDecision({ + const { + baseDecision, + approvedByAsk: initialApprovedByAsk, + deniedReason: initialDeniedReason, + } = execHostShared.createExecApprovalDecisionState({ decision, askFallback, obfuscationDetected: obfuscation.detected, }); - let approvedByAsk = baseDecision.approvedByAsk; + let approvedByAsk = initialApprovedByAsk; let approvalDecision: "allow-once" | "allow-always" | null = null; - let deniedReason = baseDecision.deniedReason; + let deniedReason = initialDeniedReason; if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) { approvalDecision = "allow-once"; @@ -288,15 +291,10 @@ export async function executeNodeHostCommand( } if (deniedReason) { - await sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`, - }).catch(() => {}); + await execHostShared.sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`, + ); return; } @@ -330,76 +328,28 @@ export async function executeNodeHostCommand( const summary = output ? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}` : `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`; - await sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: summary, - }).catch(() => {}); + await execHostShared.sendExecApprovalFollowupResult(followupTarget, summary); } catch { - await sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`, - }).catch(() => {}); + await execHostShared.sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`, + ); } })(); - return { - content: [ - { - type: "text", - text: - unavailableReason !== null - ? (buildExecApprovalUnavailableReplyPayload({ - warningText, - reason: unavailableReason, - channelLabel: initiatingSurface.channelLabel, - sentApproverDms, - }).text ?? "") - : buildApprovalPendingMessage({ - warningText, - approvalSlug, - approvalId, - command: prepared.plan.commandText, - cwd: runCwd, - host: "node", - nodeId, - }), - }, - ], - details: - unavailableReason !== null - ? ({ - status: "approval-unavailable", - reason: unavailableReason, - channelLabel: initiatingSurface.channelLabel, - sentApproverDms, - host: "node", - command: params.command, - cwd: params.workdir, - nodeId, - warningText, - } satisfies ExecToolDetails) - : ({ - status: "approval-pending", - approvalId, - approvalSlug, - expiresAtMs, - host: "node", - command: params.command, - cwd: params.workdir, - nodeId, - warningText, - } satisfies ExecToolDetails), - }; + return execHostShared.buildExecApprovalPendingToolResult({ + host: "node", + command: params.command, + cwd: params.workdir, + warningText, + approvalId, + approvalSlug, + expiresAtMs, + initiatingSurface, + sentApproverDms, + unavailableReason, + nodeId, + }); } const startedAt = Date.now(); diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index e62bc8d484a..a9adaff17ee 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -1,5 +1,7 @@ import crypto from "node:crypto"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { loadConfig } from "../config/config.js"; +import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; import { hasConfiguredExecApprovalDmRoute, type ExecApprovalInitiatingSurfaceState, @@ -12,11 +14,14 @@ import { type ExecAsk, type ExecSecurity, } from "../infra/exec-approvals.js"; +import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { type ExecApprovalRegistration, resolveRegisteredExecApprovalDecision, } from "./bash-tools.exec-approval-request.js"; +import { buildApprovalPendingMessage } from "./bash-tools.exec-runtime.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js"; +import type { ExecToolDetails } from "./bash-tools.exec-types.js"; type ResolvedExecApprovals = ReturnType; @@ -53,6 +58,23 @@ export type RegisteredExecApprovalRequestContext = { unavailableReason: ExecApprovalUnavailableReason | null; }; +export type ExecApprovalFollowupTarget = { + approvalId: string; + sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; +}; + +export type DefaultExecApprovalRequestArgs = { + warnings: string[]; + approvalRunningNoticeMs: number; + createApprovalSlug: (approvalId: string) => string; + turnSourceChannel?: string; + turnSourceAccountId?: string; +}; + export function createExecApprovalPendingState(params: { warnings: string[]; timeoutMs: number; @@ -257,3 +279,123 @@ export async function createAndRegisterDefaultExecApprovalRequest(params: { unavailableReason, }; } + +export function buildDefaultExecApprovalRequestArgs( + params: DefaultExecApprovalRequestArgs, +): DefaultExecApprovalRequestArgs { + return { + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug: params.createApprovalSlug, + turnSourceChannel: params.turnSourceChannel, + turnSourceAccountId: params.turnSourceAccountId, + }; +} + +export function buildExecApprovalFollowupTarget( + params: ExecApprovalFollowupTarget, +): ExecApprovalFollowupTarget { + return { + approvalId: params.approvalId, + sessionKey: params.sessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + }; +} + +export function createExecApprovalDecisionState(params: { + decision: string | null | undefined; + askFallback: ResolvedExecApprovals["agent"]["askFallback"]; + obfuscationDetected: boolean; +}) { + const baseDecision = resolveBaseExecApprovalDecision({ + decision: params.decision ?? null, + askFallback: params.askFallback, + obfuscationDetected: params.obfuscationDetected, + }); + return { + baseDecision, + approvedByAsk: baseDecision.approvedByAsk, + deniedReason: baseDecision.deniedReason, + }; +} + +export async function sendExecApprovalFollowupResult( + target: ExecApprovalFollowupTarget, + resultText: string, +): Promise { + await sendExecApprovalFollowup({ + approvalId: target.approvalId, + sessionKey: target.sessionKey, + turnSourceChannel: target.turnSourceChannel, + turnSourceTo: target.turnSourceTo, + turnSourceAccountId: target.turnSourceAccountId, + turnSourceThreadId: target.turnSourceThreadId, + resultText, + }).catch(() => {}); +} + +export function buildExecApprovalPendingToolResult(params: { + host: "gateway" | "node"; + command: string; + cwd: string; + warningText: string; + approvalId: string; + approvalSlug: string; + expiresAtMs: number; + initiatingSurface: ExecApprovalInitiatingSurfaceState; + sentApproverDms: boolean; + unavailableReason: ExecApprovalUnavailableReason | null; + nodeId?: string; +}): AgentToolResult { + return { + content: [ + { + type: "text", + text: + params.unavailableReason !== null + ? (buildExecApprovalUnavailableReplyPayload({ + warningText: params.warningText, + reason: params.unavailableReason, + channelLabel: params.initiatingSurface.channelLabel, + sentApproverDms: params.sentApproverDms, + }).text ?? "") + : buildApprovalPendingMessage({ + warningText: params.warningText, + approvalSlug: params.approvalSlug, + approvalId: params.approvalId, + command: params.command, + cwd: params.cwd, + host: params.host, + nodeId: params.nodeId, + }), + }, + ], + details: + params.unavailableReason !== null + ? ({ + status: "approval-unavailable", + reason: params.unavailableReason, + channelLabel: params.initiatingSurface.channelLabel, + sentApproverDms: params.sentApproverDms, + host: params.host, + command: params.command, + cwd: params.cwd, + nodeId: params.nodeId, + warningText: params.warningText, + } satisfies ExecToolDetails) + : ({ + status: "approval-pending", + approvalId: params.approvalId, + approvalSlug: params.approvalSlug, + expiresAtMs: params.expiresAtMs, + host: params.host, + command: params.command, + cwd: params.cwd, + nodeId: params.nodeId, + warningText: params.warningText, + } satisfies ExecToolDetails), + }; +} From c5d905871f3cedaf98e14f781befbd68c52be0a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:38:55 +0000 Subject: [PATCH 21/23] test: share oauth profile fixtures --- src/agents/auth-profiles/oauth.test.ts | 74 ++++++++++++++------------ 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index c38d043c549..d4161b0d8ad 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -32,6 +32,20 @@ function tokenStore(params: { }; } +function githubCopilotTokenStore(profileId: string, includeInlineToken = true): AuthProfileStore { + return { + version: 1, + profiles: { + [profileId]: { + type: "token", + provider: "github-copilot", + ...(includeInlineToken ? { token: "" } : {}), + tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, + }, + }, + }; +} + async function resolveWithConfig(params: { profileId: string; provider: string; @@ -59,6 +73,25 @@ async function withEnvVar(key: string, value: string, run: () => Promise): } } +async function expectResolvedApiKey(params: { + profileId: string; + provider: string; + mode: "api_key" | "token" | "oauth"; + store: AuthProfileStore; + expectedApiKey: string; +}) { + const result = await resolveApiKeyForProfile({ + cfg: cfgFor(params.profileId, params.provider, params.mode), + store: params.store, + profileId: params.profileId, + }); + expect(result).toEqual({ + apiKey: params.expectedApiKey, // pragma: allowlist secret + provider: params.provider, + email: undefined, + }); +} + describe("resolveApiKeyForProfile config compatibility", () => { it("accepts token credentials when config mode is oauth", async () => { const profileId = "anthropic:token"; @@ -278,25 +311,12 @@ describe("resolveApiKeyForProfile secret refs", () => { it("resolves token tokenRef from env", async () => { const profileId = "github-copilot:default"; await withEnvVar("GITHUB_TOKEN", "gh-ref-token", async () => { - const result = await resolveApiKeyForProfile({ - cfg: cfgFor(profileId, "github-copilot", "token"), - store: { - version: 1, - profiles: { - [profileId]: { - type: "token", - provider: "github-copilot", - token: "", - tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, - }, - }, - }, + await expectResolvedApiKey({ profileId, - }); - expect(result).toEqual({ - apiKey: "gh-ref-token", // pragma: allowlist secret provider: "github-copilot", - email: undefined, + mode: "token", + store: githubCopilotTokenStore(profileId), + expectedApiKey: "gh-ref-token", // pragma: allowlist secret }); }); }); @@ -304,24 +324,12 @@ describe("resolveApiKeyForProfile secret refs", () => { it("resolves token tokenRef without inline token when expires is absent", async () => { const profileId = "github-copilot:no-inline-token"; await withEnvVar("GITHUB_TOKEN", "gh-ref-token", async () => { - const result = await resolveApiKeyForProfile({ - cfg: cfgFor(profileId, "github-copilot", "token"), - store: { - version: 1, - profiles: { - [profileId]: { - type: "token", - provider: "github-copilot", - tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, - }, - }, - }, + await expectResolvedApiKey({ profileId, - }); - expect(result).toEqual({ - apiKey: "gh-ref-token", // pragma: allowlist secret provider: "github-copilot", - email: undefined, + mode: "token", + store: githubCopilotTokenStore(profileId, false), + expectedApiKey: "gh-ref-token", // pragma: allowlist secret }); }); }); From 66e02b296fa7c18c04dc94fe071d916042a30137 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:39:42 +0000 Subject: [PATCH 22/23] test: share memory search config helpers --- src/agents/memory-search.test.ts | 142 ++++++++++++++----------------- 1 file changed, 63 insertions(+), 79 deletions(-) diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 8b1b4bc3494..feb0054b302 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -29,6 +29,56 @@ describe("memory search config", () => { }); } + function expectEmptyMultimodalConfig(resolved: ReturnType) { + expect(resolved?.multimodal).toEqual({ + enabled: true, + modalities: [], + maxFileBytes: 10 * 1024 * 1024, + }); + } + + function configWithRemoteDefaults(remote: Record) { + return asConfig({ + agents: { + defaults: { + memorySearch: { + provider: "openai", + remote, + }, + }, + list: [ + { + id: "main", + default: true, + memorySearch: { + remote: { + baseUrl: "https://agent.example/v1", + }, + }, + }, + ], + }, + }); + } + + function expectMergedRemoteConfig( + resolved: ReturnType, + apiKey: unknown, + ) { + expect(resolved?.remote).toEqual({ + baseUrl: "https://agent.example/v1", + apiKey, + headers: { "X-Default": "on" }, + batch: { + enabled: false, + wait: true, + concurrency: 2, + pollIntervalMs: 2000, + timeoutMinutes: 60, + }, + }); + } + it("returns null when disabled", () => { const cfg = asConfig({ agents: { @@ -171,11 +221,7 @@ describe("memory search config", () => { }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); - expect(resolved?.multimodal).toEqual({ - enabled: true, - modalities: [], - maxFileBytes: 10 * 1024 * 1024, - }); + expectEmptyMultimodalConfig(resolved); expect(resolved?.provider).toBe("gemini"); }); @@ -196,11 +242,7 @@ describe("memory search config", () => { }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); - expect(resolved?.multimodal).toEqual({ - enabled: true, - modalities: [], - maxFileBytes: 10 * 1024 * 1024, - }); + expectEmptyMultimodalConfig(resolved); }); it("rejects multimodal memory on unsupported providers", () => { @@ -289,85 +331,27 @@ describe("memory search config", () => { }); it("merges remote defaults with agent overrides", () => { - const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - remote: { - baseUrl: "https://default.example/v1", - apiKey: "default-key", // pragma: allowlist secret - headers: { "X-Default": "on" }, - }, - }, - }, - list: [ - { - id: "main", - default: true, - memorySearch: { - remote: { - baseUrl: "https://agent.example/v1", - }, - }, - }, - ], - }, - }); - const resolved = resolveMemorySearchConfig(cfg, "main"); - expect(resolved?.remote).toEqual({ - baseUrl: "https://agent.example/v1", + const cfg = configWithRemoteDefaults({ + baseUrl: "https://default.example/v1", apiKey: "default-key", // pragma: allowlist secret headers: { "X-Default": "on" }, - batch: { - enabled: false, - wait: true, - concurrency: 2, - pollIntervalMs: 2000, - timeoutMinutes: 60, - }, }); + const resolved = resolveMemorySearchConfig(cfg, "main"); + expectMergedRemoteConfig(resolved, "default-key"); // pragma: allowlist secret }); it("preserves SecretRef remote apiKey when merging defaults with agent overrides", () => { - const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - remote: { - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret - headers: { "X-Default": "on" }, - }, - }, - }, - list: [ - { - id: "main", - default: true, - memorySearch: { - remote: { - baseUrl: "https://agent.example/v1", - }, - }, - }, - ], - }, + const cfg = configWithRemoteDefaults({ + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + headers: { "X-Default": "on" }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); - expect(resolved?.remote).toEqual({ - baseUrl: "https://agent.example/v1", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - headers: { "X-Default": "on" }, - batch: { - enabled: false, - wait: true, - concurrency: 2, - pollIntervalMs: 2000, - timeoutMinutes: 60, - }, + expectMergedRemoteConfig(resolved, { + source: "env", + provider: "default", + id: "OPENAI_API_KEY", }); }); From bed661609e5cee4ca1ab95d8491e1ef49750cc48 Mon Sep 17 00:00:00 2001 From: Luke <92253590+ImLukeF@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:43:21 +1100 Subject: [PATCH 23/23] fix(macos): align minimum Node.js version with runtime guard (22.16.0) (#45640) * macOS: align minimum Node.js version with runtime guard * macOS: add boundary and failure-message coverage for RuntimeLocator * docs: add changelog note for the macOS runtime locator fix * credit: original fix direction from @sumleo, cleaned up and rebased in #45640 by @ImLukeF --- CHANGELOG.md | 1 + .../Sources/OpenClaw/RuntimeLocator.swift | 6 ++-- .../RuntimeLocatorTests.swift | 30 +++++++++++++++++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 349f47cb41a..a9374919ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom. - macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance. - Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks. +- macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo. ## 2026.3.12 diff --git a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift index 3112f57879b..6f1ef2b723d 100644 --- a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift +++ b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift @@ -54,7 +54,7 @@ enum RuntimeResolutionError: Error { enum RuntimeLocator { private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime") - private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0) + private static let minNode = RuntimeVersion(major: 22, minor: 16, patch: 0) static func resolve( searchPaths: [String] = CommandResolver.preferredPaths()) -> Result @@ -91,7 +91,7 @@ enum RuntimeLocator { switch error { case let .notFound(searchPaths): [ - "openclaw needs Node >=22.0.0 but found no runtime.", + "openclaw needs Node >=22.16.0 but found no runtime.", "PATH searched: \(searchPaths.joined(separator: ":"))", "Install Node: https://nodejs.org/en/download", ].joined(separator: "\n") @@ -105,7 +105,7 @@ enum RuntimeLocator { [ "Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).", "PATH searched: \(searchPaths.joined(separator: ":"))", - "Try reinstalling or pinning a supported version (Node >=22.0.0).", + "Try reinstalling or pinning a supported version (Node >=22.16.0).", ].joined(separator: "\n") } } diff --git a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift index 990c033445f..782dbd77212 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift @@ -16,7 +16,7 @@ struct RuntimeLocatorTests { @Test func `resolve succeeds with valid node`() throws { let script = """ #!/bin/sh - echo v22.5.0 + echo v22.16.0 """ let node = try self.makeTempExecutable(contents: script) let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) @@ -25,7 +25,23 @@ struct RuntimeLocatorTests { return } #expect(res.path == node.path) - #expect(res.version == RuntimeVersion(major: 22, minor: 5, patch: 0)) + #expect(res.version == RuntimeVersion(major: 22, minor: 16, patch: 0)) + } + + @Test func `resolve fails on boundary below minimum`() throws { + let script = """ + #!/bin/sh + echo v22.15.9 + """ + let node = try self.makeTempExecutable(contents: script) + let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .failure(.unsupported(_, found, required, path, _)) = result else { + Issue.record("Expected unsupported error, got \(result)") + return + } + #expect(found == RuntimeVersion(major: 22, minor: 15, patch: 9)) + #expect(required == RuntimeVersion(major: 22, minor: 16, patch: 0)) + #expect(path == node.path) } @Test func `resolve fails when too old`() throws { @@ -60,7 +76,17 @@ struct RuntimeLocatorTests { @Test func `describe failure includes paths`() { let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"])) + #expect(msg.contains("Node >=22.16.0")) #expect(msg.contains("PATH searched: /tmp/a:/tmp/b")) + + let parseMsg = RuntimeLocator.describeFailure( + .versionParse( + kind: .node, + raw: "garbage", + path: "/usr/local/bin/node", + searchPaths: ["/usr/local/bin"], + )) + #expect(parseMsg.contains("Node >=22.16.0")) } @Test func `runtime version parses with leading V and metadata`() {