mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 16:23:54 +08:00
Compare commits
26 Commits
v2026.6.10
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af826ae7fc | ||
|
|
f33b5426aa | ||
|
|
df1a008584 | ||
|
|
cc7cbc0580 | ||
|
|
2dc0ea95d7 | ||
|
|
224f195709 | ||
|
|
c422f10aef | ||
|
|
619da391ab | ||
|
|
2d886f03a7 | ||
|
|
6a53001e2f | ||
|
|
0843ad5ad1 | ||
|
|
960a631b25 | ||
|
|
607d341451 | ||
|
|
b6da5443fc | ||
|
|
a84858d315 | ||
|
|
23549694f7 | ||
|
|
4a5fe2e0e7 | ||
|
|
32ae0eba54 | ||
|
|
24f76d04eb | ||
|
|
5b53ddcc5f | ||
|
|
51ff658586 | ||
|
|
be85d9aaec | ||
|
|
fd1b355f84 | ||
|
|
b9a9290dfc | ||
|
|
b141da4ca9 | ||
|
|
4857f9d0c2 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -1,5 +1,7 @@
|
||||
name: CI
|
||||
|
||||
# Keep PR CI synchronized on branch updates.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
2
extensions/discord/channel-entry.ts
Normal file
2
extensions/discord/channel-entry.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { discordPlugin } from "./src/channel.js";
|
||||
export { discordSetupPlugin } from "./src/channel.setup.js";
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
1
extensions/line/src/monitor.runtime.ts
Normal file
1
extensions/line/src/monitor.runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { monitorLineProvider } from "./monitor.js";
|
||||
1
extensions/line/src/probe.runtime.ts
Normal file
1
extensions/line/src/probe.runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { probeLineBot } from "./probe.js";
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
1
extensions/mattermost/entry-api.ts
Normal file
1
extensions/mattermost/entry-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { mattermostPlugin } from "./src/channel.js";
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -137,6 +137,7 @@ describe("minimax provider hooks", () => {
|
||||
registerProvider() {},
|
||||
registerMediaUnderstandingProvider() {},
|
||||
registerImageGenerationProvider() {},
|
||||
registerVideoGenerationProvider() {},
|
||||
registerSpeechProvider() {},
|
||||
registerWebSearchProvider(provider: unknown) {
|
||||
webSearchProviders.push(provider);
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("ollama web search provider", () => {
|
||||
const webSearchProviders: unknown[] = [];
|
||||
|
||||
plugin.register({
|
||||
registerMemoryEmbeddingProvider() {},
|
||||
registerProvider() {},
|
||||
registerWebSearchProvider(provider: unknown) {
|
||||
webSearchProviders.push(provider);
|
||||
|
||||
1
extensions/tlon/channel-entry.ts
Normal file
1
extensions/tlon/channel-entry.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { tlonPlugin } from "./src/channel.js";
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SsrFPolicy } from "../../api.js";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
export {
|
||||
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { whatsappOutbound } from "./src/outbound-adapter.js";
|
||||
export { resolveWhatsAppRuntimeGroupPolicy } from "./src/runtime-group-policy.js";
|
||||
|
||||
1
extensions/zalo/channel-entry.ts
Normal file
1
extensions/zalo/channel-entry.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { zaloPlugin } from "./src/channel.js";
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
src/channels/config-presence.persisted-auth.test.ts
Normal file
48
src/channels/config-presence.persisted-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 })),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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() ?? ""),
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@ export type UsageSummary = {
|
||||
export type UsageProviderId =
|
||||
| "anthropic"
|
||||
| "github-copilot"
|
||||
| "google-gemini-cli"
|
||||
| "minimax"
|
||||
| "openai-codex"
|
||||
| "xiaomi"
|
||||
|
||||
@@ -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[] = []) => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user