Merge branch 'main' into feature/btw-ephemeral-side-turns

This commit is contained in:
Val Alexander 2026-03-13 21:49:04 -05:00 committed by GitHub
commit ecedddae81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1361 additions and 1273 deletions

View File

@ -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

View File

@ -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<RuntimeResolution, RuntimeResolutionError>
@ -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")
}
}

View File

@ -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`() {

View File

@ -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<boolean>)
| 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",

View File

@ -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(

View File

@ -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 {

View File

@ -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<ResolvedGoogleChatAccount> = {
}
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({

View File

@ -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<ResolvedIMessageAccount> = {
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 }) => ({

View File

@ -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<ResolvedIrcAccount, IrcProbe> = {
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<ResolvedIrcAccount, IrcProbe> = {
abortSignal: ctx.abortSignal,
statusSink,
}),
stop: async (monitor) => {
monitor.stop();
},
});
},
},

View File

@ -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,
});
});

View File

@ -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(

View File

@ -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,

View File

@ -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,
});

View File

@ -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({

View File

@ -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<ResolvedMatrixAccount> = {
lastError: runtime?.lastError ?? null,
probe,
lastProbeAt: runtime?.lastProbeAt ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
...buildTrafficStatusSummary(runtime),
}),
},
gateway: {

View File

@ -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({

View File

@ -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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<void>();
const gate = createDeferred<void>();
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<void>();
const gate = createDeferred<void>();
const events: string[] = [];
const first = enqueueSend("!room:example.org", async () => {

View File

@ -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<ResolvedMattermostAccount> = {
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();

View File

@ -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,
});
});

View File

@ -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,
});

View File

@ -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,

View File

@ -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,

View File

@ -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<ResolvedNextcloudTalkAccount> =
setStatus: ctx.setStatus,
});
await runPassiveAccountLifecycle({
await runStoppablePassiveMonitor({
abortSignal: ctx.abortSignal,
start: async () =>
await monitorNextcloudTalkProvider({
@ -354,9 +354,6 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
abortSignal: ctx.abortSignal,
statusSink,
}),
stop: async (monitor) => {
monitor.stop();
},
});
},
logoutAccount: async ({ accountId, cfg }) => {

View File

@ -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,
});
});

View File

@ -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}"`);

View File

@ -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",
});

View File

@ -7,6 +7,10 @@ import {
mapAllowFromEntries,
type ChannelPlugin,
} from "openclaw/plugin-sdk/nostr";
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";
@ -160,14 +164,10 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
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,
@ -179,8 +179,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
...buildTrafficStatusSummary(runtime),
}),
},

View File

@ -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<string, unknown>) => Promise<void>;
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<string, unknown>) => 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 {

View File

@ -0,0 +1,48 @@
type PassiveChannelStatusSnapshot = {
configured?: boolean;
running?: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: unknown;
lastProbeAt?: number | null;
};
type TrafficStatusSnapshot = {
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
};
export function buildPassiveChannelStatusSummary<TExtra extends object>(
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<TExtra extends object>(
snapshot: PassiveChannelStatusSnapshot,
extra?: TExtra,
) {
return {
...buildPassiveChannelStatusSummary(snapshot, extra),
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
};
}
export function buildTrafficStatusSummary<TSnapshot extends TrafficStatusSnapshot>(
snapshot?: TSnapshot | null,
) {
return {
lastInboundAt: snapshot?.lastInboundAt ?? null,
lastOutboundAt: snapshot?.lastOutboundAt ?? null,
};
}

View File

@ -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 "*"`,
});
}

View File

@ -0,0 +1,9 @@
export function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

View File

@ -0,0 +1,18 @@
import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk";
type StoppableMonitor = {
stop: () => void;
};
export async function runStoppablePassiveMonitor<TMonitor extends StoppableMonitor>(params: {
abortSignal: AbortSignal;
start: () => Promise<TMonitor>;
}): Promise<void> {
await runPassiveAccountLifecycle({
abortSignal: params.abortSignal,
start: params.start,
stop: async (monitor) => {
monitor.stop();
},
});
}

View File

@ -0,0 +1,14 @@
import { createLoggerBackedRuntime } from "openclaw/plugin-sdk";
export function resolveLoggerBackedRuntime<TRuntime>(
runtime: TRuntime | undefined,
logger: Parameters<typeof createLoggerBackedRuntime>[0]["logger"],
): TRuntime {
return (
runtime ??
(createLoggerBackedRuntime({
logger,
exitError: () => new Error("Runtime exit not available"),
}) as TRuntime)
);
}

View File

@ -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<ResolvedSlackAccount> = {
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) {

View File

@ -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<typeof directory.listPeers>;
listGroups: NonNullable<typeof directory.listGroups>;
};
}

View File

@ -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<T extends object>(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,
};
}

View File

@ -0,0 +1,65 @@
import { expect } from "vitest";
type MockFn = (...args: never[]) => unknown;
type CfgThreadingAssertion<TCfg> = {
loadConfig: MockFn;
resolveAccount: MockFn;
cfg: TCfg;
accountId?: string;
};
type SendRuntimeState = {
loadConfig: MockFn;
resolveMarkdownTableMode: MockFn;
convertMarkdownTables: MockFn;
record: MockFn;
};
export function expectProvidedCfgSkipsRuntimeLoad<TCfg>({
loadConfig,
resolveAccount,
cfg,
accountId,
}: CfgThreadingAssertion<TCfg>): void {
expect(loadConfig).not.toHaveBeenCalled();
expect(resolveAccount).toHaveBeenCalledWith({
cfg,
accountId,
});
}
export function expectRuntimeCfgFallback<TCfg>({
loadConfig,
resolveAccount,
cfg,
accountId,
}: CfgThreadingAssertion<TCfg>): 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,
},
},
};
}

View File

@ -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<TwitchAccountConfig> = {
},
/** 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 ({

View File

@ -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,

View File

@ -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<ResolvedZalouserAccount> = {
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);

View File

@ -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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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;

View File

@ -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<T>(key: string, value: string, run: () => Promise<T>):
}
}
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
});
});
});

View File

@ -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,
}),
};
}

View File

@ -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<AgentToolResult<ExecToolDetails>> {
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<string, unknown>;
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();

View File

@ -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<typeof resolveExecApprovals>;
@ -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<void> {
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<ExecToolDetails> {
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),
};
}

View File

@ -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<string, { models: Array<{ id: string; contextWindow: number }> }>,
) {
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 ✓.

View File

@ -29,6 +29,56 @@ describe("memory search config", () => {
});
}
function expectEmptyMultimodalConfig(resolved: ReturnType<typeof resolveMemorySearchConfig>) {
expect(resolved?.multimodal).toEqual({
enabled: true,
modalities: [],
maxFileBytes: 10 * 1024 * 1024,
});
}
function configWithRemoteDefaults(remote: Record<string, unknown>) {
return asConfig({
agents: {
defaults: {
memorySearch: {
provider: "openai",
remote,
},
},
list: [
{
id: "main",
default: true,
memorySearch: {
remote: {
baseUrl: "https://agent.example/v1",
},
},
},
],
},
});
}
function expectMergedRemoteConfig(
resolved: ReturnType<typeof resolveMemorySearchConfig>,
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",
});
});

View File

@ -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<Record<string, unknown>>) {
return {
models: {
providers: {
[provider]: {
baseUrl: `https://${provider}.example.com`,
models,
},
},
},
} as Partial<OpenClawConfig>;
}
function resolveConfiguredRefForTest(cfg: Partial<OpenClawConfig>) {
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<OpenClawConfig> = {
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<OpenClawConfig> = {
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<OpenClawConfig> = {
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" });
});

View File

@ -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> = {}): 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<string, { apiKey?: string; baseUrl?: string }>;
}>();
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<string, { apiKey?: string; baseUrl?: string }>;
}>();
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<string, { apiKey?: string }>;
}>();
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
});
});

View File

@ -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<string, Record<string, unknown>>;
};
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"));

View File

@ -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", () => {

View File

@ -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);

View File

@ -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" });

View File

@ -79,6 +79,36 @@ export function createSandbox(overrides?: Partial<SandboxContext>): 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<T>(
prefix: string,
run: (stateDir: string) => Promise<T>,

View File

@ -25,6 +25,33 @@ async function createCaseDir(prefix: string): Promise<string> {
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");

View File

@ -43,22 +43,44 @@ function withWorkspaceHome<T>(workspaceDir: string, cb: () => T): T {
return withEnv({ HOME: workspaceDir, PATH: "" }, cb);
}
function buildSnapshot(
workspaceDir: string,
options?: Parameters<typeof buildWorkspaceSkillSnapshot>[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<string> {
const cloned = await fixtureSuite.createCaseDir(prefix);
await fs.cp(templateDir, cloned, { recursive: true });
return cloned;
}
function expectSnapshotNamesAndPrompt(
snapshot: ReturnType<typeof buildWorkspaceSkillSnapshot>,
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"],
});
});
});

View File

@ -49,6 +49,16 @@ const withClearedEnv = <T>(
}
};
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),

View File

@ -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");

View File

@ -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<string, unknown>) {
}
function setupGatewayMock() {
hoisted.callGatewayMock.mockImplementation(
async (opts: { method?: string; params?: Record<string, unknown> }) => {
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",
});
});
});

View File

@ -0,0 +1,9 @@
export function installAcceptedSubagentGatewayMock(mock: {
mockImplementation: (
impl: (opts: { method?: string; params?: unknown }) => Promise<unknown>,
) => unknown;
}) {
mock.mockImplementation(async ({ method }) =>
method === "agent" ? { runId: "run-1" } : method?.startsWith("sessions.") ? { ok: true } : {},
);
}