Compare commits

...

26 Commits

Author SHA1 Message Date
Peter Steinberger
af826ae7fc fix: unblock bundled runtime ci lanes 2026-04-06 01:01:17 +01:00
Peter Steinberger
f33b5426aa ci: retrigger pull request workflow 2026-04-06 00:37:23 +01:00
Peter Steinberger
df1a008584 fix: stabilize line and zalo ci shards 2026-04-06 00:33:33 +01:00
Peter Steinberger
cc7cbc0580 test: fix markdown tables mock typing 2026-04-05 23:56:48 +01:00
Peter Steinberger
2dc0ea95d7 fix: restore provider runtime hook typing 2026-04-05 23:53:32 +01:00
Peter Steinberger
224f195709 chore: retrigger ci workflow 2026-04-05 23:49:52 +01:00
Peter Steinberger
c422f10aef fix: harden bootstrap config discovery 2026-04-05 23:49:52 +01:00
Peter Steinberger
619da391ab fix: guard bootstrap channel config lookup 2026-04-05 23:49:52 +01:00
Peter Steinberger
2d886f03a7 chore: retrigger ci 2026-04-05 23:49:52 +01:00
Peter Steinberger
6a53001e2f style: normalize ci regression tests 2026-04-05 23:49:52 +01:00
Peter Steinberger
0843ad5ad1 fix: stabilize bundled channel bootstrap 2026-04-05 23:49:52 +01:00
Peter Steinberger
960a631b25 test: remove duplicate google auth mode keys 2026-04-05 23:49:52 +01:00
Peter Steinberger
607d341451 fix: repair ci seams after main rebase 2026-04-05 23:49:52 +01:00
Peter Steinberger
b6da5443fc fix(google): restore gemini cli provider hooks 2026-04-05 23:49:52 +01:00
Peter Steinberger
a84858d315 ci: retrigger stalled workflow 2026-04-05 23:49:34 +01:00
Peter Steinberger
23549694f7 fix: repair bundled contract test surfaces 2026-04-05 23:49:34 +01:00
Peter Steinberger
4a5fe2e0e7 fix: restore mattermost bundled entry root 2026-04-05 23:49:34 +01:00
Peter Steinberger
32ae0eba54 chore: retrigger ci 2026-04-05 23:49:34 +01:00
Peter Steinberger
24f76d04eb test: seed setup helper registry 2026-04-05 23:49:34 +01:00
Peter Steinberger
5b53ddcc5f fix: align gateway config mock 2026-04-05 23:49:34 +01:00
Peter Steinberger
51ff658586 chore: retrigger ci 2026-04-05 23:49:34 +01:00
Peter Steinberger
be85d9aaec fix: unblock extension ci 2026-04-05 23:49:34 +01:00
Peter Steinberger
fd1b355f84 fix: rebase ci follow-ups 2026-04-05 23:49:34 +01:00
Peter Steinberger
b9a9290dfc fix: satisfy ci checks 2026-04-05 23:49:34 +01:00
Peter Steinberger
b141da4ca9 fix: restore ci guards 2026-04-05 23:49:34 +01:00
Peter Steinberger
4857f9d0c2 refactor: share plugin update install args 2026-04-05 23:49:34 +01:00
73 changed files with 1084 additions and 302 deletions

View File

@@ -1,5 +1,7 @@
name: CI
# Keep PR CI synchronized on branch updates.
on:
push:
branches: [main]

View File

@@ -1,4 +1,4 @@
57a3b1cc7d573c3788a670d927eac947fb1685384804f5c3c926f702a27fe00b config-baseline.json
82163136ff466db3caa61290fd65a8b8dd9487fc61f3871c177f96fcecf9e29b config-baseline.core.json
0135fa04d71f209a54b076f41a3f6cb9795c9169fa631364fb3561eb5ff89891 config-baseline.json
0e93c22a45545e13c74647f4945e9d8540d359640ed8c364b0f2514c9dc7a66c config-baseline.core.json
ae67508350baf891b902348d55fada6c17e9c053adf53aaf3a8b92cd364ef3f1 config-baseline.channel.json
d972a11d0f86080a722bddfe48990dd1b8fa16eb8e157e83f49bd46a5941c512 config-baseline.plugin.json

View File

@@ -180,6 +180,7 @@ Hook guard semantics to keep in mind:
- `before_tool_call`: `{ requireApproval: true }` pauses agent execution and prompts the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel.
- `before_install`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_install`: `{ block: false }` is treated as no decision.
- `tool_result_persist`: must stay synchronous because it runs in the transcript persistence path; return an updated tool result payload or `undefined` to keep the original.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is treated as no decision.

View File

@@ -0,0 +1,2 @@
export { discordPlugin } from "./src/channel.js";
export { discordSetupPlugin } from "./src/channel.setup.js";

View File

@@ -15,7 +15,7 @@ export default defineBundledChannelEntry({
description: "Discord channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./channel-entry.js",
exportName: "discordPlugin",
},
runtime: {

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./channel-entry.js",
exportName: "discordSetupPlugin",
},
});

View File

@@ -21,8 +21,8 @@ describe("fal video generation provider", () => {
it("posts to the model endpoint and downloads the returned video URL", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-key",
source: "env",
mode: "api-key",
source: "env",
});
vi.spyOn(providerHttp, "resolveProviderHttpRequestConfig").mockReturnValue({
baseUrl: "https://fal.run",

View File

@@ -77,6 +77,8 @@ describe("broadcast dispatch", () => {
const resolveEnvelopeFormatOptionsMock: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"] =
() => ({}) satisfies EnvelopeFormatOptions;
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
const mockResolveStorePath = vi.fn(() => "/tmp/feishu-sessions.json");
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/inbound-clip.mp4",
contentType: "video/mp4",
@@ -89,6 +91,12 @@ describe("broadcast dispatch", () => {
routing: {
resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params),
},
session: {
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
resolveStorePath:
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
},
reply: {
resolveEnvelopeFormatOptions: resolveEnvelopeFormatOptionsMock,
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
@@ -122,6 +130,7 @@ describe("broadcast dispatch", () => {
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-broadcast-group": {
requireMention: true,
@@ -247,6 +256,7 @@ describe("broadcast dispatch", () => {
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-broadcast-group": {
requireMention: false,
@@ -288,6 +298,7 @@ describe("broadcast dispatch", () => {
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-broadcast-group": {
requireMention: false,
@@ -334,6 +345,7 @@ describe("broadcast dispatch", () => {
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-broadcast-group": {
requireMention: false,

View File

@@ -159,11 +159,13 @@ export default definePluginEntry({
resolveDynamicModel: (ctx) =>
resolveGoogleGeminiForwardCompatModel({
providerId: ctx.provider,
templateProviderId: "google-gemini-cli",
ctx,
}),
...GOOGLE_GEMINI_PROVIDER_HOOKS,
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
});
registerGoogleGeminiCliProvider(api);
api.registerImageGenerationProvider(createLazyGoogleImageGenerationProvider());
api.registerMediaUnderstandingProvider(createLazyGoogleMediaUnderstandingProvider());
api.registerVideoGenerationProvider(buildGoogleVideoGenerationProvider());

View File

@@ -10,6 +10,7 @@ const GEMINI_2_5_FLASH_PREFIX = "gemini-2.5-flash";
const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro";
const GEMINI_3_1_FLASH_LITE_PREFIX = "gemini-3.1-flash-lite";
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";
const GOOGLE_GEMINI_CLI_PROVIDER_ID = "google-gemini-cli";
const GEMINI_2_5_PRO_TEMPLATE_IDS = ["gemini-2.5-pro"] as const;
const GEMINI_2_5_FLASH_LITE_TEMPLATE_IDS = ["gemini-2.5-flash-lite"] as const;
const GEMINI_2_5_FLASH_TEMPLATE_IDS = ["gemini-2.5-flash"] as const;
@@ -18,18 +19,26 @@ const GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS = ["gemini-3.1-flash-lite-preview"] as
const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const;
type GoogleForwardCompatFamily = {
googleTemplateIds: readonly string[];
cliTemplateIds: readonly string[];
preferExternalFirstForCli?: boolean;
};
type GoogleTemplateSource = {
templateProviderId: string;
templateIds: readonly string[];
};
function cloneGoogleTemplateModel(params: {
providerId: string;
modelId: string;
templateProviderId: string;
templateIds: readonly string[];
ctx: ProviderResolveDynamicModelContext;
patch?: Partial<ProviderRuntimeModel>;
}): ProviderRuntimeModel | undefined {
return cloneFirstTemplateModel({
providerId: params.providerId,
providerId: params.templateProviderId,
modelId: params.modelId,
templateIds: params.templateIds,
ctx: params.ctx,
@@ -40,36 +49,121 @@ function cloneGoogleTemplateModel(params: {
});
}
function isGoogleGeminiCliProvider(providerId: string): boolean {
return providerId.trim().toLowerCase() === GOOGLE_GEMINI_CLI_PROVIDER_ID;
}
function templateIdsForProvider(
templateProviderId: string,
family: GoogleForwardCompatFamily,
): readonly string[] {
return isGoogleGeminiCliProvider(templateProviderId)
? family.cliTemplateIds
: family.googleTemplateIds;
}
function buildGoogleTemplateSources(params: {
providerId: string;
templateProviderId?: string;
family: GoogleForwardCompatFamily;
}): GoogleTemplateSource[] {
const defaultTemplateProviderId = params.templateProviderId?.trim()
? params.templateProviderId
: isGoogleGeminiCliProvider(params.providerId)
? "google"
: GOOGLE_GEMINI_CLI_PROVIDER_ID;
const preferredExternalFirst =
isGoogleGeminiCliProvider(params.providerId) &&
params.family.preferExternalFirstForCli === true;
const orderedTemplateProviderIds = preferredExternalFirst
? [defaultTemplateProviderId, params.providerId]
: [params.providerId, defaultTemplateProviderId];
const seen = new Set<string>();
const sources: GoogleTemplateSource[] = [];
for (const providerId of orderedTemplateProviderIds) {
const trimmed = providerId?.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
sources.push({
templateProviderId: trimmed,
templateIds: templateIdsForProvider(trimmed, params.family),
});
}
return sources;
}
export function resolveGoogleGeminiForwardCompatModel(params: {
providerId: string;
templateProviderId?: string;
ctx: ProviderResolveDynamicModelContext;
}): ProviderRuntimeModel | undefined {
const trimmed = params.ctx.modelId.trim();
const lower = trimmed.toLowerCase();
let family: GoogleForwardCompatFamily;
let patch: Partial<ProviderRuntimeModel> | undefined;
if (lower.startsWith(GEMINI_2_5_PRO_PREFIX)) {
family = { templateIds: GEMINI_2_5_PRO_TEMPLATE_IDS };
family = {
googleTemplateIds: GEMINI_2_5_PRO_TEMPLATE_IDS,
cliTemplateIds: GEMINI_3_1_PRO_TEMPLATE_IDS,
preferExternalFirstForCli: true,
};
} else if (lower.startsWith(GEMINI_2_5_FLASH_LITE_PREFIX)) {
family = { templateIds: GEMINI_2_5_FLASH_LITE_TEMPLATE_IDS };
family = {
googleTemplateIds: GEMINI_2_5_FLASH_LITE_TEMPLATE_IDS,
cliTemplateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS,
preferExternalFirstForCli: true,
};
} else if (lower.startsWith(GEMINI_2_5_FLASH_PREFIX)) {
family = { templateIds: GEMINI_2_5_FLASH_TEMPLATE_IDS };
family = {
googleTemplateIds: GEMINI_2_5_FLASH_TEMPLATE_IDS,
cliTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS,
preferExternalFirstForCli: true,
};
} else if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) {
family = { templateIds: GEMINI_3_1_PRO_TEMPLATE_IDS };
family = {
googleTemplateIds: GEMINI_3_1_PRO_TEMPLATE_IDS,
cliTemplateIds: GEMINI_3_1_PRO_TEMPLATE_IDS,
};
if (params.providerId === "google" || params.providerId === GOOGLE_GEMINI_CLI_PROVIDER_ID) {
patch = { reasoning: true };
}
} else if (lower.startsWith(GEMINI_3_1_FLASH_LITE_PREFIX)) {
family = { templateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS };
family = {
googleTemplateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS,
cliTemplateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS,
};
} else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) {
family = { templateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS };
family = {
googleTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS,
cliTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS,
};
} else {
return undefined;
}
return cloneGoogleTemplateModel({
for (const source of buildGoogleTemplateSources({
providerId: params.providerId,
modelId: trimmed,
templateIds: family.templateIds,
ctx: params.ctx,
});
templateProviderId: params.templateProviderId,
family,
})) {
const model = cloneGoogleTemplateModel({
providerId: params.providerId,
modelId: trimmed,
templateProviderId: source.templateProviderId,
templateIds: source.templateIds,
ctx: params.ctx,
patch,
});
if (model) {
return model;
}
}
return undefined;
}
export function isModernGoogleModel(modelId: string): boolean {

View File

@@ -37,8 +37,8 @@ describe("google video generation provider", () => {
it("submits generation and returns inline video bytes", async () => {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-key",
source: "env",
mode: "api-key",
source: "env",
});
generateVideosMock.mockResolvedValue({
done: false,
@@ -100,8 +100,8 @@ describe("google video generation provider", () => {
it("rejects mixed image and video inputs", async () => {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-key",
source: "env",
mode: "api-key",
source: "env",
});
const provider = buildGoogleVideoGenerationProvider();

View File

@@ -5,34 +5,146 @@ import type { LineAccountConfig } from "./types.js";
// Avoid pulling in globals/pairing/media dependencies; this suite only asserts
// allowlist/groupPolicy gating and message-context wiring.
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
"openclaw/plugin-sdk/runtime-env",
);
return {
...actual,
danger: (text: string) => text,
logVerbose: () => {},
shouldLogVerbose: () => false,
};
});
vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
buildMentionRegexes: () => [],
matchesMentionPatterns: () => false,
resolveMentionGatingWithBypass: ({
isGroup,
requireMention,
canDetectMention,
wasMentioned,
hasAnyMention,
allowTextCommands,
hasControlCommand,
commandAuthorized,
}: {
isGroup: boolean;
requireMention: boolean;
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
}) => ({
shouldSkip:
isGroup &&
requireMention &&
canDetectMention &&
!wasMentioned &&
!(allowTextCommands && hasControlCommand && commandAuthorized && !hasAnyMention),
}),
}));
vi.mock("openclaw/plugin-sdk/channel-pairing", () => ({
createChannelPairingChallengeIssuer:
({ upsertPairingRequest }: { upsertPairingRequest: (args: unknown) => Promise<unknown> }) =>
async ({ senderId, onCreated }: { senderId: string; onCreated?: () => void }) => {
await upsertPairingRequest({ id: senderId, meta: {} });
onCreated?.();
},
}));
vi.mock("openclaw/plugin-sdk/command-auth", () => ({
hasControlCommand: (text: string) => text.trim().startsWith("!"),
resolveControlCommandGate: ({
hasControlCommand,
authorizers,
}: {
hasControlCommand: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
}) => ({
commandAuthorized:
hasControlCommand && authorizers.some((entry) => entry.allowed || entry.configured === false),
}),
}));
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
resolveAllowlistProviderRuntimeGroupPolicy: ({
groupPolicy,
defaultGroupPolicy,
}: {
groupPolicy?: string;
defaultGroupPolicy: string;
}) => ({
groupPolicy: groupPolicy ?? defaultGroupPolicy,
providerMissingFallbackApplied: false,
}),
resolveDefaultGroupPolicy: (cfg: { channels?: { line?: { groupPolicy?: string } } }) =>
cfg.channels?.line?.groupPolicy ?? "open",
warnMissingProviderGroupPolicyFallbackOnce: () => {},
}));
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
danger: (text: string) => text,
logVerbose: () => {},
}));
vi.mock("openclaw/plugin-sdk/group-access", () => ({
evaluateMatchedGroupAccessForPolicy: ({
groupPolicy,
hasMatchInput,
allowlistConfigured,
allowlistMatched,
}: {
groupPolicy: string;
hasMatchInput: boolean;
allowlistConfigured: boolean;
allowlistMatched: boolean;
}) => {
if (groupPolicy === "disabled") {
return { allowed: false, reason: "disabled" };
}
if (groupPolicy !== "allowlist") {
return { allowed: true, reason: null };
}
if (!hasMatchInput) {
return { allowed: false, reason: "missing_match_input" };
}
if (!allowlistConfigured) {
return { allowed: false, reason: "empty_allowlist" };
}
if (!allowlistMatched) {
return { allowed: false, reason: "not_allowlisted" };
}
return { allowed: true, reason: null };
},
}));
vi.mock("openclaw/plugin-sdk/reply-history", () => ({
DEFAULT_GROUP_HISTORY_LIMIT: 20,
clearHistoryEntriesIfEnabled: ({
historyMap,
historyKey,
}: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
}) => {
historyMap.delete(historyKey);
},
recordPendingHistoryEntryIfEnabled: ({
historyMap,
historyKey,
limit,
entry,
}: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
limit: number;
entry: HistoryEntry;
}) => {
const existing = historyMap.get(historyKey) ?? [];
historyMap.set(historyKey, [...existing, entry].slice(-limit));
},
}));
vi.mock("openclaw/plugin-sdk/routing", () => ({
resolveAgentRoute: () => ({ agentId: "default" }),
}));
const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({
readAllowFromStoreMock: vi.fn(async () => [] as string[]),
upsertPairingRequestMock: vi.fn(async () => ({ code: "CODE", created: true })),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
);
return {
...actual,
resolvePairingIdLabel: () => "lineUserId",
readChannelAllowFromStore: readAllowFromStoreMock,
upsertChannelPairingRequest: upsertPairingRequestMock,
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
resolvePairingIdLabel: () => "lineUserId",
readChannelAllowFromStore: readAllowFromStoreMock,
upsertChannelPairingRequest: upsertPairingRequestMock,
}));
vi.mock("./download.js", () => ({
downloadLineMedia: async () => {

View File

@@ -10,7 +10,8 @@ import {
} from "./channel-api.js";
import { getLineRuntime } from "./runtime.js";
const loadLineChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const loadLineProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
const loadLineMonitorRuntime = createLazyRuntimeModule(() => import("./monitor.runtime.js"));
export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["gateway"]> = {
startAccount: async (ctx) => {
@@ -30,7 +31,7 @@ export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>[
let lineBotLabel = "";
try {
const probe = await (await loadLineChannelRuntime()).probeLineBot(token, 2500);
const probe = await (await loadLineProbeRuntime()).probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) {
lineBotLabel = ` (${displayName})`;
@@ -45,7 +46,7 @@ export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>[
const monitorLineProvider =
getLineRuntime().channel.line?.monitorLineProvider ??
(await loadLineChannelRuntime()).monitorLineProvider;
(await loadLineMonitorRuntime()).monitorLineProvider;
return await monitorLineProvider({
channelAccessToken: token,

View File

@@ -0,0 +1 @@
export { monitorLineProvider } from "./monitor.js";

View File

@@ -0,0 +1 @@
export { probeLineBot } from "./probe.js";

View File

@@ -28,7 +28,6 @@ vi.mock("@line/bot-sdk", () => ({
}));
const lineConfigure = createPluginSetupWizardConfigure(linePlugin);
let probeLineBot: typeof import("./probe.js").probeLineBot;
const LINE_SRC_PREFIX = `../../${bundledPluginRoot("line")}/src/`;
function normalizeModuleSpecifier(specifier: string): string | null {
@@ -296,10 +295,6 @@ describe("line setup wizard", () => {
});
describe("probeLineBot", () => {
beforeAll(async () => {
({ probeLineBot } = await import("./probe.js"));
});
beforeEach(() => {
getBotInfoMock.mockReset();
MessagingApiClientMock.mockReset();
@@ -315,6 +310,7 @@ describe("probeLineBot", () => {
});
it("returns timeout when bot info stalls", async () => {
const { probeLineBot } = await import("./probe.js");
vi.useFakeTimers();
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
@@ -327,6 +323,7 @@ describe("probeLineBot", () => {
});
it("returns bot info when available", async () => {
const { probeLineBot } = await import("./probe.js");
getBotInfoMock.mockResolvedValue({
displayName: "OpenClaw",
userId: "U123",
@@ -343,6 +340,7 @@ describe("probeLineBot", () => {
describe("linePlugin status.probeAccount", () => {
it("falls back to the direct probe helper when runtime is not initialized", async () => {
const { probeLineBot } = await import("./probe.js");
MessagingApiClientMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return { getBotInfo: getBotInfoMock };

View File

@@ -8,7 +8,7 @@ import {
import { hasLineCredentials } from "./account-helpers.js";
import { DEFAULT_ACCOUNT_ID, type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
const loadLineChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const loadLineProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
const collectLineStatusIssues = createDependentCredentialStatusIssueCollector({
channel: "line",
@@ -23,7 +23,7 @@ export const lineStatusAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["
collectStatusIssues: collectLineStatusIssues,
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
await (await loadLineChannelRuntime()).probeLineBot(account.channelAccessToken, timeoutMs),
await (await loadLineProbeRuntime()).probeLineBot(account.channelAccessToken, timeoutMs),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name,

View File

@@ -1,4 +1,5 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
export { registerMatrixCliMetadata } from "./src/cli-metadata.js";
import { registerMatrixCliMetadata } from "./src/cli-metadata.js";
export default definePluginEntry({

View File

@@ -1,20 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
const cliMocks = vi.hoisted(() => ({
registerMatrixCli: vi.fn(),
}));
vi.mock("./src/cli.js", async () => {
const actual = await vi.importActual<typeof import("./src/cli.js")>("./src/cli.js");
return {
...actual,
registerMatrixCli: cliMocks.registerMatrixCli,
};
});
import matrixPlugin from "./index.js";
type CommandNode = {
command: ReturnType<typeof vi.fn>;
} & Record<string, ReturnType<typeof vi.fn>>;
function createCommandNode(): CommandNode {
const methods = new Map<string, ReturnType<typeof vi.fn>>();
let node = {} as CommandNode;
node = new Proxy(node, {
get(_target, prop) {
if (typeof prop !== "string") {
return undefined;
}
const existing = methods.get(prop);
if (existing) {
return existing;
}
const fn = prop === "command" ? vi.fn(() => createCommandNode()) : vi.fn(() => node);
methods.set(prop, fn);
return fn;
},
}) as CommandNode;
return node;
}
describe("matrix plugin", () => {
it("registers matrix CLI through a descriptor-backed lazy registrar", async () => {
const registerCli = vi.fn();
@@ -43,13 +54,12 @@ describe("matrix plugin", () => {
],
});
expect(typeof registrar).toBe("function");
expect(cliMocks.registerMatrixCli).not.toHaveBeenCalled();
const program = { command: vi.fn() };
const program = createCommandNode();
const result = registrar?.({ program } as never);
await result;
expect(cliMocks.registerMatrixCli).toHaveBeenCalledWith({ program });
expect(program.command).toHaveBeenCalledWith("matrix");
expect(registerGatewayMethod).not.toHaveBeenCalled();
});

View File

@@ -159,7 +159,7 @@ describe("matrix onboarding", () => {
expect(noteText).toContain("MATRIX_<ACCOUNT_ID>_DEVICE_NAME");
});
it("prompts for private-network access when onboarding an internal http homeserver", async () => {
it("accepts loopback http homeservers without storing private-network opt-in", async () => {
installMatrixTestRuntime();
const prompter = createMatrixWizardPrompter({
@@ -167,7 +167,7 @@ describe("matrix onboarding", () => {
"Matrix auth method": "token",
},
text: {
"Matrix homeserver URL": "http://localhost.localdomain:8008",
"Matrix homeserver URL": "http://localhost:8008",
"Matrix access token": "ops-token",
"Matrix device name (optional)": "",
},
@@ -189,12 +189,10 @@ describe("matrix onboarding", () => {
}
expect(result.cfg.channels?.matrix).toMatchObject({
homeserver: "http://localhost.localdomain:8008",
network: {
dangerouslyAllowPrivateNetwork: true,
},
homeserver: "http://localhost:8008",
accessToken: "ops-token",
});
expect(result.cfg.channels?.matrix?.network).toBeUndefined();
});
it("preserves SecretRef access tokens when keeping existing credentials", async () => {

View File

@@ -0,0 +1 @@
export { mattermostPlugin } from "./src/channel.js";

View File

@@ -18,7 +18,7 @@ export default defineBundledChannelEntry({
description: "Mattermost channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./entry-api.js",
exportName: "mattermostPlugin",
},
runtime: {

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./entry-api.js",
exportName: "mattermostPlugin",
},
});

View File

@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings.js";
import type {
EmbeddingProvider,
EmbeddingProviderRuntime,

View File

@@ -137,6 +137,7 @@ describe("minimax provider hooks", () => {
registerProvider() {},
registerMediaUnderstandingProvider() {},
registerImageGenerationProvider() {},
registerVideoGenerationProvider() {},
registerSpeechProvider() {},
registerWebSearchProvider(provider: unknown) {
webSearchProviders.push(provider);

View File

@@ -31,6 +31,7 @@ describe("ollama web search provider", () => {
const webSearchProviders: unknown[] = [];
plugin.register({
registerMemoryEmbeddingProvider() {},
registerProvider() {},
registerWebSearchProvider(provider: unknown) {
webSearchProviders.push(provider);

View File

@@ -0,0 +1 @@
export { tlonPlugin } from "./src/channel.js";

View File

@@ -118,7 +118,7 @@ export default defineBundledChannelEntry({
description: "Tlon/Urbit channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./channel-entry.js",
exportName: "tlonPlugin",
},
runtime: {

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./channel-entry.js",
exportName: "tlonPlugin",
},
});

View File

@@ -1,4 +1,4 @@
import type { LookupFn, SsrFPolicy } from "../../api.js";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { UrbitAuthError } from "./errors.js";
import { urbitFetch } from "./fetch.js";

View File

@@ -1,4 +1,4 @@
import type { LookupFn, SsrFPolicy } from "../../api.js";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { UrbitHttpError } from "./errors.js";
import { urbitFetch } from "./fetch.js";

View File

@@ -1,4 +1,4 @@
import type { SsrFPolicy } from "../../api.js";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
export {
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
ssrfPolicyFromAllowPrivateNetwork,

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import type { LookupFn, SsrFPolicy } from "../../api.js";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
import { urbitFetch } from "./fetch.js";

View File

@@ -1,14 +1,9 @@
import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
// Mock fetchWithSsrFGuard from the local runtime seam.
vi.mock("../../runtime-api.js", async () => {
const actual =
await vi.importActual<typeof import("../../runtime-api.js")>("../../runtime-api.js");
return {
...actual,
fetchWithSsrFGuard: vi.fn(),
};
});
vi.mock("../../runtime-api.js", () => ({
fetchWithSsrFGuard: vi.fn(),
}));
// Mock the local Tlon upload seam.
vi.mock("../tlon-api.js", () => ({

View File

@@ -1 +1,2 @@
export { whatsappOutbound } from "./src/outbound-adapter.js";
export { resolveWhatsAppRuntimeGroupPolicy } from "./src/runtime-group-policy.js";

View File

@@ -0,0 +1 @@
export { zaloPlugin } from "./src/channel.js";

View File

@@ -6,7 +6,7 @@ export default defineBundledChannelEntry({
description: "Zalo channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./channel-entry.js",
exportName: "zaloPlugin",
},
runtime: {

View File

@@ -1,9 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("zalo runtime api", () => {
it("exports the channel plugin without reentering setup surfaces", async () => {
const runtimeApi = await import("./runtime-api.js");
it("keeps the runtime seam free of the local plugin export", async () => {
const source = fs.readFileSync(path.resolve("extensions/zalo/runtime-api.ts"), "utf8");
expect(runtimeApi.zaloPlugin.id).toBe("zalo");
expect(source.includes('export { zaloPlugin } from "./src/channel.js";')).toBe(false);
expect(source.includes('export * from "./api.js";')).toBe(false);
expect(source.includes('export { setZaloRuntime } from "./src/runtime.js";')).toBe(true);
});
});

View File

@@ -1,7 +1,6 @@
// Private runtime barrel for the bundled Zalo extension.
// Keep this barrel thin and free of local plugin self-imports so the bundled
// entry loader can resolve the channel plugin without re-entering this module.
export { zaloPlugin } from "./src/channel.js";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-runtime";
export type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
specifier: "./channel-entry.js",
exportName: "zaloPlugin",
},
});

View File

@@ -108,6 +108,8 @@ type ZaloMessageAuthorizationResult = {
senderName: string | undefined;
};
const ZALO_POLL_ABORTED = Symbol("zalo-poll-aborted");
function formatZaloError(error: unknown): string {
if (error instanceof Error) {
return error.stack ?? `${error.name}: ${error.message}`;
@@ -135,6 +137,19 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
}
}
async function waitForAbortResult(signal: AbortSignal): Promise<typeof ZALO_POLL_ABORTED> {
await waitForAbortSignal(signal);
return ZALO_POLL_ABORTED;
}
async function waitWithAbort(ms: number, signal: AbortSignal): Promise<boolean> {
const timerResult = await Promise.race([
new Promise<true>((resolve) => setTimeout(() => resolve(true), ms)),
waitForAbortResult(signal),
]);
return timerResult !== ZALO_POLL_ABORTED;
}
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
return registerZaloWebhookTargetInternal(target, {
route: {
@@ -214,7 +229,13 @@ function startPollingLoop(params: ZaloPollingLoopParams) {
}
try {
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
const response = await Promise.race([
getUpdates(token, { timeout: pollTimeout }, fetcher),
waitForAbortResult(abortSignal),
]);
if (response === ZALO_POLL_ABORTED || isStopped() || abortSignal.aborted) {
return;
}
if (response.ok && response.result) {
statusSink?.({ lastInboundAt: Date.now() });
await processUpdate({
@@ -227,7 +248,9 @@ function startPollingLoop(params: ZaloPollingLoopParams) {
// no updates
} else if (!isStopped() && !abortSignal.aborted) {
runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
await new Promise((resolve) => setTimeout(resolve, 5000));
if (!(await waitWithAbort(5000, abortSignal))) {
return;
}
}
}

View File

@@ -1,4 +1,4 @@
import { isDangerousNameMatchingEnabled } from "../runtime-api.js";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import type { ResolvedZalouserAccount } from "./accounts.js";
export function isZalouserMutableGroupEntry(raw: string): boolean {

View File

@@ -19,6 +19,7 @@ const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
const runtimeMetadataFiles = ["package.json", "openclaw.plugin.json"];
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
@@ -179,6 +180,75 @@ const readBuildStamp = (deps) => {
}
};
const readUtf8IfExists = (filePath, fsImpl = fs) => {
try {
return fsImpl.readFileSync(filePath, "utf8");
} catch {
return null;
}
};
const hasCurrentBundledRuntimeOverlay = (deps) => {
const distExtensionsRoot = path.join(deps.distRoot, "extensions");
const runtimeRoot = path.join(deps.cwd, "dist-runtime");
const runtimeExtensionsRoot = path.join(runtimeRoot, "extensions");
if (!deps.fs.existsSync(distExtensionsRoot)) {
return !deps.fs.existsSync(runtimeRoot);
}
if (!deps.fs.existsSync(runtimeExtensionsRoot)) {
return false;
}
const sourcePluginIds = new Set();
let distEntries = [];
try {
distEntries = deps.fs.readdirSync(distExtensionsRoot, { withFileTypes: true });
} catch {
return false;
}
for (const entry of distEntries) {
if (!entry.isDirectory()) {
continue;
}
sourcePluginIds.add(entry.name);
const distPluginDir = path.join(distExtensionsRoot, entry.name);
const runtimePluginDir = path.join(runtimeExtensionsRoot, entry.name);
if (!deps.fs.existsSync(runtimePluginDir)) {
return false;
}
for (const fileName of runtimeMetadataFiles) {
const distValue = readUtf8IfExists(path.join(distPluginDir, fileName), deps.fs);
const runtimeValue = readUtf8IfExists(path.join(runtimePluginDir, fileName), deps.fs);
if (distValue !== runtimeValue) {
return false;
}
}
const distNodeModulesDir = path.join(distPluginDir, "node_modules");
const runtimeNodeModulesDir = path.join(runtimePluginDir, "node_modules");
if (deps.fs.existsSync(distNodeModulesDir) !== deps.fs.existsSync(runtimeNodeModulesDir)) {
return false;
}
}
try {
const runtimeEntries = deps.fs.readdirSync(runtimeExtensionsRoot, { withFileTypes: true });
for (const entry of runtimeEntries) {
if (!entry.isDirectory()) {
continue;
}
if (!sourcePluginIds.has(entry.name)) {
return false;
}
}
} catch {
return false;
}
return true;
};
const hasSourceMtimeChanged = (stampMtime, deps) => {
let latestSourceMtime = null;
for (const sourceRoot of deps.sourceRoots) {
@@ -329,9 +399,12 @@ const runOpenClaw = async (deps) => {
return res.exitCode ?? 1;
};
const syncRuntimeArtifacts = (deps) => {
const syncRuntimeArtifacts = (deps, options = {}) => {
try {
deps.runRuntimePostBuild({ cwd: deps.cwd });
deps.runRuntimePostBuild({
cwd: deps.cwd,
includeBundledRuntimeStaging: options.includeBundledRuntimeStaging,
});
} catch (error) {
logRunner(
`Failed to write runtime build artifacts: ${error?.message ?? "unknown error"}`,
@@ -382,7 +455,12 @@ export async function runNodeMain(params = {}) {
const buildRequirement = resolveBuildRequirement(deps);
if (!buildRequirement.shouldBuild) {
if (!shouldSkipCleanWatchRuntimeSync(deps) && !syncRuntimeArtifacts(deps)) {
if (
!shouldSkipCleanWatchRuntimeSync(deps) &&
!syncRuntimeArtifacts(deps, {
includeBundledRuntimeStaging: !hasCurrentBundledRuntimeOverlay(deps),
})
) {
return 1;
}
return await runOpenClaw(deps);
@@ -410,7 +488,7 @@ export async function runNodeMain(params = {}) {
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
return buildRes.exitCode;
}
if (!syncRuntimeArtifacts(deps)) {
if (!syncRuntimeArtifacts(deps, { includeBundledRuntimeStaging: true })) {
return 1;
}
writeBuildStamp(deps);

View File

@@ -85,8 +85,10 @@ export function runRuntimePostBuild(params = {}) {
copyPluginSdkRootAlias(params);
copyBundledPluginMetadata(params);
writeOfficialChannelCatalog(params);
stageBundledPluginRuntimeDeps(params);
stageBundledPluginRuntime(params);
if (params.includeBundledRuntimeStaging !== false) {
stageBundledPluginRuntimeDeps(params);
stageBundledPluginRuntime(params);
}
writeStableRootRuntimeAliases(params);
copyStaticExtensionAssets(params);
}

View File

@@ -56,7 +56,7 @@ function shouldCopyRuntimeFile(sourcePath) {
function writeRuntimeModuleWrapper(sourcePath, targetPath) {
const specifier = relativeSymlinkTarget(sourcePath, targetPath).replace(/\\/g, "/");
const normalizedSpecifier = specifier.startsWith(".") ? specifier : `./${specifier}`;
fs.writeFileSync(
writeFileIfChanged(
targetPath,
[
`export * from ${JSON.stringify(normalizedSpecifier)};`,
@@ -64,12 +64,71 @@ function writeRuntimeModuleWrapper(sourcePath, targetPath) {
"export default module.default;",
"",
].join("\n"),
"utf8",
);
}
function prepareWritableTarget(targetPath) {
try {
const stat = fs.lstatSync(targetPath);
if (!stat.isFile()) {
removePathIfExists(targetPath);
}
} catch {
// Target missing. Parent creation happens below.
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
}
function writeFileIfChanged(targetPath, contents) {
prepareWritableTarget(targetPath);
const next = String(contents);
try {
if (fs.readFileSync(targetPath, "utf8") === next) {
return;
}
} catch {
// Rewrite when missing or unreadable.
}
fs.writeFileSync(targetPath, next, "utf8");
}
function copyFileIfChanged(sourcePath, targetPath) {
prepareWritableTarget(targetPath);
const next = fs.readFileSync(sourcePath);
try {
const current = fs.readFileSync(targetPath);
if (current.equals(next)) {
return;
}
} catch {
// Rewrite when missing or unreadable.
}
fs.writeFileSync(targetPath, next);
}
function removeStaleRuntimeEntries(sourceDir, targetDir) {
let targetEntries = [];
try {
targetEntries = fs.readdirSync(targetDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of targetEntries) {
if (entry.name === "node_modules") {
continue;
}
const sourcePath = path.join(sourceDir, entry.name);
if (fs.existsSync(sourcePath)) {
continue;
}
removePathIfExists(path.join(targetDir, entry.name));
}
}
function stagePluginRuntimeOverlay(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
removeStaleRuntimeEntries(sourceDir, targetDir);
for (const dirent of fs.readdirSync(sourceDir, { withFileTypes: true })) {
if (dirent.name === "node_modules") {
@@ -99,7 +158,7 @@ function stagePluginRuntimeOverlay(sourceDir, targetDir) {
}
if (shouldCopyRuntimeFile(sourcePath)) {
fs.copyFileSync(sourcePath, targetPath);
copyFileIfChanged(sourcePath, targetPath);
continue;
}
@@ -128,13 +187,14 @@ export function stageBundledPluginRuntime(params = {}) {
return;
}
removePathIfExists(runtimeRoot);
fs.mkdirSync(runtimeExtensionsRoot, { recursive: true });
const sourcePluginIds = new Set();
for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
sourcePluginIds.add(dirent.name);
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
const runtimePluginDir = path.join(runtimeExtensionsRoot, dirent.name);
const distPluginNodeModulesDir = path.join(distPluginDir, "node_modules");
@@ -145,6 +205,16 @@ export function stageBundledPluginRuntime(params = {}) {
sourcePluginNodeModulesDir: distPluginNodeModulesDir,
});
}
for (const dirent of fs.readdirSync(runtimeExtensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
if (sourcePluginIds.has(dirent.name)) {
continue;
}
removePathIfExists(path.join(runtimeExtensionsRoot, dirent.name));
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

View File

@@ -394,13 +394,13 @@ describe("convertTools", () => {
it("handles tools without description", () => {
const tools = [{ name: "ping", description: "", parameters: {} }];
const result = convertTools(tools as Parameters<typeof convertTools>[0]);
const result = convertTools(tools as unknown as Parameters<typeof convertTools>[0]);
expect(result[0]?.name).toBe("ping");
});
it("normalizes truly empty parameter schemas for parameter-free tools", () => {
const tools = [{ name: "ping", description: "No params", parameters: {} }];
const result = convertTools(tools as Parameters<typeof convertTools>[0]);
const result = convertTools(tools as unknown as Parameters<typeof convertTools>[0]);
expect(result[0]?.parameters).toEqual({
type: "object",
properties: {},

View File

@@ -99,10 +99,20 @@ function resolveProviderRuntimeHooks(): ProviderRuntimeHooks | null {
const loaded = requireProviderRuntime(
"../../plugins/provider-runtime.js",
) as unknown as ProviderRuntimeModule;
const hooks = loaded as Partial<ProviderRuntimeHooks>;
if (
typeof hooks.classifyProviderFailoverReasonWithPlugin !== "function" ||
typeof hooks.matchesProviderContextOverflowWithPlugin !== "function"
) {
cachedProviderRuntimeHooks = null;
return cachedProviderRuntimeHooks;
}
const classifyProviderFailoverReasonWithPlugin = hooks.classifyProviderFailoverReasonWithPlugin;
const matchesProviderContextOverflowWithPlugin = hooks.matchesProviderContextOverflowWithPlugin;
cachedProviderRuntimeHooks = {
classifyProviderFailoverReasonWithPlugin: ({ context }) =>
loaded.classifyProviderFailoverReasonWithPlugin({ context }) ?? null,
matchesProviderContextOverflowWithPlugin: loaded.matchesProviderContextOverflowWithPlugin,
classifyProviderFailoverReasonWithPlugin({ context }) ?? null,
matchesProviderContextOverflowWithPlugin,
};
} catch {
cachedProviderRuntimeHooks = null;

View File

@@ -544,11 +544,11 @@ export function createSubscriptionMock(): SubscriptionMock {
let runEmbeddedAttemptPromise:
| Promise<typeof import("./attempt.js").runEmbeddedAttempt>
| undefined;
const ATTEMPT_SPAWN_WORKSPACE_TEST_SPECIFIER = "./attempt.ts?spawn-workspace-test";
async function loadRunEmbeddedAttempt() {
const attemptModuleId = `./attempt.js?spawn-workspace-test=${Date.now()}`;
runEmbeddedAttemptPromise ??= (
import(ATTEMPT_SPAWN_WORKSPACE_TEST_SPECIFIER) as Promise<typeof import("./attempt.js")>
import(attemptModuleId) as Promise<typeof import("./attempt.js")>
).then((mod) => mod.runEmbeddedAttempt);
return await runEmbeddedAttemptPromise;
}

View File

@@ -1,13 +1,5 @@
import type { SubscribeEmbeddedPiSessionParams } from "../../pi-embedded-subscribe.types.js";
type IdleAwareAgent = {
waitForIdle?: (() => Promise<void>) | undefined;
};
type ToolResultFlushManager = {
flushPendingToolResults?: (() => void) | undefined;
clearPendingToolResults?: (() => void) | undefined;
};
import type { FlushPendingToolResultsAfterIdleOptions } from "../wait-for-idle-before-flush.js";
export function buildEmbeddedSubscriptionParams(
params: SubscribeEmbeddedPiSessionParams,
): SubscribeEmbeddedPiSessionParams {
@@ -16,14 +8,11 @@ export function buildEmbeddedSubscriptionParams(
export async function cleanupEmbeddedAttemptResources(params: {
removeToolResultContextGuard?: () => void;
flushPendingToolResultsAfterIdle: (params: {
agent: IdleAwareAgent | null | undefined;
sessionManager: ToolResultFlushManager | null | undefined;
timeoutMs?: number;
clearPendingOnTimeout?: boolean;
}) => Promise<void>;
session?: { agent?: unknown; dispose(): void };
sessionManager: unknown;
flushPendingToolResultsAfterIdle: (
params: FlushPendingToolResultsAfterIdleOptions,
) => Promise<void>;
session?: { agent?: FlushPendingToolResultsAfterIdleOptions["agent"]; dispose(): void };
sessionManager: FlushPendingToolResultsAfterIdleOptions["sessionManager"];
releaseWsSession: (sessionId: string) => void;
sessionId: string;
bundleLspRuntime?: { dispose(): Promise<void> | void };
@@ -37,8 +26,8 @@ export async function cleanupEmbeddedAttemptResources(params: {
}
try {
await params.flushPendingToolResultsAfterIdle({
agent: params.session?.agent as IdleAwareAgent | null | undefined,
sessionManager: params.sessionManager as ToolResultFlushManager | null | undefined,
agent: params.session?.agent,
sessionManager: params.sessionManager,
clearPendingOnTimeout: true,
});
} catch {

View File

@@ -9,6 +9,13 @@ type ToolResultFlushManager = {
export const DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 30_000;
export type FlushPendingToolResultsAfterIdleOptions = {
agent: IdleAwareAgent | null | undefined;
sessionManager: ToolResultFlushManager | null | undefined;
timeoutMs?: number;
clearPendingOnTimeout?: boolean;
};
async function waitForAgentIdleBestEffort(
agent: IdleAwareAgent | null | undefined,
timeoutMs: number,
@@ -40,12 +47,9 @@ async function waitForAgentIdleBestEffort(
}
}
export async function flushPendingToolResultsAfterIdle(opts: {
agent: IdleAwareAgent | null | undefined;
sessionManager: ToolResultFlushManager | null | undefined;
timeoutMs?: number;
clearPendingOnTimeout?: boolean;
}): Promise<void> {
export async function flushPendingToolResultsAfterIdle(
opts: FlushPendingToolResultsAfterIdleOptions,
): Promise<void> {
const timedOut = await waitForAgentIdleBestEffort(
opts.agent,
opts.timeoutMs ?? DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS,

View File

@@ -0,0 +1,48 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const listBootstrapChannelPlugins = vi.hoisted(() => vi.fn());
vi.mock("./plugins/bootstrap-registry.js", () => ({
listBootstrapChannelPlugins,
}));
import {
hasPotentialConfiguredChannels,
listPotentialConfiguredChannelIds,
} from "./config-presence.js";
const tempDirs: string[] = [];
function makeTempStateDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-config-presence-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
listBootstrapChannelPlugins.mockReset();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
describe("config presence persisted auth", () => {
it("ignores bootstrap plugin load failures while probing persisted auth state", () => {
listBootstrapChannelPlugins.mockImplementation(() => {
throw new Error("broken bootstrap plugin");
});
const env = {
OPENCLAW_STATE_DIR: makeTempStateDir(),
} as NodeJS.ProcessEnv;
expect(listPotentialConfiguredChannelIds({}, env)).toEqual([]);
expect(hasPotentialConfiguredChannels({}, env)).toBe(false);
});
});

View File

@@ -39,6 +39,15 @@ function hasPersistedChannelState(env: NodeJS.ProcessEnv): boolean {
return fs.existsSync(resolveStateDir(env, os.homedir));
}
function getBootstrapChannelPluginSafe(channelId: string) {
try {
return getBootstrapChannelPlugin(channelId);
} catch {
// Config discovery must stay resilient while unrelated bundled channels are mid-refactor.
return undefined;
}
}
export function listPotentialConfiguredChannelIds(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -72,7 +81,7 @@ export function listPotentialConfiguredChannelIds(
if (options.includePersistedAuthState !== false && hasPersistedChannelState(env)) {
for (const channelId of channelIds) {
const plugin = getBootstrapChannelPlugin(channelId);
const plugin = getBootstrapChannelPluginSafe(channelId);
if (plugin?.config?.hasPersistedAuthState?.({ cfg, env })) {
configuredChannelIds.add(channelId);
}
@@ -101,7 +110,7 @@ function hasEnvConfiguredChannel(
return false;
}
return channelIds.some((channelId) =>
Boolean(getBootstrapChannelPlugin(channelId)?.config?.hasPersistedAuthState?.({ cfg, env })),
Boolean(getBootstrapChannelPluginSafe(channelId)?.config?.hasPersistedAuthState?.({ cfg, env })),
);
}

View File

@@ -3,10 +3,7 @@ import type { OpenClawConfig } from "../../../config/config.js";
import { listBundledChannelPlugins, setBundledChannelRuntime } from "../bundled.js";
import type { ChannelPlugin } from "../types.js";
import { channelPluginSurfaceKeys, type ChannelPluginSurface } from "./manifest.js";
import {
importBundledChannelContractArtifact,
resolveBundledChannelContractArtifactUrl,
} from "./runtime-artifacts.js";
import { importBundledChannelContractArtifact } from "./runtime-artifacts.js";
type SurfaceContractEntry = {
id: string;
@@ -45,6 +42,10 @@ const sendMessageMatrixMock = vi.hoisted(() =>
})),
);
function buildBundledPluginModuleId(pluginId: string, ...segments: string[]): string {
return ["..", "..", "..", "..", "extensions", pluginId, ...segments].join("/");
}
const lineContractApi = await importBundledChannelContractArtifact<{
listLineAccountIds: () => string[];
resolveDefaultLineAccountId: (cfg: OpenClawConfig) => string | undefined;
@@ -62,12 +63,10 @@ setBundledChannelRuntime("line", {
},
} as never);
vi.mock(resolveBundledChannelContractArtifactUrl("matrix", "runtime-api"), async () => {
const matrixRuntimeApiModuleId = resolveBundledChannelContractArtifactUrl(
"matrix",
"runtime-api",
);
const actual = await vi.importActual(matrixRuntimeApiModuleId);
const matrixSendModuleId = buildBundledPluginModuleId("matrix", "src", "matrix", "send.js");
vi.doMock(matrixSendModuleId, async () => {
const actual = await vi.importActual(matrixSendModuleId);
return {
...actual,
sendMessageMatrix: sendMessageMatrixMock,

View File

@@ -1,6 +1,16 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "../../../extensions/matrix/contract-api.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import {
applySetupAccountConfigPatch,
clearSetupPromotionRuntimeModuleCache,
@@ -14,8 +24,24 @@ function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
const matrixSetupPlugin = {
...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }),
setup: {
singleAccountKeysToMove,
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "matrix", plugin: matrixSetupPlugin, source: "test" }]),
);
});
afterEach(() => {
clearSetupPromotionRuntimeModuleCache();
resetPluginRuntimeStateForTest();
});
describe("applySetupAccountConfigPatch", () => {

View File

@@ -1,10 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { singleAccountKeysToMove } from "../../../extensions/telegram/contract-api.js";
import {
resolveSetupWizardAllowFromEntries,
resolveSetupWizardGroupAllowlist,
} from "../../../test/helpers/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import {
applySingleTokenPromptResult,
buildSingleChannelSecretPromptState,
@@ -60,6 +66,23 @@ import {
splitSetupEntries,
} from "./setup-wizard-helpers.js";
const telegramSetupPlugin = {
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
setup: {
singleAccountKeysToMove,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", plugin: telegramSetupPlugin, source: "test" }]),
);
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
function createPrompter(inputs: string[]) {
return {
text: vi.fn(async () => inputs.shift() ?? ""),

View File

@@ -316,12 +316,15 @@ vi.mock("../infra/clawhub.js", () => ({
const { registerPluginsCli } = await import("./plugins-cli.js");
export { registerPluginsCli };
export function createPluginsProgram() {
const program = new Command();
registerPluginsCli(program);
return program;
}
export function runPluginsCommand(argv: string[]) {
const program = new Command();
const program = createPluginsProgram();
program.exitOverride();
registerPluginsCli(program);
return program.parseAsync(argv, { from: "user" });
}

View File

@@ -1,9 +1,8 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
createPluginsProgram,
loadConfig,
registerPluginsCli,
resetPluginsCliTestState,
runPluginsCommand,
runtimeErrors,
@@ -38,8 +37,7 @@ describe("plugins cli update", () => {
});
it("shows the dangerous unsafe install override in update help", () => {
const program = new Command();
registerPluginsCli(program);
const program = createPluginsProgram();
const pluginsCommand = program.commands.find((command) => command.name() === "plugins");
const updateCommand = pluginsCommand?.commands.find((command) => command.name() === "update");

View File

@@ -1,6 +1,21 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { isChannelConfigured } from "./channel-configured.js";
vi.mock("../channels/plugins/bootstrap-registry.js", async () => {
const actual = await vi.importActual<typeof import("../channels/plugins/bootstrap-registry.js")>(
"../channels/plugins/bootstrap-registry.js",
);
return {
...actual,
getBootstrapChannelPlugin: vi.fn(actual.getBootstrapChannelPlugin),
};
});
async function getBootstrapRegistryMock() {
const module = await import("../channels/plugins/bootstrap-registry.js");
return vi.mocked(module.getBootstrapChannelPlugin);
}
describe("isChannelConfigured", () => {
it("detects Telegram env configuration through the channel plugin seam", () => {
expect(isChannelConfigured({}, "telegram", { TELEGRAM_BOT_TOKEN: "token" })).toBe(true);
@@ -39,4 +54,25 @@ describe("isChannelConfigured", () => {
),
).toBe(true);
});
it("falls back to generic config presence when bootstrap registry loading throws", async () => {
const getBootstrapChannelPlugin = await getBootstrapRegistryMock();
getBootstrapChannelPlugin.mockImplementationOnce(() => {
throw new Error("boom");
});
expect(
isChannelConfigured(
{
channels: {
signal: {
httpPort: 8080,
},
},
},
"signal",
{},
),
).toBe(true);
});
});

View File

@@ -17,12 +17,21 @@ function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boo
return hasMeaningfulChannelConfig(entry);
}
function getBootstrapChannelPluginSafe(channelId: string) {
try {
return getBootstrapChannelPlugin(channelId);
} catch {
// Facade/bootstrap callers must not fail just because an unrelated bundled channel explodes.
return undefined;
}
}
export function isChannelConfigured(
cfg: OpenClawConfig,
channelId: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const plugin = getBootstrapChannelPlugin(channelId);
const plugin = getBootstrapChannelPluginSafe(channelId);
const pluginConfigured = plugin?.config?.hasConfiguredState?.({ cfg, env });
if (pluginConfigured) {
return true;

View File

@@ -15,7 +15,7 @@ vi.mock("../channels/plugins/registry.js", async () => {
);
return {
...actual,
listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args),
listChannelPlugins: () => listChannelPluginsMock(),
};
});
@@ -24,8 +24,7 @@ vi.mock("../plugins/runtime.js", async () => {
await vi.importActual<typeof import("../plugins/runtime.js")>("../plugins/runtime.js");
return {
...actual,
getActivePluginChannelRegistryVersion: (...args: unknown[]) =>
getActivePluginChannelRegistryVersionMock(...args),
getActivePluginChannelRegistryVersion: () => getActivePluginChannelRegistryVersionMock(),
};
});

View File

@@ -5101,6 +5101,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.",
},
verboseDefault: {
type: "string",
enum: ["off", "on", "full"],
title: "Agent Verbose Default",
description:
"Optional per-agent default verbose level. Overrides agents.defaults.verboseDefault for this agent when no per-message or session override is set.",
},
reasoningDefault: {
type: "string",
enum: ["on", "off", "stream"],
@@ -22483,6 +22490,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.",
tags: ["advanced"],
},
"agents.list[].verboseDefault": {
label: "Agent Verbose Default",
help: "Optional per-agent default verbose level. Overrides agents.defaults.verboseDefault for this agent when no per-message or session override is set.",
tags: ["advanced"],
},
"agents.list[].reasoningDefault": {
label: "Agent Reasoning Default",
help: "Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.",

View File

@@ -200,6 +200,8 @@ export const FIELD_HELP: Record<string, string> = {
"Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.",
"agents.list[].thinkingDefault":
"Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.",
"agents.list[].verboseDefault":
"Optional per-agent default verbose level. Overrides agents.defaults.verboseDefault for this agent when no per-message or session override is set.",
"agents.list[].reasoningDefault":
"Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.",
"agents.list[].fastModeDefault":

View File

@@ -63,6 +63,7 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.list[].runtime.acp.mode": "Agent ACP Mode",
"agents.list[].runtime.acp.cwd": "Agent ACP Working Directory",
"agents.list[].thinkingDefault": "Agent Thinking Default",
"agents.list[].verboseDefault": "Agent Verbose Default",
"agents.list[].reasoningDefault": "Agent Reasoning Default",
"agents.list[].fastModeDefault": "Agent Fast Mode Default",
agents: "Agents",

View File

@@ -781,6 +781,7 @@ export const AgentEntrySchema = z
thinkingDefault: z
.enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"])
.optional(),
verboseDefault: z.enum(["off", "on", "full"]).optional(),
reasoningDefault: z.enum(["on", "off", "stream"]).optional(),
fastModeDefault: z.boolean().optional(),
skills: z.array(z.string()).optional(),

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
@@ -6,14 +7,52 @@ import { vi } from "vitest";
import type { ReadConfigFileSnapshotForWriteResult } from "../config/io.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { AgentBinding } from "../config/types.agents.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
import { buildTestConfigSnapshot } from "./test-helpers.config-snapshots.js";
import type {
ConfigFileSnapshot,
OpenClawConfig,
ResolvedSourceConfig,
RuntimeConfig,
} from "../config/types.js";
import { testConfigRoot, testIsNixMode, testState } from "./test-helpers.runtime-state.js";
type GatewayConfigModule = typeof import("../config/config.js");
export function createGatewayConfigModuleMock(actual: GatewayConfigModule): GatewayConfigModule {
const resolveConfigPath = () => path.join(testConfigRoot.value, "openclaw.json");
const hashConfigRaw = (raw: string | null) =>
crypto
.createHash("sha256")
.update(raw ?? "")
.digest("hex");
const buildSnapshot = (params: {
path: string;
exists: boolean;
raw: string | null;
parsed: unknown;
valid: boolean;
runtimeConfig: OpenClawConfig;
issues: Array<{ path: string; message: string }>;
legacyIssues: Array<{ path: string; message: string }>;
}): ConfigFileSnapshot => {
const runtimeConfig = params.runtimeConfig as RuntimeConfig;
const resolved = params.runtimeConfig as ResolvedSourceConfig;
return {
path: params.path,
exists: params.exists,
raw: params.raw,
parsed: params.parsed,
sourceConfig: resolved,
resolved,
valid: params.valid,
runtimeConfig,
config: runtimeConfig,
hash: hashConfigRaw(params.raw),
issues: params.issues,
warnings: [],
legacyIssues: params.legacyIssues,
};
};
const composeTestConfig = (baseConfig: Record<string, unknown>) => {
const fileAgents =
@@ -151,58 +190,58 @@ export function createGatewayConfigModuleMock(actual: GatewayConfigModule): Gate
const readConfigFileSnapshot = async (): Promise<ConfigFileSnapshot> => {
if (testState.legacyIssues.length > 0) {
const raw = JSON.stringify(testState.legacyParsed ?? {});
return buildTestConfigSnapshot({
return buildSnapshot({
path: resolveConfigPath(),
exists: true,
raw,
parsed: testState.legacyParsed ?? {},
valid: false,
config: composeTestConfig({}),
issues: testState.legacyIssues.map((issue) => ({
path: issue.path,
message: issue.message,
})),
legacyIssues: testState.legacyIssues,
runtimeConfig: {},
});
}
const configPath = resolveConfigPath();
try {
await fs.access(configPath);
} catch {
return buildTestConfigSnapshot({
return buildSnapshot({
path: configPath,
exists: false,
raw: null,
parsed: {},
valid: true,
config: composeTestConfig({}),
issues: [],
legacyIssues: [],
runtimeConfig: composeTestConfig({}),
});
}
try {
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
return buildTestConfigSnapshot({
return buildSnapshot({
path: configPath,
exists: true,
raw,
parsed,
valid: true,
config: composeTestConfig(parsed),
issues: [],
legacyIssues: [],
runtimeConfig: composeTestConfig(parsed),
});
} catch (err) {
return buildTestConfigSnapshot({
return buildSnapshot({
path: configPath,
exists: true,
raw: null,
parsed: {},
valid: false,
config: composeTestConfig({}),
issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [],
runtimeConfig: {},
});
}
};

View File

@@ -10,6 +10,7 @@ export const DEFAULT_TIMEOUT_MS = 5000;
export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
anthropic: "Claude",
"github-copilot": "Copilot",
"google-gemini-cli": "Gemini",
minimax: "MiniMax",
"openai-codex": "Codex",
xiaomi: "Xiaomi",
@@ -19,6 +20,7 @@ export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
export const usageProviders: UsageProviderId[] = [
"anthropic",
"github-copilot",
"google-gemini-cli",
"minimax",
"openai-codex",
"xiaomi",

View File

@@ -20,6 +20,7 @@ export type UsageSummary = {
export type UsageProviderId =
| "anthropic"
| "github-copilot"
| "google-gemini-cli"
| "minimax"
| "openai-codex"
| "xiaomi"

View File

@@ -70,6 +70,16 @@ async function writeRuntimePostBuildScaffold(tmp: string): Promise<void> {
await fs.utimes(pluginSdkAliasPath, BUILD_TIME, BUILD_TIME);
}
async function writeRuntimeOverlayFiles(tmp: string, files: Record<string, string>) {
const runtimeFiles = Object.fromEntries(
Object.entries(files).map(([relativePath, contents]) => [
relativePath.replace(/^dist\//u, "dist-runtime/"),
contents,
]),
);
await writeProjectFiles(tmp, runtimeFiles);
}
function expectedBuildSpawn() {
return [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"];
}
@@ -179,7 +189,7 @@ async function runStatusCommand(params: {
spawn: (cmd: string, args: string[]) => ReturnType<typeof createExitedProcess>;
spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string };
env?: Record<string, string>;
runRuntimePostBuild?: (params?: { cwd?: string }) => void;
runRuntimePostBuild?: (params: { cwd: string; includeBundledRuntimeStaging?: boolean }) => void;
}) {
return await runNodeMain({
cwd: params.tmp,
@@ -354,6 +364,80 @@ describe("run-node script", () => {
});
});
it("skips bundled runtime staging on a clean tree when dist-runtime metadata is already current", async () => {
await withTempDir(async (tmp) => {
await setupTrackedProject(tmp, {
files: {
[ROOT_SRC]: "export const value = 1;\n",
[EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
[EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./index.js"]}}\n',
[DIST_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
[DIST_EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./index.js"]}}\n',
},
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, EXTENSION_MANIFEST, EXTENSION_PACKAGE],
buildPaths: [DIST_ENTRY, BUILD_STAMP, DIST_EXTENSION_MANIFEST, DIST_EXTENSION_PACKAGE],
});
await writeRuntimeOverlayFiles(tmp, {
[DIST_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
[DIST_EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./index.js"]}}\n',
});
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
gitHead: "abc123\n",
gitStatus: "",
});
const runRuntimePostBuild = vi.fn();
const exitCode = await runStatusCommand({
tmp,
spawn,
spawnSync,
runRuntimePostBuild,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([statusCommandSpawn()]);
expect(runRuntimePostBuild).toHaveBeenCalledWith({
cwd: tmp,
includeBundledRuntimeStaging: false,
});
});
});
it("stages bundled runtime on a clean tree when dist-runtime metadata is missing", async () => {
await withTempDir(async (tmp) => {
await setupTrackedProject(tmp, {
files: {
[ROOT_SRC]: "export const value = 1;\n",
[EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
[DIST_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
},
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, EXTENSION_MANIFEST],
buildPaths: [DIST_ENTRY, BUILD_STAMP, DIST_EXTENSION_MANIFEST],
});
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
gitHead: "abc123\n",
gitStatus: "",
});
const runRuntimePostBuild = vi.fn();
const exitCode = await runStatusCommand({
tmp,
spawn,
spawnSync,
runRuntimePostBuild,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([statusCommandSpawn()]);
expect(runRuntimePostBuild).toHaveBeenCalledWith({
cwd: tmp,
includeBundledRuntimeStaging: true,
});
});
});
it("returns the build exit code when the compiler step fails", async () => {
await withTempDir(async (tmp) => {
const spawn = (cmd: string, args: string[] = []) => {

View File

@@ -150,14 +150,18 @@ export function loadBundledEntryExportSync<T>(
reference: BundledEntryModuleRef,
): T {
const loaded = loadBundledEntryModuleSync(importMetaUrl, reference.specifier);
const moduleRecord = loaded as Record<string, unknown> | undefined;
const resolved =
loaded && typeof loaded === "object" && "default" in (loaded as Record<string, unknown>)
? (loaded as { default: unknown }).default
moduleRecord && "default" in moduleRecord
? (moduleRecord as { default: unknown }).default
: loaded;
if (!reference.exportName) {
return resolved as T;
}
const record = (resolved ?? loaded) as Record<string, unknown> | undefined;
if (moduleRecord && reference.exportName in moduleRecord) {
return moduleRecord[reference.exportName] as T;
}
const record = resolved as Record<string, unknown> | undefined;
if (!record || !(reference.exportName in record)) {
throw new Error(
`missing export "${reference.exportName}" from bundled entry module ${reference.specifier}`,

View File

@@ -631,6 +631,79 @@ describe("updateNpmInstalledPlugins", () => {
}),
);
});
it("forwards dangerous force unsafe install to ClawHub plugin updates", async () => {
installPluginFromClawHubMock.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: "/tmp/demo",
version: "1.2.4",
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
integrity: "sha256-next",
resolvedAt: "2026-03-22T00:00:00.000Z",
},
});
await updateNpmInstalledPlugins({
config: createClawHubInstallConfig({
pluginId: "demo",
installPath: "/tmp/demo",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
}),
pluginIds: ["demo"],
dangerouslyForceUnsafeInstall: true,
});
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
dangerouslyForceUnsafeInstall: true,
expectedPluginId: "demo",
}),
);
});
it("forwards dangerous force unsafe install to marketplace plugin updates", async () => {
installPluginFromMarketplaceMock.mockResolvedValue({
ok: true,
pluginId: "claude-bundle",
targetDir: "/tmp/claude-bundle",
version: "1.3.0",
extensions: ["index.ts"],
marketplaceName: "Vincent's Claude Plugins",
marketplaceSource: "vincentkoc/claude-marketplace",
marketplacePlugin: "claude-bundle",
});
await updateNpmInstalledPlugins({
config: createMarketplaceInstallConfig({
pluginId: "claude-bundle",
installPath: "/tmp/claude-bundle",
marketplaceName: "Vincent's Claude Plugins",
marketplaceSource: "vincentkoc/claude-marketplace",
marketplacePlugin: "claude-bundle",
}),
pluginIds: ["claude-bundle"],
dangerouslyForceUnsafeInstall: true,
});
expect(installPluginFromMarketplaceMock).toHaveBeenCalledWith(
expect.objectContaining({
marketplace: "vincentkoc/claude-marketplace",
plugin: "claude-bundle",
dangerouslyForceUnsafeInstall: true,
expectedPluginId: "claude-bundle",
}),
);
});
});
describe("syncPluginsForUpdateChannel", () => {

View File

@@ -269,6 +269,55 @@ export async function updateNpmInstalledPlugins(params: {
let next = params.config;
let changed = false;
const buildSharedInstallFields = (pluginId: string) => ({
mode: "update" as const,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
});
const buildNpmInstallParams = (input: {
pluginId: string;
spec: string;
expectedIntegrity?: string;
dryRun: boolean;
}) => ({
...buildSharedInstallFields(input.pluginId),
spec: input.spec,
...(input.dryRun ? { dryRun: true } : {}),
expectedIntegrity: input.expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId: input.pluginId,
dryRun: input.dryRun,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
});
const buildClawHubInstallParams = (input: {
pluginId: string;
spec: string;
baseUrl?: string;
dryRun: boolean;
}) => ({
...buildSharedInstallFields(input.pluginId),
spec: input.spec,
baseUrl: input.baseUrl,
...(input.dryRun ? { dryRun: true } : {}),
});
const buildMarketplaceInstallParams = (input: {
pluginId: string;
marketplace: string;
plugin: string;
dryRun: boolean;
}) => ({
...buildSharedInstallFields(input.pluginId),
marketplace: input.marketplace,
plugin: input.plugin,
...(input.dryRun ? { dryRun: true } : {}),
});
for (const pluginId of targets) {
if (params.skipIds?.has(pluginId)) {
outcomes.push({
@@ -300,6 +349,7 @@ export async function updateNpmInstalledPlugins(params: {
const effectiveSpec =
record.source === "npm" ? (params.specOverrides?.[pluginId] ?? record.spec) : record.spec;
const clawhubSpec = effectiveSpec ?? `clawhub:${record.clawhubPackage ?? ""}`;
const expectedIntegrity =
record.source === "npm" && effectiveSpec === record.spec
? expectedIntegrityForUpdate(record.spec, record.integrity)
@@ -356,40 +406,31 @@ export async function updateNpmInstalledPlugins(params: {
try {
probe =
record.source === "npm"
? await installPluginFromNpmSpec({
spec: effectiveSpec!,
mode: "update",
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
? await installPluginFromNpmSpec(
buildNpmInstallParams({
pluginId,
spec: effectiveSpec!,
expectedIntegrity,
dryRun: true,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
logger,
})
)
: record.source === "clawhub"
? await installPluginFromClawHub({
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
baseUrl: record.clawhubUrl,
mode: "update",
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
});
? await installPluginFromClawHub(
buildClawHubInstallParams({
pluginId,
spec: clawhubSpec,
baseUrl: record.clawhubUrl,
dryRun: true,
}),
)
: await installPluginFromMarketplace(
buildMarketplaceInstallParams({
pluginId,
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
dryRun: true,
}),
);
} catch (err) {
outcomes.push({
pluginId,
@@ -413,7 +454,7 @@ export async function updateNpmInstalledPlugins(params: {
: record.source === "clawhub"
? formatClawHubInstallFailure({
pluginId,
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
spec: clawhubSpec,
phase: "check",
error: probe.error,
})
@@ -457,37 +498,31 @@ export async function updateNpmInstalledPlugins(params: {
try {
result =
record.source === "npm"
? await installPluginFromNpmSpec({
spec: effectiveSpec!,
mode: "update",
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
? await installPluginFromNpmSpec(
buildNpmInstallParams({
pluginId,
spec: effectiveSpec!,
expectedIntegrity,
dryRun: false,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
logger,
})
)
: record.source === "clawhub"
? await installPluginFromClawHub({
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
baseUrl: record.clawhubUrl,
mode: "update",
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
});
? await installPluginFromClawHub(
buildClawHubInstallParams({
pluginId,
spec: clawhubSpec,
baseUrl: record.clawhubUrl,
dryRun: false,
}),
)
: await installPluginFromMarketplace(
buildMarketplaceInstallParams({
pluginId,
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
dryRun: false,
}),
);
} catch (err) {
outcomes.push({
pluginId,
@@ -511,7 +546,7 @@ export async function updateNpmInstalledPlugins(params: {
: record.source === "clawhub"
? formatClawHubInstallFailure({
pluginId,
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
spec: clawhubSpec,
phase: "update",
error: result.error,
})
@@ -549,7 +584,7 @@ export async function updateNpmInstalledPlugins(params: {
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "clawhub",
spec: effectiveSpec ?? record.spec ?? `clawhub:${record.clawhubPackage!}`,
spec: effectiveSpec ?? record.spec ?? clawhubSpec,
installPath: result.targetDir,
version: nextVersion,
integrity: clawhubResult.clawhub.integrity,

View File

@@ -75,6 +75,7 @@ export function describeBundledMetadataOnlyChannelCatalogContract(params: {
JSON.stringify({ id: params.pluginId, channels: [params.meta.id], configSchema: {} }),
"utf8",
);
fs.writeFileSync(path.join(bundledDir, "index.js"), "export default {};\n", "utf8");
const entry = listChannelPluginCatalogEntries({
env: {

View File

@@ -1,4 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { bluebubblesPlugin } from "../../../extensions/bluebubbles/api.js";
import {
discordPlugin,
discordThreadBindingTesting,
} from "../../../extensions/discord/test-api.js";
import { feishuPlugin, feishuThreadBindingTesting } from "../../../extensions/feishu/api.js";
import { imessagePlugin } from "../../../extensions/imessage/api.js";
import { resetMatrixThreadBindingsForTests } from "../../../extensions/matrix/api.js";
import { matrixPlugin, setMatrixRuntime } from "../../../extensions/matrix/test-api.js";
import {
resetTelegramThreadBindingsForTests,
telegramPlugin,
} from "../../../extensions/telegram/test-api.js";
import { getSessionBindingContractRegistry } from "../../../src/channels/plugins/contracts/registry-session-binding.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import {
@@ -13,10 +26,6 @@ import {
import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import type { PluginRuntime } from "../../../src/plugins/runtime/index.js";
import {
loadBundledPluginPublicSurfaceSync,
loadBundledPluginTestApiSync,
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";
type DiscordThreadBindingTesting = {
@@ -27,61 +36,27 @@ type ResetTelegramThreadBindingsForTests = () => Promise<void>;
let discordThreadBindingTestingCache: DiscordThreadBindingTesting | undefined;
let resetTelegramThreadBindingsForTestsCache: ResetTelegramThreadBindingsForTests | undefined;
let feishuApiPromise: Promise<typeof import("../../../extensions/feishu/api.js")> | undefined;
let matrixApiPromise: Promise<typeof import("../../../extensions/matrix/api.js")> | undefined;
let bluebubblesPluginCache: ChannelPlugin | undefined;
let discordPluginCache: ChannelPlugin | undefined;
let feishuPluginCache: ChannelPlugin | undefined;
let imessagePluginCache: ChannelPlugin | undefined;
let matrixPluginCache: ChannelPlugin | undefined;
let setMatrixRuntimeCache: ((runtime: PluginRuntime) => void) | undefined;
let telegramPluginCache: ChannelPlugin | undefined;
function getBluebubblesPlugin(): ChannelPlugin {
if (!bluebubblesPluginCache) {
({ bluebubblesPlugin: bluebubblesPluginCache } = loadBundledPluginPublicSurfaceSync<{
bluebubblesPlugin: ChannelPlugin;
}>({ pluginId: "bluebubbles", artifactBasename: "index.js" }));
}
return bluebubblesPluginCache;
return bluebubblesPlugin as unknown as ChannelPlugin;
}
function getDiscordPlugin(): ChannelPlugin {
if (!discordPluginCache) {
({ discordPlugin: discordPluginCache } = loadBundledPluginTestApiSync<{
discordPlugin: ChannelPlugin;
}>("discord"));
}
return discordPluginCache;
return discordPlugin as unknown as ChannelPlugin;
}
function getFeishuPlugin(): ChannelPlugin {
if (!feishuPluginCache) {
({ feishuPlugin: feishuPluginCache } = loadBundledPluginPublicSurfaceSync<{
feishuPlugin: ChannelPlugin;
}>({ pluginId: "feishu", artifactBasename: "api.js" }));
}
return feishuPluginCache;
return feishuPlugin as unknown as ChannelPlugin;
}
function getIMessagePlugin(): ChannelPlugin {
if (!imessagePluginCache) {
({ imessagePlugin: imessagePluginCache } = loadBundledPluginPublicSurfaceSync<{
imessagePlugin: ChannelPlugin;
}>({ pluginId: "imessage", artifactBasename: "api.js" }));
}
return imessagePluginCache;
return imessagePlugin as unknown as ChannelPlugin;
}
function getMatrixPlugin(): ChannelPlugin {
if (!matrixPluginCache) {
({ matrixPlugin: matrixPluginCache, setMatrixRuntime: setMatrixRuntimeCache } =
loadBundledPluginTestApiSync<{
matrixPlugin: ChannelPlugin;
setMatrixRuntime: (runtime: PluginRuntime) => void;
}>("matrix"));
}
return matrixPluginCache;
setMatrixRuntimeCache ??= setMatrixRuntime;
return matrixPlugin as unknown as ChannelPlugin;
}
function getSetMatrixRuntime(): (runtime: PluginRuntime) => void {
@@ -92,42 +67,29 @@ function getSetMatrixRuntime(): (runtime: PluginRuntime) => void {
}
function getTelegramPlugin(): ChannelPlugin {
if (!telegramPluginCache) {
({ telegramPlugin: telegramPluginCache } = loadBundledPluginTestApiSync<{
telegramPlugin: ChannelPlugin;
}>("telegram"));
}
return telegramPluginCache;
return telegramPlugin as unknown as ChannelPlugin;
}
function getDiscordThreadBindingTesting(): DiscordThreadBindingTesting {
if (!discordThreadBindingTestingCache) {
({ discordThreadBindingTesting: discordThreadBindingTestingCache } =
loadBundledPluginTestApiSync<{
discordThreadBindingTesting: DiscordThreadBindingTesting;
}>("discord"));
discordThreadBindingTestingCache = discordThreadBindingTesting;
}
return discordThreadBindingTestingCache;
}
function getResetTelegramThreadBindingsForTests(): ResetTelegramThreadBindingsForTests {
if (!resetTelegramThreadBindingsForTestsCache) {
({ resetTelegramThreadBindingsForTests: resetTelegramThreadBindingsForTestsCache } =
loadBundledPluginTestApiSync<{
resetTelegramThreadBindingsForTests: ResetTelegramThreadBindingsForTests;
}>("telegram"));
resetTelegramThreadBindingsForTestsCache = resetTelegramThreadBindingsForTests;
}
return resetTelegramThreadBindingsForTestsCache;
}
async function getFeishuThreadBindingTesting() {
feishuApiPromise ??= import("../../../extensions/feishu/api.js");
return (await feishuApiPromise).feishuThreadBindingTesting;
return feishuThreadBindingTesting;
}
async function getResetMatrixThreadBindingsForTests() {
matrixApiPromise ??= import("../../../extensions/matrix/api.js");
return (await matrixApiPromise).resetMatrixThreadBindingsForTests;
return resetMatrixThreadBindingsForTests;
}
function resolveSessionBindingContractRuntimeConfig(id: string) {