Merge branch 'main' into feature/btw-ephemeral-side-turns
This commit is contained in:
commit
ecedddae81
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`() {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 }) => ({
|
||||
|
||||
@ -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();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}"`);
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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),
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
48
extensions/shared/channel-status-summary.ts
Normal file
48
extensions/shared/channel-status-summary.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
extensions/shared/config-schema-helpers.ts
Normal file
25
extensions/shared/config-schema-helpers.ts
Normal 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 "*"`,
|
||||
});
|
||||
}
|
||||
9
extensions/shared/deferred.ts
Normal file
9
extensions/shared/deferred.ts
Normal 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 };
|
||||
}
|
||||
18
extensions/shared/passive-monitor.ts
Normal file
18
extensions/shared/passive-monitor.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
14
extensions/shared/runtime.ts
Normal file
14
extensions/shared/runtime.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
33
extensions/test-utils/directory.ts
Normal file
33
extensions/test-utils/directory.ts
Normal 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>;
|
||||
};
|
||||
}
|
||||
43
extensions/test-utils/plugin-api.ts
Normal file
43
extensions/test-utils/plugin-api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
65
extensions/test-utils/send-config.ts
Normal file
65
extensions/test-utils/send-config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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 ({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 ✓.
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
9
src/agents/test-helpers/subagent-gateway.ts
Normal file
9
src/agents/test-helpers/subagent-gateway.ts
Normal 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 } : {},
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user